Claude Code Axum Rust CLAUDE.md AI Coding 2026

Claude Code for Axum: CLAUDE.md Rules for Extractors, the {id} Path Syntax, and Middleware Order (2026)

The Prompt Shelf ·

Axum is the most-downloaded async web framework in the Rust ecosystem — over 437 million all-time crates.io downloads against roughly 78 million for Actix-web, its closest competitor — and it has no dedicated CLAUDE.md guide anywhere. Not on the two sites that between them have written framework-specific CLAUDE.md guides for Deno, Hono, Bun, Solid.js, Temporal, and CockroachDB. Not in this gallery’s own Actix-web .cursorrules entry, which predates Axum’s rise to the default choice. The closest thing that exists is generic “write idiomatic Rust” advice, which says nothing about the parts of Axum that actually trip up an AI coding agent: a path-parameter syntax that changed in a breaking release most training data predates, an extractor-ordering rule enforced at compile time in a way that’s confusing the first time you hit it, and a middleware-ordering behavior that silently flips depending on whether you chain .layer() calls or wrap them in a ServiceBuilder.

None of this is Axum being poorly designed — the opposite, actually. Its type-safe extractor system and tight integration with the Tower ecosystem are why it won. But type safety catches wrong types, not wrong assumptions about which syntax or which ordering rule applies, and an agent trained on a mix of pre-2025 and post-2025 Axum code has no way to know which era’s conventions your project expects unless CLAUDE.md says so.

Why Generic Rust Rules Don’t Cover Axum

A CLAUDE.md written for Rust in general — ownership, thiserror vs anyhow, cargo clippy, async patterns with Tokio — is necessary but not sufficient for an Axum project. None of that touches the object model specific to building a web server with it: how a Router composes, how an extractor claims part of a request, how a tower::Layer wraps a handler. This gallery’s own Rust guide covers the language-level rules; this one picks up where it stops.

Three things make Axum specifically worth its own section, not a paragraph inside a general Rust guide:

