axum-vite 0.1.0

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

axum-vite

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

A runnable example lives in examples/. It pairs a minimal Axum server with a Vite + React frontend (generated by following https://vite.dev/guide) — no configuration needed to try it out:

# Terminal 1 — start the frontend
cd examples/frontend
npm install && npm run dev

# Terminal 2 — start the backend (proxies frontend → Vite)
cargo run --example basic

Then open http://localhost:3000.

Embedding into your own project

Add the dependency

[dependencies]
axum-vite = "0.1.0"

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.
    // Keep the handle alive — dropping it immediately kills the child process.
    #[cfg(debug_assertions)]
    let _dev_server = config.auto_start
        .then(|| axum_vite::spawn_dev_server(&config).expect("Failed to start Vite"));

    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.

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

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 to generate the dist folder.
  3. Production: Pass axum_vite::embedded_dir!("$CARGO_MANIFEST_DIR/dist") to ViteConfig::from_env. The macro embeds the folder into your binary at compile time and the crate automatically switches from proxying to serving those embedded files.

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, …)

Because Axum renders the HTML, Vite never gets a chance to inject its preamble. You must call config.hmr_scripts() in your template and set VITE_FRAMEWORK so the crate knows which preamble to generate.

If HMR is not working, this is the most likely reason: you are rendering your own HTML but haven’t called hmr_scripts() in the template.

// In your Axum handler:
let config = ViteConfig::from_env(axum_vite::embedded_dir!("$CARGO_MANIFEST_DIR/dist"));
let hmr = config.hmr_scripts(); // empty string in release builds
// Pass `hmr` into your template context.
<!-- In your base template: -->
<head>
  <title>{{ title }}</title>
  {{ hmr_scripts|safe }}
</head>

Set VITE_FRAMEWORK=react (or vue / svelte) so the correct preamble is generated. You do not need spa_router or hmr_injection_middleware in this setup.

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.