axum-vite 0.3.3

Seamless Axum and Vite integration: proxies to Vite in development and embeds the frontend directly into the Rust binary for production.
Documentation

axum-vite

crates.io tests clippy

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:

  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/.

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
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 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
cd examples/template-askama/frontend
npm install && npm run dev

# Terminal 2 — start the backend
cargo run -p template-askama

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:

cd examples/template-sailfish/frontend
npm install

and then start the backend:

cargo run -p template-sailfish

Then open http://localhost:3000.

Embedding into your own project

Add the dependency

[dependencies]
axum-vite = "0.3.3"
include_dir = "0.7"  # required — see Known Limitations

Wire up the router

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:

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()
};

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 {
    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-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 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

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!:

// 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.

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/"):

// 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> 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(
    "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>.

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:

[dependencies]
axum-vite = "0.3.3"
include_dir = "0.7"