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](https://img.shields.io/crates/v/axum-vite.svg?color=blue)](https://crates.io/crates/axum-vite) ![tests](https://github.com/gko/axum-vite/actions/workflows/test.yml/badge.svg) ![clippy](https://github.com/gko/axum-vite/actions/workflows/clippy.yml/badge.svg)

`axum-vite` is a utility crate that simplifies the integration between an [Axum](https://github.com/tokio-rs/axum) backend and a [Vite](https://vite.dev/) 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/`](examples/).

### Basic SPA application

The [`basic-spa`](examples/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:
```sh
# 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]#configuration
> table and [Auto-Spawn]#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`](examples/template-askama/) example shows how to integrate [Askama](https://askama.rs/). 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:
```sh
# 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`](examples/template-sailfish/) example demonstrates the same pattern using [Sailfish](https://github.com/rust-sailfish/sailfish), a fast and simple template engine. This example is configured to autostart `vite` dev server.

In order to run, install frontend dependencies:
```sh
cd examples/template-sailfish/frontend
npm install
```

and then start the backend:
```sh
cargo run -p template-sailfish
```

Then open <http://localhost:3000>.

### Embedding into your own project

### Add the dependency

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

### Wire up the router

```rust
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:

```rust
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:

```rust
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:

```sh
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`](https://docs.rs/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. |

```sh
# 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!`:

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

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

```ts
// 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:

```rust
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()
```

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

```rust
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:

```rust
// 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`](examples/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:

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