Static Files & SPAs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against tower-http 0.7.1 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Bookshelf serves its own CSS, JavaScript (htmx) and cover images, and can also host a separately built React front end. In production a CDN or reverse proxy often serves static files instead; the techniques here are for when the application does it itself — single-binary deployments, development, or small services.

ServeDir and ServeFile

tower_http::services::ServeDir serves a directory, ServeFile a single file. They are tower services, so they are mounted with nest_service (under a prefix) or fallback_service (for every path no route matched):

use axum::{
    Router,
    http::{HeaderValue, header},
    routing::get,
};
use tower::ServiceBuilder;
use tower_http::{
    services::{ServeDir, ServeFile},
    set_header::SetResponseHeaderLayer,
};

fn app() -> Router {
    // /static/*: Bookshelf's own assets, with precompressed variants
    let assets = ServiceBuilder::new()
        .layer(SetResponseHeaderLayer::if_not_present(
            header::CACHE_CONTROL,
            HeaderValue::from_static("public, max-age=3600"),
        ))
        .service(ServeDir::new("static").precompressed_gzip().precompressed_br());

    // everything else: a single-page application with client-side routing
    let spa = ServeDir::new("dist").not_found_service(ServeFile::new("dist/index.html"));

    Router::new()
        .route("/api/v1/health", get(|| async { "ok" }))
        .nest_service("/static", assets)
        .fallback_service(spa)
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app()).await.unwrap();
}
$ curl -s -o /dev/null -D - -H 'accept-encoding: gzip' localhost:3000/static/app.css | grep -i -E '^HTTP|encoding|etag|vary|cache'
HTTP/1.1 200 OK
content-encoding: gzip
vary: accept-encoding
etag: "6ac5837e.29484b18-104"
cache-control: public, max-age=3600
$ curl -s localhost:3000/books/9780441013593 | head -c 43   # unknown path: the SPA's index.html (status 404)
<!doctype html><title>Bookshelf SPA</title>
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/api/v1/health
200
  • Content types come from the file extension (mime_guess); ranges (Range: bytes=...) are supported for media and resumable downloads.

  • Precompressed files: with precompressed_gzip()/precompressed_br()/precompressed_zstd(), ServeDir serves app.css.br or app.css.gz when it exists and the client accepts the encoding, with Vary: Accept-Encoding (added in 0.7). Compress at build time with the highest level; it costs nothing at runtime, unlike CompressionLayer.

  • Security: ServeDir rejects paths containing .. or absolute components, so it cannot escape its directory. Do not point it at a directory containing anything private (configuration, .env, uploads awaiting validation).

  • Directories: /static/ serves index.html if present (append_index_html_on_directories, on by default); /static/docs (a directory without a slash) redirects to /static/docs/, which 0.7.1’s redirect_to_trailing_slash(false) turns off, and redirect_path_prefix fixes the redirect target when the service is nested.

SPAs: falling back to index.html

A single-page application (React, Vue, Angular, Svelte) routes on the client: /books/9780441013593 is a route inside the JavaScript app, not a file. The server must answer such paths with index.html and let the client router take over, while still serving real files (/assets/index-3f2a1c.js) and the API:

  • ServeDir::new("dist").not_found_service(ServeFile::new("dist/index.html")) serves the file if it exists and index.html with status 404 otherwise — correct for crawlers, but some hosting checks dislike it;

  • ServeDir::new("dist").fallback(ServeFile::new("dist/index.html")) serves index.html with status 200.

Keep API routes on their own prefix (/api) and give that prefix its own fallback, so that a mistyped API path returns a JSON 404 instead of the SPA’s HTML (see Routing Composition). The React and Vite side — npm run build producing dist/, environment variables, routing — is covered in this site’s React section; in development, run the Vite dev server and proxy /api to axum instead of serving dist.

Caching: ETags and fingerprinted assets

tower-http 0.7 added strong ETags to ServeDir/ServeFile, with If-None-Match and If-Match handling per RFC 9110: a revalidation request gets 304 Not Modified (now with ETag and Last-Modified), so browsers re-download only changed files.

$ ETAG=$(curl -sI localhost:3000/static/app.css | grep -i etag | cut -d' ' -f2 | tr -d '\r')
$ curl -s -o /dev/null -w '%{http_code}\n' -H "if-none-match: $ETAG" localhost:3000/static/app.css
304

For the best of both worlds, build tools fingerprint asset names with a content hash (index-3f2a1c.js). Such files never change, so they can be cached forever with Cache-Control: public, max-age=31536000, immutable; only index.html, which references them, needs no-cache (revalidate every time). Apply the headers per directory with SetResponseHeaderLayer as above.

