axum-vite
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.
Why axum-vite?
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.
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_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.
Quick Start
Runnable examples live in examples/.
Basic SPA application
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
&&
# Terminal 2 — start the backend (proxies frontend → Vite)
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=trueandVITE_ROOT=examples/basic-spa/frontend, then callconfig.maybe_spawn_dev_server()inmain— see the Configuration table and 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 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
&&
# Terminal 2 — start the backend
Sailfish
The 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:
and then start the backend:
Then open http://localhost:3000.
Embedding into your own project
Add the dependency
[]
= "0.3.3"
= "0.7" # required — see Known Limitations
Wire up the router
use Router;
use ;
async
$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:
| 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 PathBuf;
use ;
let config = ViteConfig ;
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:
let config = ViteConfig ;
// 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
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 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
# See only warnings and above (recommended for normal dev)
RUST_LOG=axum_vite=warn
Workflow
- 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.
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!:
// build.rs
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.
use Router;
use ;
let config = from_env;
let app = new
.route
.merge;
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/"):
// 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 Arc;
use ;
use ;
let config = new;
let static_prefix = format!;
let app = new
.route
.nest
.with_state;
// In each handler:
// let hmr = config.hmr_scripts(); // → empty string in release builds
// MyTemplate { hmr, … }.render()
<!-- In your base template: -->
{{ hmr_scripts|safe }}
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 ;
// 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:
// 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;
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.origininvite.config. It causes Vite to rewrite asset paths in ways that break the prefix, producing 404s in dev. Useserver.hmr.host/server.hmr.portif 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:
[]
= "0.3.3"
= "0.7"