axum-extra

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 axum-extra 0.12.6 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. axum-extra holds utilities that are useful but not essential, or that have not yet earned a place in axum’s stable core. It is versioned in lockstep with axum (axum 0.8 pairs with axum-extra 0.10 through 0.12) and maintained in the same repository. WithRejection and Cached are covered on Custom Extractors.

Opt-in features (0.12)

axum-extra 0.12 made almost everything opt-in: its only default feature is tracing. Enable what you use:

Feature Enables

typed-header

TypedHeader and a re-export of the headers crate

cookie, cookie-signed, cookie-private, cookie-key-expansion

CookieJar, SignedCookieJar, PrivateCookieJar, deriving a Key from a shorter master key

typed-routing (implies routing)

TypedPath, the #[derive(TypedPath)] macro and RouterExt::typed_get etc.

routing

RouterExt, Resource and the second_element_is helpers

query, form

multi-value Query and Form (serde_html_form), OptionalQuery

optional-path

OptionalPath, deprecated since axum-extra 0.10.0 — do not enable it in new code; use axum’s Option<Path<T>>

json-lines, json-deserializer

newline-delimited JSON streams; JsonDeserializer for borrowed deserialisation

erased-json, attachment, file-stream, protobuf, error-response

the response types below and InternalServerError

multipart

a Multipart variant with a different API from axum’s

with-rejection, cached, handler, middleware

WithRejection, Cached, handler utilities, option_layer

[dependencies]
axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["typed-header", "cookie-private", "typed-routing", "query", "form",
  "json-lines", "erased-json", "attachment", "middleware"] }

TypedHeader

HeaderMap gives you strings; TypedHeader<T> parses one header into a type from the headers crate — Authorization<Bearer>, UserAgent, ContentType, IfNoneMatch, CacheControl, Cookie, Host, and dozens more — and rejects with 400 when it is malformed. It was moved from axum to axum-extra in 0.7. It also implements IntoResponseParts, so typed headers can be returned in tuples.

use axum::{Router, routing::get};
use axum_extra::{
    TypedHeader,
    headers::{Authorization, CacheControl, UserAgent, authorization::Bearer},
};