The path syntax changed in a breaking release, and most training data predates it. Axum 0.8, released January 2025, replaced /users/:id and /files/*path with /users/{id} and /files/{*path}. The old syntax doesn’t silently misroute in 0.8+ — it panics at router build time with Path segments must not start with :. For capture groups, use {capture}. That’s a fail-fast, not a fail-silent, but it still means an agent can write code that compiles cleanly and only breaks the moment the server actually starts — easy to miss if the agent’s workflow doesn’t include running the app.

Extractor ordering is enforced by the trait system, not by convention. Axum’s request body is an async stream that can only be consumed once, so exactly one extractor per handler is allowed to take ownership of it (Json<T>, Form<T>, Bytes, String, Multipart), and it has to be the last function argument. Extractors that only need request partsState, Path, Query, headers — implement a different trait (FromRequestParts) and can appear in any order before it. Get the order wrong and the handler fails to compile; that’s a real safety net, but it means “why won’t this compile” is a common enough Axum-specific question that stating the rule up front saves a debugging loop.

Middleware ordering depends on how you attach it, not just what you attach. Chaining .layer() calls directly wraps each new layer around everything already added — an onion, where the last .layer() call runs first on the way in. Passing the same layers through a tower::ServiceBuilder instead runs them top-to-bottom, matching how most people read the code. Same layers, different execution order, depending entirely on which of the two equally-valid-looking patterns gets used.

The {id} Path Syntax Migration

## Routing Syntax

This project targets Axum 0.8+. Path parameters use curly-brace syntax:
`.route("/users/{id}", get(get_user))` and `.route("/files/{*path}", get(serve_file))`.

Do NOT use the pre-0.8 `:id` / `*path` syntax — it panics at router build
time in 0.8+ with "Path segments must not start with :". If you see `:id`
anywhere in this codebase, it's either a bug or intentional escaping via
`.without_v07_checks()` (rare — check before assuming the latter).
// Axum 0.7 and earlier — panics on 0.8+
Router::new().route("/users/:id", get(get_user))

// Axum 0.8+
Router::new().route("/users/{id}", get(get_user))

// Wildcard/catch-all, 0.7 vs 0.8+
Router::new().route("/files/*path", get(serve_file));   // pre-0.8
Router::new().route("/files/{*path}", get(serve_file)); // 0.8+

The reasoning behind the change, per Axum’s own 0.8.0 announcement, was to free up leading : and * for routes that need those characters literally. If a project genuinely needs the old behavior, Router::without_v07_checks() exists as an explicit opt-out — worth a one-line mention in CLAUDE.md only if the project actually uses it, since otherwise it just invites an agent to “fix” a panic by reaching for an escape hatch instead of the syntax update.

Extractor Order: Body Consumers Go Last

## Extractors

Only one extractor per handler can consume the request body, and it MUST
be the last function argument: `Json<T>`, `Form<T>`, `Bytes`, `String`,
`Multipart`. Everything else — `State`, `Path`, `Query`, `HeaderMap`,
custom `FromRequestParts` extractors — implements request-*parts*
extraction and can appear in any order before the body extractor.

Extractors run left to right. Put cheap, fail-fast extractors (`Path`,
`Query`) before expensive ones so a malformed path returns 400 before the
body is even read.
// Correct — State, Path, Query all extract from request parts;
// Json is the single body-consuming extractor and comes last
async fn update_post(
    State(pool): State<PgPool>,
    Path(id): Path<i64>,
    Query(params): Query<UpdateParams>,
    Json(payload): Json<UpdatePostRequest>,
) -> Result<Json<Post>, AppError> {
    // ...
}

// Won't compile — Json (a FromRequest, body-consuming extractor)
// appears before Path (a FromRequestParts extractor)
async fn broken(
    Json(payload): Json<UpdatePostRequest>,
    Path(id): Path<i64>,
) -> Result<Json<Post>, AppError> {
    // ...
}

The 0.8 release also changed how Option<T> behaves as a wrapper around an extractor: previously, wrapping any extractor in Option<T> silently converted a failed extraction into None. Now, T has to implement OptionalFromRequestParts (or OptionalFromRequest) for that to work, and extractors that do implement it have specific semantics — Option<TypedHeader<Authorization<Bearer>>> returns None if the header is absent, but still rejects the request if the header is present and malformed. Worth stating if the project leans on optional extraction for things like optional auth:

## Optional Extraction

`Option<T>` around an extractor only works if `T` implements
`OptionalFromRequestParts`. It returns `None` for a missing value, but
still rejects the request if the value is present and fails to parse —
optional does not mean "swallow all extraction errors."

Middleware Order: .layer() Is an Onion, ServiceBuilder Isn’t

## Middleware Ordering

Use `tower::ServiceBuilder` to compose more than one layer — it runs
top-to-bottom for both requests and responses, matching reading order.

Chaining `.layer()` calls directly on the Router is legal but reverses on
the way in: the LAST `.layer()` call added runs FIRST on an incoming
request (each new layer wraps everything before it). Don't mix the two
patterns in the same router — pick ServiceBuilder and keep every
multi-middleware stack there.
// .layer() chained directly — onion model.
// Incoming request order: layer_three -> layer_two -> layer_one -> handler
// Outgoing response order: handler -> layer_one -> layer_two -> layer_three
let app = Router::new()
    .route("/", get(handler))
    .layer(layer_one)
    .layer(layer_two)
    .layer(layer_three);

// Same three layers via ServiceBuilder — top-to-bottom for both directions.
// Request order: layer_one -> layer_two -> layer_three -> handler
use tower::ServiceBuilder;

let app = Router::new()
    .route("/", get(handler))
    .layer(
        ServiceBuilder::new()
            .layer(layer_one)
            .layer(layer_two)
            .layer(layer_three),
    );

This is the kind of rule that doesn’t show up as a bug in a diff — the code compiles and runs either way, and the difference only matters when the specific ordering of, say, a tracing span and a timeout, or an auth check and a rate limiter, actually changes behavior under load. An agent copying a .layer() chain pattern from one part of the codebase into a ServiceBuilder elsewhere (or vice versa) won’t get a compiler error for it.

Application State: Arc via State, Not a Global

## State Management

Application state lives in a single struct passed to `Router::with_state`,
accessed in handlers via the `State<AppState>` extractor. Wrap fields that
need sharing across handlers in `Arc` (or use types that are already
cheaply cloneable internally, like `sqlx::PgPool` or `redis::Client`).

Do NOT use `once_cell`/`lazy_static` globals for state that's specific to
one Axum app instance — `State` is checked at router construction time,
so a handler requesting state the router wasn't built with fails to
compile instead of panicking at runtime.
#[derive(Clone)]
struct AppState {
    db: PgPool,             // already cheaply cloneable (Arc internally)
    cache: Arc<RedisPool>,
    config: Arc<AppConfig>,
}

#[tokio::main]
async fn main() {
    let state = AppState {
        db: PgPool::connect(&db_url).await.unwrap(),
        cache: Arc::new(redis_pool),
        config: Arc::new(config),
    };

    let app = Router::new()
        .route("/posts/{id}", get(get_post))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn get_post(State(state): State<AppState>, Path(id): Path<i64>) -> Result<Json<Post>, AppError> {
    let post = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE id = $1")
        .bind(id)
        .fetch_optional(&state.db)
        .await?
        .ok_or(AppError::NotFound)?;
    Ok(Json(post))
}

The compile-time check is the part worth calling out explicitly: Router::with_state(state) has to be called with a value matching every handler’s State<T> type, or the router doesn’t type-check. A global static sidesteps that safety net entirely — it always “compiles,” and any mismatch between what a handler expects and what actually got initialized shows up as a runtime panic instead.

Error Handling: IntoResponse, Not unwrap()

## Errors

Define a project-wide `AppError` enum implementing `IntoResponse`.
Handlers return `Result<T, AppError>` and use `?` to propagate — never
`.unwrap()` or `.expect()` on a `Result` inside a handler body, since a
panic there takes down the request (and with the default panic behavior,
the whole worker thread) instead of returning a clean error response.
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;

enum AppError {
    NotFound,
    Database(sqlx::Error),
    Validation(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "resource not found".to_string()),
            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
            AppError::Database(e) => {
                tracing::error!(error = ?e, "database error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
            }
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

impl From<sqlx::Error> for AppError {
    fn from(e: sqlx::Error) -> Self {
        AppError::Database(e)
    }
}

With From<sqlx::Error> for AppError in place, ? inside a handler that returns Result<_, AppError> propagates a database error straight into a clean 500 response with the underlying error logged, not exposed to the client. The rule worth stating isn’t “handle errors” in the abstract — it’s specifically that .unwrap()/.expect() inside a handler is the failure mode to catch in review, because it’s the one pattern that compiles fine, looks like every other line, and takes the request down instead of returning a response.

Testing Handlers Without a Running Server

## Testing

Test handlers directly via `tower::ServiceExt::oneshot` against the
`Router`, not by spinning up a real TCP listener — faster, and doesn't
need port allocation per test.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt; // for `oneshot`

#[tokio::test]
async fn get_post_returns_404_for_missing_id() {
    let app = build_router(test_state()).await;

    let response = app
        .oneshot(
            Request::builder()
                .uri("/posts/999999")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

Router implements tower::Service, so oneshot drives a single request through the exact middleware stack and handler the app actually runs in production — including the ServiceBuilder layers — without opening a socket. Reserve a real axum::serve + HTTP client integration test for the handful of paths where the full network stack (headers set by a reverse proxy, actual connection handling) matters.

Complete CLAUDE.md Template

# CLAUDE.md

## Project Overview
Axum 0.8+ web API. Tokio async runtime. [sqlx / diesel / sea-orm] for
database access. [PostgreSQL / MySQL / SQLite].

## Commands
- Dev server: `cargo watch -x run` (or `cargo run`)
- Check without building: `cargo check`
- Lint: `cargo clippy -- -D warnings`
- Format: `cargo fmt`
- Tests: `cargo test`

## Routing Syntax
Axum 0.8+ path parameters use `{id}` / `{*rest}`, never the pre-0.8
`:id` / `*rest` syntax — the old syntax panics at router build time.

## Extractors
Body-consuming extractors (`Json`, `Form`, `Bytes`, `Multipart`) must be
the last handler argument — only one per handler. Everything else
(`State`, `Path`, `Query`, headers) extracts from request parts and can
appear in any order before it. `Option<T>` around an extractor requires
`T: OptionalFromRequestParts` and still rejects malformed-but-present
values.

## Middleware
Use `tower::ServiceBuilder` to compose more than one layer — top-to-bottom
execution for both requests and responses. Chained `.layer()` calls
reverse on the way in; don't mix the two patterns in one router.

## State
`State<AppState>` backed by an `Arc`-wrapped or already-cloneable struct,
passed via `Router::with_state`. No `once_cell`/`lazy_static` globals for
app-instance state.

## Errors
Project-wide `AppError` implementing `IntoResponse`. Handlers return
`Result<T, AppError>` and propagate with `?`. No `.unwrap()`/`.expect()`
inside a handler body.

## Testing
Test handlers via `tower::ServiceExt::oneshot` against the `Router`. Real
HTTP integration tests are reserved for the paths where the network stack
itself matters.

## What to Avoid
- `/users/:id` or `/files/*path` — panics on Axum 0.8+, use `{id}` / `{*rest}`.
- A body-consuming extractor placed before a `FromRequestParts` extractor.
- Mixing chained `.layer()` calls with a `ServiceBuilder` in the same router.
- A global `lazy_static`/`once_cell` in place of `State<T>`.
- `.unwrap()`/`.expect()` on a `Result` inside a handler.
- Spinning up a real TCP listener for handler-level unit tests.

CLAUDE.md vs AGENTS.md for an Axum Project

AGENTS.md holds everything tool-agnostic: the routing syntax version, the extractor-ordering rule, the middleware-composition rule, the state and error-handling patterns. None of it is Claude-specific — a Cursor or Codex agent generating an Axum handler needs the exact same constraints. CLAUDE.md adds anything specific to how Claude Code should operate on top of that: whether to run cargo check automatically after editing a .rs file, how much explanation to include when fixing a routing panic.

AGENTS.md   → routing syntax, extractor ordering, middleware pattern, state/error rules
CLAUDE.md   → "run `cargo check` after every .rs edit", verbosity preferences

A PostToolUse hook that runs cargo check (fast, type-only) after every .rs file edit — with cargo clippy reserved for a pre-commit or CI step rather than every single edit — catches most of the mistakes above before they reach a human review, including the extractor-ordering compile error, which is otherwise a confusing first-time-you-hit-it message to debug from scratch.

Axum vs Actix-web in 2026

The gallery’s Actix-web .cursorrules entry is still a legitimate reference for teams with an existing Actix codebase — its actor-based extensions and raw-throughput benchmarks remain competitive. But the download numbers have moved decisively toward Axum since Actix-web’s early lead: as of mid-2026, Axum’s all-time crates.io downloads (437M+) are more than 5x Actix-web’s (78M), and GitHub stars have crossed over too (Axum ~27k vs Actix-web ~25k). The practical driver is Tower: because Axum is built directly on tower::Service and tower::Layer, the same middleware — rate limiting, tracing, retry, load shedding — works unmodified across Axum HTTP handlers and Tonic gRPC services in the same codebase, which matters more every year as more Rust backends mix both. For a new project without an existing Actix investment, Axum is the more common 2026 default.

Putting It Together

None of Axum’s traps are exotic — they’re the predictable result of a fast-moving, breaking-change-tolerant 0.x-to-stable framework meeting an AI agent trained on a mix of pre- and post-migration examples. A {id} written as :id compiles and only panics when the server starts. A .layer() chain and a ServiceBuilder produce different execution orders from the same three middlewares. Neither shows up as an obviously wrong diff — they show up as “why did this behave differently than I expected” thirty minutes into debugging.

Start from the template above, confirm the project’s actual Axum version (0.7 projects genuinely still use :id — don’t blanket-apply the 0.8 rule to a codebase that hasn’t upgraded), and add the database layer’s own conventions (sqlx compile-time query checking, sea-orm entity generation) as a project-specific section underneath.

Real-world Rust examples in our gallery include the Actix-web ruleset referenced above for a direct before/after comparison against Axum’s extractor model.

FAQ

Why does Claude Code write Axum routes with a leading colon like /users/:id?

Most training data predates Axum 0.8 (January 2025), which replaced :id/*rest with {id}/{*rest}. The old syntax now panics at router build time rather than misrouting silently, but the panic only appears once the server actually starts — stating the current syntax explicitly in CLAUDE.md avoids the compile-clean, crash-on-boot loop.

Why does my Axum handler fail to compile when I add a Json extractor?

Only one extractor per handler can consume the request body, and it has to be the last argument — State, Path, and Query extract from request parts and can come in any order before it. Putting the body-consuming extractor anywhere else, or using two of them, fails Axum’s trait bounds at compile time.

What’s the difference between .layer() and ServiceBuilder for Axum middleware ordering?

Chained .layer() calls wrap in reverse — the last one added runs first on an incoming request. The same layers passed through tower::ServiceBuilder run top-to-bottom for both requests and responses instead, which is why Axum’s own docs recommend ServiceBuilder once there’s more than one middleware.

How should Axum handlers share database connections and application state?

Via the State<T> extractor backed by a struct passed to Router::with_state — checked at router construction time, so a mismatch fails to compile rather than panicking at runtime, unlike a lazy_static/once_cell global.

Should a new Rust web project use Axum or Actix-web in 2026?

Axum has pulled well ahead on adoption (5x+ Actix-web’s crates.io downloads as of mid-2026), largely because its Tower foundation means the same middleware works across Axum HTTP and Tonic gRPC services in one codebase. Actix-web remains a reasonable choice for teams already invested in it, but Axum is the more common default for new projects.

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules