axum-vite is a utility crate that simplifies the integration between an Axum backend and a Vite 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.
In many Rust + Frontend workflows, developers face a choice between two frictions:
- The Two-Server Problem: Running the backend and frontend separately, dealing with CORS, and managing two different ports in the browser.
- The Restart Problem: Using
cargo-watchto 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.
- 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_dirto 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.
Runnable examples live in examples/.
The 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:
# 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-spaThen 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
table and Auto-Spawn below.
Tip
Auto-restart on Rust changes: install cargo-watch and replace cargo run with:
cargo watch -x 'run -p basic-spa'Vite's HMR handles frontend changes instantly β cargo-watch covers the Rust side. To automate the production frontend build, see Automating the frontend build.
You can use a server-side template engine to own the index.html and only use axum-vite for assets and HMR.
The template-askama example shows how to integrate Askama. 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:
# Terminal 1 β start the frontend
cd examples/template-askama/frontend
npm install && npm run dev
# Terminal 2 β start the backend
cargo run -p template-askamaThe template-sailfish example demonstrates the same pattern using Sailfish, a fast and simple template engine. This example is configured to autostart vite dev server.
In order to run, install frontend dependencies:
cd examples/template-sailfish/frontend
npm installand then start the backend:
cargo run -p template-sailfishThen open http://localhost:3000.
[dependencies]
axum-vite = "0.3.3"
include_dir = "0.7" # required β see Known Limitationsuse 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.
You can configure the crate via ViteConfig or environment variables:
| Field | Env Var | Default | Description |
|---|---|---|---|
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:
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()
};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:
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:
VITE_AUTO_START=1 VITE_ROOT=examples/basic-spa/frontend cargo run -p basic-spaThe 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).
axum-vite uses the log facade. Hook it up with any compatible logger (e.g. env_logger).
| Level | When |
|---|---|
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. |
# 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- Development: Run your Rust server. It proxies frontend to Vite. You get instant HMR.
- Build: Run
npm run buildinside your frontend directory to generatedist/. This must happen beforecargo build --releaseβ the macro captures the folder contents at compile time. If you use Option B (template engine), also setbuild: { manifest: true }invite.configsodist/.vite/manifest.jsonis generated for production asset path resolution. - Production: Pass
axum_vite::embedded_dir!("$CARGO_MANIFEST_DIR/dist")toViteConfig::from_envβ the macro embedsdist/into the binary at compile time and the crate automatically switches from proxying to serving those files. Runcargo build --release; the resulting binary is self-contained with no separate web server ordist/folder needed at runtime.
If you want cargo build --release to run npm run build automatically, add a build.rs to the same crate that calls embedded_dir!:
// 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.
There are two approaches depending on who generates your HTML:
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.
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.
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/"):
// 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:
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()<!-- In your base template: -->
<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:
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> valuesMulti-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:
// 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 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>.
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.
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:
[dependencies]
axum-vite = "0.3.3"
include_dir = "0.7"