async fn whoami(
    TypedHeader(agent): TypedHeader<UserAgent>,
    auth: Option<TypedHeader<Authorization<Bearer>>>, // None if missing, 400 if malformed
) -> (TypedHeader<CacheControl>, String) {
    let who = match auth {
        Some(TypedHeader(Authorization(bearer))) => format!("token of {} bytes", bearer.token().len()),
        None => "anonymous".to_owned(),
    };
    (TypedHeader(CacheControl::new().with_no_store()), format!("{who} using {}\n", agent.as_str()))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/whoami", get(whoami));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

CookieJar reads the Cookie header and, when returned, writes Set-Cookie headers for every change. Because a jar is both an extractor and a response part, the pattern is "extract, modify, return":

  • CookieJar — plain cookies; the client can read and alter them.

  • SignedCookieJar — cookies with an HMAC signature: readable by the client, but tampering is detected (the cookie is ignored).

  • PrivateCookieJar — cookies encrypted and authenticated with AES-GCM: neither readable nor alterable.

Signed and private jars need a cookie::Key they can obtain with FromRef from the state:

use axum::{
    Router,
    extract::FromRef,
    response::{IntoResponse, Redirect},
    routing::{get, post},
};
use axum_extra::extract::{
    PrivateCookieJar,
    cookie::{Cookie, Key, SameSite},
};

#[derive(Clone)]
struct AppState {
    cookie_key: Key,
}

impl FromRef<AppState> for Key {
    fn from_ref(state: &AppState) -> Self {
        state.cookie_key.clone()
    }
}

async fn remember_shelf(jar: PrivateCookieJar) -> (PrivateCookieJar, Redirect) {
    let cookie = Cookie::build(("last_shelf", "science-fiction"))
        .path("/")
        .http_only(true)
        .secure(true)
        .same_site(SameSite::Lax)
        .build();
    (jar.add(cookie), Redirect::to("/"))
}

async fn home(jar: PrivateCookieJar) -> impl IntoResponse {
    match jar.get("last_shelf") {
        Some(c) => format!("welcome back to the {} shelf\n", c.value()),
        None => "welcome\n".to_owned(),
    }
}

#[tokio::main]
async fn main() {
    // In production, load at least 64 bytes from a secret store: Key::from(&bytes).
    // Key::generate() makes every restart invalidate existing cookies.
    let state = AppState { cookie_key: Key::generate() };
    let app = Router::new().route("/", get(home)).route("/shelves/remember", post(remember_shelf)).with_state(state);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Key::from panics if given fewer than 64 bytes; with the cookie-key-expansion feature, Key::derive_from expands a 32-byte master key. Rotating the key invalidates every cookie signed or encrypted with the old one. Session cookies, cookie attributes and session fixation are covered on Sessions & Cookies.

Typed routing

#[derive(TypedPath)] ties a path template to a struct. The same struct is the extractor (it must come first in the handler) and the way to build links, so a renamed route cannot leave broken links behind:

use axum::{Router, response::Html};
use axum_extra::routing::{RouterExt, TypedPath};
use serde::Deserialize;

#[derive(TypedPath, Deserialize)]
#[typed_path("/books/{isbn}")]
struct BookPath {
    isbn: String,
}

#[derive(TypedPath, Deserialize)]
#[typed_path("/books/{isbn}/reviews/{id}")]
struct ReviewPath {
    isbn: String,
    id: u32,
}

async fn show_book(BookPath { isbn }: BookPath) -> Html<String> {
    let first_review = ReviewPath { isbn: isbn.clone(), id: 1 };
    Html(format!("<h1>{isbn}</h1><a href=\"{}\">first review</a>", first_review.to_uri()))
}

async fn show_review(ReviewPath { isbn, id }: ReviewPath) -> String {
    format!("review {id} of {isbn}\n")
}

#[tokio::main]
async fn main() {
    let app = Router::new().typed_get(show_book).typed_get(show_review);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

#[typed_path("...", rejection(MyError))] customises the rejection. Since 0.12.6 the macro refuses old :param segments at compile time, mirroring axum’s runtime check.

Multi-value Query and Form

axum’s Query and Form use serde_urlencoded, which cannot collect repeated keys. axum-extra’s versions use serde_html_form, which deserialises ?genre=sf&genre=fantasy into a Vec — the format HTML checkboxes and multi-selects produce. OptionalQuery<T> yields None when the query string is absent and still rejects invalid ones. For an optional path capture, see Optional path captures: Option<Path<T>>, not OptionalPath below: axum 0.8 handles it natively.

use axum::{Router, routing::get};
use axum_extra::extract::{OptionalQuery, Query};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Filters {
    #[serde(default)]
    genre: Vec<String>,
    #[serde(default)]
    year: Option<u16>,
}

async fn search(Query(filters): Query<Filters>) -> String {
    format!("{filters:?}\n")
}

#[derive(Debug, Deserialize)]
struct Sort {
    by: String,
}

async fn sorted(OptionalQuery(sort): OptionalQuery<Sort>) -> String {
    format!("{sort:?}\n")
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/books", get(search)).route("/api/v1/authors", get(sorted));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -s 'localhost:3000/api/v1/books?genre=sf&genre=fantasy&year=1969'
Filters { genre: ["sf", "fantasy"], year: Some(1969) }
$ curl -s 'localhost:3000/api/v1/authors'
None

Host (the Host header, or X-Forwarded-Host/Forwarded when present) was moved here from axum in 0.8 and has been deprecated since 0.12.4, because trusting forwarded headers is a deployment decision; read the header you trust explicitly instead.

Optional path captures: Option<Path<T>>, not OptionalPath

When one handler serves both /blog and /blog/{page}, use axum’s own Option<Path<T>>. Since axum 0.8, Path implements OptionalFromRequestParts: the handler gets None on a route with no captures and Some on a route with one, and an invalid capture (/blog/abc, /blog/0) is still rejected with 400 Bad Request instead of being silently treated as absent.

use std::num::NonZeroU32;

use axum::{Router, extract::Path, routing::get};

async fn blog(page: Option<Path<NonZeroU32>>) -> String {
    let page = page.map_or(1, |Path(p)| p.get());
    format!("page {page}\n")
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/blog", get(blog)).route("/blog/{page}", get(blog));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -s localhost:3000/blog
page 1
$ curl -s localhost:3000/blog/3
page 3
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/blog/abc
400

Source: axum: Path

axum-extra’s OptionalPath<T> (behind the optional-path feature) predates this. It was deprecated in axum-extra 0.10.0, the release that accompanied axum 0.8, and in 0.12.6 it is still present but marked #[deprecated = "Use Option<Path<_>> instead"], so using it triggers a deprecation warning (an error under -D warnings). Treat it as legacy: replace OptionalPath(page): OptionalPath<T> with page: Option<Path<T>> and drop the optional-path feature.

Responses: Either, JsonLines, ErasedJson, Attachment, FileStream

use std::convert::Infallible;

use axum::{Json, Router, http::StatusCode, routing::get};
use axum_extra::{
    either::Either,
    json,
    json_lines::AsResponse,
    response::{Attachment, ErasedJson, JsonLines},
};
use futures::stream::{self, Stream};
use serde::Serialize;

#[derive(Serialize)]
struct Book {
    title: &'static str,
    year: u16,
}

// Either: two different response types without boxing or `.into_response()`
async fn maybe_book(axum::extract::Path(id): axum::extract::Path<u32>) -> Either<Json<Book>, StatusCode> {
    if id == 1 { Either::E1(Json(Book { title: "Dune", year: 1965 })) } else { Either::E2(StatusCode::NOT_FOUND) }
}

// JsonLines: one JSON document per line, streamed (it sets no Content-Type; add one if clients need it)
async fn export() -> JsonLines<impl Stream<Item = Result<Book, Infallible>>, AsResponse> {
    JsonLines::new(stream::iter([
        Ok(Book { title: "Dune", year: 1965 }),
        Ok(Book { title: "The Dispossessed", year: 1974 }),
    ]))
}

// ErasedJson: JSON without naming a type; the json! macro also accepts serialisable values
async fn stats() -> ErasedJson {
    json!({ "books": 2, "authors": ["Herbert", "Le Guin"] })
}

// Attachment: Content-Disposition: attachment; filename="catalogue.csv"
async fn download() -> Attachment<String> {
    Attachment::new("isbn,title\n9780441013593,Dune\n".to_owned())
        .filename("catalogue.csv")
        .content_type("text/csv")
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/books/{id}", get(maybe_book))
        .route("/export.ndjson", get(export))
        .route("/stats", get(stats))
        .route("/catalogue.csv", get(download));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
  • Either, Either3 …​ Either8 implement IntoResponse (and FromRequestParts, trying each extractor in turn).

  • JsonLines is also an extractor for NDJSON request bodies, yielding a stream of T; it respects the body limit since 0.12.5 (see Server-Sent Events & Streaming).

  • FileStream streams any AsyncRead (a tokio::fs::File, an object-store download) with a filename, content length and HTTP range support (into_range_response); see File Uploads.

  • Protobuf<T> (feature protobuf, prost 0.14) is a Json-like extractor and response for protocol buffers.

  • Since 0.12.6, Attachment and FileStream escape quotes and backslashes in filenames, closing a header-parameter injection.

option_layer

A layer that is configured at start-up — compression only in production, a rate limiter only when configured — is an Option<L>. tower’s Either handles that, but the two branches have different response body types; axum-extra’s option_layer maps both to axum::body::Body (since 0.12) so the result can be passed to Router::layer directly:

use std::time::Duration;

use axum::{Router, http::StatusCode, routing::get};
use axum_extra::middleware::option_layer;
use tower_http::timeout::TimeoutLayer;

#[tokio::main]
async fn main() {
    let timeout_secs: Option<u64> = std::env::var("REQUEST_TIMEOUT_SECS").ok().and_then(|v| v.parse().ok());
    let timeout = timeout_secs
        .map(|s| TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(s)));

    let app = Router::new().route("/", get(|| async { "ok" })).layer(option_layer(timeout));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Where the books are outdated

Yoon (Beginning Axum, chapter 2) imports TypedHeader and the cookie jars from axum-extra, which is correct for 0.8, but enables axum-extra with its pre-0.12 default features. With axum-extra 0.12 every feature used must be listed explicitly (for example cached, with-rejection, typed-routing), or the imports fail with "unresolved import". The Host extractor that older code takes from axum::extract lives in axum-extra since axum 0.8 and is deprecated since axum-extra 0.12.4. Older code that uses OptionalPath for an optional capture should switch to axum’s Option<Path<T>> (see Optional path captures: Option<Path<T>>, not OptionalPath).