Two behaviour changes in tower-http 0.7 can surprise an upgrade: a file requested with a trailing slash (/static/app.css/) now returns 404 instead of the file, and since 0.7.1 unexpected I/O errors in ServeDir (as opposed to "not found") propagate to try_call callers instead of being turned into 404.

Embedding assets in the binary

A single self-contained binary is convenient for deployment: no static/ directory to ship alongside it. tower-http 0.7’s Backend trait lets ServeDir read files from anywhere; an in-memory backend over embedded bytes takes about 50 lines:

use std::{
    collections::HashMap,
    future::{Ready, ready},
    io::{self, Cursor},
    path::{Path, PathBuf},
    sync::Arc,
    time::SystemTime,
};

use axum::Router;
use tower_http::services::fs::{Backend, File, Metadata, ServeDir};

/// Files compiled into the binary; real code uses include_bytes!("../static/app.css") or the include_dir crate.
static FILES: &[(&str, &[u8])] = &[
    ("app.css", b"body { font-family: sans-serif; }\n"),
    ("htmx.min.js", b"/* htmx would be embedded here */\n"),
];

#[derive(Clone)]
struct Embedded(Arc<HashMap<PathBuf, &'static [u8]>>);

#[derive(Clone, Copy)]
struct EmbeddedMeta {
    len: u64,
}

impl Metadata for EmbeddedMeta {
    fn is_dir(&self) -> bool {
        false // no directory listings or index files in this simple backend
    }
    fn modified(&self) -> io::Result<SystemTime> {
        // ETag and Last-Modified are derived from this and the length: return the build time in real code
        Ok(SystemTime::UNIX_EPOCH)
    }
    fn len(&self) -> u64 {
        self.len
    }
}

struct EmbeddedFile(Cursor<&'static [u8]>);

impl tokio::io::AsyncRead for EmbeddedFile {
    fn poll_read(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> std::task::Poll<io::Result<()>> {
        std::pin::Pin::new(&mut self.0).poll_read(cx, buf)
    }
}

impl tokio::io::AsyncSeek for EmbeddedFile {
    fn start_seek(mut self: std::pin::Pin<&mut Self>, pos: io::SeekFrom) -> io::Result<()> {
        std::pin::Pin::new(&mut self.0).start_seek(pos)
    }
    fn poll_complete(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<io::Result<u64>> {
        std::pin::Pin::new(&mut self.0).poll_complete(cx)
    }
}

impl File for EmbeddedFile {
    type Metadata = EmbeddedMeta;
    type MetadataFuture<'a> = Ready<io::Result<EmbeddedMeta>>;
    fn metadata(&self) -> Self::MetadataFuture<'_> {
        ready(Ok(EmbeddedMeta { len: self.0.get_ref().len() as u64 }))
    }
}

impl Embedded {
    fn lookup(&self, path: &Path) -> io::Result<&'static [u8]> {
        // ServeDir passes "./<base>/<request path>"; strip the base used below ("embedded")
        let key = path.strip_prefix("./embedded").or_else(|_| path.strip_prefix("embedded")).unwrap_or(path);
        self.0.get(key).copied().ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))
    }
}

impl Backend for Embedded {
    type File = EmbeddedFile;
    type Metadata = EmbeddedMeta;
    type OpenFuture = Ready<io::Result<EmbeddedFile>>;
    type MetadataFuture = Ready<io::Result<EmbeddedMeta>>;

    fn open(&self, path: PathBuf) -> Self::OpenFuture {
        ready(self.lookup(&path).map(|bytes| EmbeddedFile(Cursor::new(bytes))))
    }
    fn metadata(&self, path: PathBuf) -> Self::MetadataFuture {
        ready(self.lookup(&path).map(|bytes| EmbeddedMeta { len: bytes.len() as u64 }))
    }
}

#[tokio::main]
async fn main() {
    let files = FILES.iter().map(|(name, bytes)| (PathBuf::from(name), *bytes)).collect();
    let assets = ServeDir::with_backend("embedded", Embedded(Arc::new(files)));
    let app = Router::new().nest_service("/static", assets);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -si localhost:3000/static/app.css | grep -i -E '^HTTP|content-type|etag'
HTTP/1.1 200 OK
content-type: text/css
etag: "0.00000000-22"
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/static/missing.js
404

Before tower-http 0.7, embedding meant a hand-written handler around crates such as rust-embed (which remains a good choice: it reads files from disk in debug builds and embeds them in release builds) and re-implementing content types, ETags and ranges. The Backend trait keeps all of ServeDir's HTTP behaviour.

Where the books are outdated

Yoon (Beginning Axum) serves its React build with tower-http 0.6, whose ServeDir had no ETags (only Last-Modified), no Backend trait and still served files requested with a trailing slash.