Skip to content

gko/axum-vite

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

49 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

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.

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.

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"

About

πŸ¦€ ⚑️ Seamless Axum and Vite integration: proxies to Vite in development and embeds the frontend directly into the Rust binary for production.

Topics

Resources

License

MIT, Apache-2.0 licenses found

Licenses found

MIT
LICENSE
Apache-2.0
LICENSE-APACHE

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages