# axum-vite
[](https://crates.io/crates/axum-vite)  
`axum-vite` is a utility crate that simplifies the integration between an [Axum](https://github.com/tokio-rs/axum) backend and a [Vite](https://vite.dev/) frontend.
It enables a **"Single Binary"** developer experience: during development, your Axum server acts as a reverse proxy to the Vite dev server, providing seamless Hot Module Replacement (HMR). In production, it serves pre-built assets embedded directly into the Rust binary.
## Why axum-vite?
In many Rust + Frontend workflows, developers face a choice between two frictions:
1. **The Two-Server Problem**: Running the backend and frontend separately, dealing with CORS, and managing two different ports in the browser.
2. **The Restart Problem**: Using `cargo-watch` to rebuild the binary on every change, which destroys the fast, partial hot-reload experience that Vite provides.
`axum-vite` solves this by making the Axum server the single entry point. You get the convenience of one URL (`localhost:3000`) and the speed of Vite's HMR, without the overhead of manual proxy configuration.
## Features
- **Transparent Proxying**: Automatically forwards requests to the Vite dev server in debug mode.
- **Header Preservation**: Forwards crucial headers (like `Accept`) so Vite can correctly serve CSS as stylesheets instead of JS modules.
- **Embedded Assets**: Uses `include_dir` to serve built assets from the binary in release mode.
- **Auto-Spawn**: Optionally starts the Vite dev server as a child process on startup.
- **Env-Driven Config**: Configure ports, roots, and commands via environment variables.
## Quick Start
Runnable examples live in [`examples/`](examples/).
### Basic SPA application
The [`basic-spa`](examples/basic-spa/) example pairs a minimal Axum server with a Vite + React frontend (generated by following https://vite.dev/guide) — no configuration needed to try it out:
In order to run:
```sh
# Terminal 1 — start the frontend
cd examples/basic-spa/frontend
npm install && npm run dev
# Terminal 2 — start the backend (proxies frontend → Vite)
cargo run -p basic-spa
```
Then open <http://localhost:3000>.
> [!NOTE]
> **Why two terminals?** Running Vite separately gives you independent logs and lets you
> restart either server without affecting the other. If you prefer a single command, set
> `VITE_AUTO_START=true` and `VITE_ROOT=examples/basic-spa/frontend`, then call
> `config.maybe_spawn_dev_server()` in `main` — see the [Configuration](#configuration)
> table and [Auto-Spawn](#auto-spawn) below.
### Template-based applications
You can use a server-side template engine to own the `index.html` and only use `axum-vite` for assets and HMR.
#### Askama
The [`template-askama`](examples/template-askama/) example shows how to integrate [Askama](https://askama.rs/). It includes a manifest reader that resolves production asset paths at startup and demonstrates a multi-entry (MPA) setup with a `/dashboard` page.
In order to run:
```sh
# Terminal 1 — start the frontend
cd examples/template-askama/frontend
npm install && npm run dev
# Terminal 2 — start the backend
cargo run -p template-askama
```
#### Sailfish
The [`template-sailfish`](examples/template-sailfish/) example demonstrates the same pattern using [Sailfish](https://github.com/rust-sailfish/sailfish), a fast and simple template engine. This example is configured to autostart `vite` dev server.
In order to run, install frontend dependencies:
```sh
cd examples/template-sailfish/frontend
npm install
```
and then start the backend:
```sh
cargo run -p template-sailfish
```
Then open <http://localhost:3000>.
### Embedding into your own project
### Add the dependency
```toml
[dependencies]
axum-vite = "0.3.3"
include_dir = "0.7" # required — see Known Limitations
```
### Wire up the router
```rust
use axum::Router;
use axum_vite::{ViteConfig, spa_router};
#[tokio::main]
async fn main() {
// embedded_dir! embeds dist at compile time in release builds,
// and returns None in debug builds — no #[cfg] boilerplate needed.
let config = ViteConfig::from_env(axum_vite::embedded_dir!("$CARGO_MANIFEST_DIR/dist"));
// (Optional) Auto-start the Vite dev server — no-op in release builds.
// Keep the handle alive — dropping it kills the child process.
let _dev_server = config.maybe_spawn_dev_server();
let app = Router::new()
// Your API routes go here — they take priority over the SPA catch-all.
// .route("/api/hello", get(|| async { "hello" }))
.merge(spa_router(config));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
```
`$CARGO_MANIFEST_DIR` is always set by Cargo at compile time, so no extra environment variables are required for release builds. In debug mode the `include_dir!` call inside the macro is not compiled at all — you can `cargo run` without building the frontend first.
## Configuration
You can configure the crate via `ViteConfig` or environment variables:
| `dev_port` | `VITE_PORT` | `5173` | Port of the Vite dev server. |
| `prefix` | `VITE_STATIC_PREFIX` | `/static/` | URL prefix for assets. |
| `frontend_root` | `VITE_ROOT` | `None` | Absolute path to the Vite project root. |
| `dev_command` | `VITE_DEV_CMD` | `npm run dev` | Command used to start Vite. |
| `auto_start` | `VITE_AUTO_START` | `false` | Whether to spawn Vite on startup. |
| `framework` | `VITE_FRAMEWORK` | `none` | Frontend framework for HMR preamble (`react`, `vue`, `svelte`). |
| `dev_host` | `VITE_DEV_HOST` | `localhost` | Hostname of the Vite dev server. |
| `dev_script` | `VITE_DEV_SCRIPT` | `src/main.tsx` | Source path served in dev mode by `entry_assets()`. |
| `manifest_key` | `VITE_MANIFEST_KEY` | `index.html` | Manifest key looked up in production by `entry_assets()`. |
All fields are public, so you can construct `ViteConfig` directly instead of using `from_env` — useful when you want compile-time config or don't want environment variables involved:
```rust
use std::path::PathBuf;
use axum_vite::{ViteConfig, frameworks::Framework};
let config = ViteConfig {
dev_port: 5173,
frontend_root: Some(PathBuf::from("frontend")),
framework: Framework::React,
auto_start: true,
..ViteConfig::default()
};
```
## Auto-Spawn
`maybe_spawn_dev_server()` is the recommended way to start Vite automatically. It encapsulates
the `#[cfg(debug_assertions)]` guard, the `auto_start` flag check, and error logging — call it
once and keep the handle alive:
```rust
let config = ViteConfig {
frontend_root: Some(PathBuf::from("frontend")),
auto_start: true,
..ViteConfig::default()
};
// No-op in release builds. In dev: spawns Vite if auto_start is true.
let _dev_server = config.maybe_spawn_dev_server();
```
Or via environment variables:
```sh
VITE_AUTO_START=1 VITE_ROOT=examples/basic-spa/frontend cargo run -p basic-spa
```
The lower-level `spawn_dev_server(&config)` free function is still available if you need
the `Result` directly (e.g. to hard-fail on spawn errors).
## Logging
`axum-vite` uses the [`log`](https://docs.rs/log) facade. Hook it up with any compatible logger (e.g. `env_logger`).
| `info` | Dev server spawned on startup. |
| `trace` | Every proxied request in dev mode — silent by default, useful for debugging. |
| `warn` | Dev server unreachable; 404s in release mode. |
| `debug` | Successful static file served in release mode. |
```sh
# See every proxied request during development
RUST_LOG=axum_vite=trace cargo run
# See only warnings and above (recommended for normal dev)
RUST_LOG=axum_vite=warn cargo run
```
## Workflow
1. **Development**: Run your Rust server. It proxies frontend to Vite. You get instant HMR.
2. **Build**: Run `npm run build` inside your frontend directory to generate `dist/`. This must happen **before** `cargo build --release` — the macro captures the folder contents at compile time. If you use Option B (template engine), also set `build: { manifest: true }` in `vite.config` so `dist/.vite/manifest.json` is generated for production asset path resolution.
3. **Production**: Pass `axum_vite::embedded_dir!("$CARGO_MANIFEST_DIR/dist")` to `ViteConfig::from_env` — the macro embeds `dist/` into the binary at compile time and the crate automatically switches from proxying to serving those files. Run `cargo build --release`; the resulting binary is self-contained with no separate web server or `dist/` folder needed at runtime.
### Automating the frontend build
If you want `cargo build --release` to run `npm run build` automatically, add a `build.rs` to the same crate that calls `embedded_dir!`:
```rust
// build.rs
fn main() {
if std::env::var("PROFILE").as_deref() == Ok("release") {
println!("cargo:rerun-if-changed=frontend/src");
let status = std::process::Command::new("npm")
.args(["run", "build"])
.current_dir("frontend")
.status()
.expect("npm run build failed");
assert!(status.success(), "npm run build exited with {status}");
}
}
```
Adjust `"frontend"` and the `rerun-if-changed` path to match your layout. This is not built into the crate because your project may use `bun`, `deno`, or `pnpm`; you may want to skip it in CI; and installing dependencies before the build runs is your responsibility.
## Serving HTML
There are two approaches depending on who generates your HTML:
### Option A — Vite owns `index.html` (most projects)
Use `spa_router`. In dev mode it proxies `/` directly to the Vite dev server, so Vite injects the HMR preamble itself. No `VITE_FRAMEWORK` needed.
```rust
use axum::Router;
use axum_vite::{ViteConfig, spa_router};
let config = ViteConfig::from_env(axum_vite::embedded_dir!("$CARGO_MANIFEST_DIR/dist"));
let app = Router::new()
.route("/api/hello", axum::routing::get(|| async { "hello" }))
.merge(spa_router(config));
```
API routes registered before `spa_router` take priority over the SPA catch-all.
### Option B — your template engine owns `index.html` (Askama, Tera, MiniJinja, …)
When your template engine renders HTML, axum-vite’s role is narrower: it serves
assets and provides the HMR preamble string. You build the rest.
**Set `base` in `vite.config`** to match `ViteConfig::prefix` (default `"/static/"`):
```ts
// vite.config.ts
export default defineConfig({
base: '/static/', // must match VITE_STATIC_PREFIX / ViteConfig::prefix
plugins: [react()],
})
```
**Wire up the asset router** under your static prefix and pass `hmr_scripts()` to
every rendered template:
```rust
use std::sync::Arc;
use axum::{Router, extract::State, routing::get};
use axum_vite::{ViteConfig, router as asset_router};
let config = Arc::new(ViteConfig::from_env(axum_vite::embedded_dir!("$CARGO_MANIFEST_DIR/dist")));
let static_prefix = format!("/{}", config.prefix.trim_matches('/'));
let app = Router::new()
.route("/", get(my_handler))
.nest(&static_prefix, asset_router((*config).clone()))
.with_state(config.clone());
// In each handler:
// let hmr = config.hmr_scripts(); // → empty string in release builds
// MyTemplate { hmr, … }.render()
```
```html
<head>
{{ hmr_scripts|safe }}
</head>
```
Set `VITE_FRAMEWORK=react` (or `vue` / `svelte`) so the correct HMR preamble is generated.
**Production `<script>` and `<link>` paths**: Vite content-hashes JS/CSS filenames in
production (`assets/main-A1b2C3.js`). The correct paths come from `dist/.vite/manifest.json`.
Call `config.entry_assets()` once at startup — it reads the embedded manifest in
release builds and falls back to source paths in dev:
```rust
let config = ViteConfig {
framework: Framework::React,
..ViteConfig::from_env(embedded_dir!("$CARGO_MANIFEST_DIR/frontend/dist"))
};
// Resolves hashed paths from manifest in release; dev_script in dev.
let entry = config.entry_assets();
// entry.script → the <script src> value
// entry.stylesheets → Vec of <link href> values
```
**Multi-entry apps (MPA)**: if your app has pages that load different JS bundles,
use `entry_assets_for` to resolve a secondary entry by its manifest key and dev
source path independently:
```rust
// Primary entry — uses ViteConfig::manifest_key + ViteConfig::dev_script
let entry = config.entry_assets();
// Secondary entry — manifest key and dev source path supplied explicitly.
// In dev mode the manifest key is ignored; dev_script is served directly by Vite.
// In production the manifest key is looked up in dist/.vite/manifest.json.
let widget_entry = config.entry_assets_for(
"src/widget.tsx", // manifest key (same as dev path when using source-file inputs)
"src/widget.tsx", // dev script path
);
```
See the [`template-askama`](examples/template-askama/) example for a working
MPA setup with a `/dashboard` page that loads only the `widget` chunk.
> [!WARNING]
> **Do not set `server.origin`** in `vite.config`. It causes Vite to rewrite asset paths
> in ways that break the prefix, producing 404s in dev. Use `server.hmr.host` / `server.hmr.port`
> if you need explicit WebSocket control.
> [!TIP]
> **If HMR is not working**, the most likely reason is that `hmr_scripts()` is missing from the
> template `<head>`.
## Known Limitations
### HMR Preamble only applies to template-based setups
`VITE_FRAMEWORK` and `hmr_scripts()` are only needed when you render HTML
server-side (e.g. Askama) and call `hmr_scripts()` in your template. When
using `spa_router`, `index.html` is proxied directly from the Vite dev server
which already injects the React Refresh preamble itself — `VITE_FRAMEWORK` has
no effect and can be omitted.
The framework-specific preamble code is **hardcoded** in this crate and may
lag behind framework plugin releases. If HMR stops working after a plugin
upgrade (browser console shows "can't detect preamble"), check the plugin's
source for the expected preamble and open an issue or PR.
### `include_dir` must be a direct dependency
The `embedded_dir!` macro expands to an `include_dir::include_dir!` call inside
your crate. Rust proc-macros resolve against the *calling* crate's dependency
graph, not the library's. If `include_dir` is only a transitive dependency (pulled
in by `axum-vite` alone), the proc-macro won't be in scope and you'll get a compile
error. Always add it explicitly:
```toml
[dependencies]
axum-vite = "0.3.3"
include_dir = "0.7"
```