Step 8 — The Route Layer: Axum Handlers for /api/tags

Step 7 gave us a TagRepository that talks to Postgres. Now crates/remote/src/routes/tags.rs exposes it over HTTP with Axum, the async web framework the rust-as-backend tutorial (see the docs sidebar) covers from scratch. Here we focus on the parts a frontend engineer needs to read a Rust route file confidently.

The router — Axum 0.8 path syntax

pub fn router() -> Router<AppState> {
    Router::new()
        .route("/tags", get(list_tags).post(create_tag))
        .route("/tags/{tag_id}", get(get_tag).put(update_tag).delete(delete_tag))
}

Two routes, five handlers. {tag_id} is Axum 0.8's path-parameter syntax — the frontend equivalent of react-router-dom's :tag_id from Step 6. (CLAUDE.md flags this explicitly: the older :tag_id syntax silently compiles but panics at server startup in Axum 0.8 — worth knowing if you ever add a route yourself.)

This router() function isn't called directly by the app — it's merged into the full app router in crates/remote/src/routes/mod.rs:

.merge(tags::router())

That one line is why /api/tags responds at all — a route file that exists but is never .merge()d into the app router would silently 404 on every request, which is why checking the mount point is a standard step (not just for this tutorial, but for CLAUDE.md's own "Gate 2f" review checklist) whenever a route seems to be missing in production.

A handler, read top to bottom

async fn list_tags(
    State(state): State<AppState>,
    Extension(ctx): Extension<RequestContext>,
    Query(params): Query<ListTagsQuery>,
) -> Result<Json<ApiResponse<Vec<Tag>>>, ErrorResponse> {
    let team_id_or_slug = params.team_id
        .ok_or_else(|| ErrorResponse::new(StatusCode::BAD_REQUEST, "team_id is required"))?;

    let team = TeamRepository::get_by_id_or_slug(state.pool(), &team_id_or_slug)
        .await
        .map_err(|error| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to get team"))?
        .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "team not found"))?;

    ensure_team_member_access(state.pool(), team.id, ctx.user.id).await?;

    let tags = TagRepository::find_by_team(state.pool(), team.id)
        .await
        .map_err(|error| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list tags"))?;

    Ok(ApiResponse::success(tags))
}

Every function argument is an extractor — Axum pulls it out of the incoming request before your handler code runs at all:

The function body is a straight-line story: validate input exists → resolve the team → check the requesting user actually has access to that team's workspace (ensure_team_member_access) → fetch the data → wrap it in a success envelope. Every fallible step (?) short-circuits to a typed ErrorResponse with an explicit HTTP status — there's no unchecked .unwrap() that could panic the whole server on bad input.

Checkpoint

Compare list_tags to create_tag in the same file: create_tag checks ensure_team_member_access before calling TagRepository::create, using the team_id from the incoming payload rather than from an existing row — because there's no existing tag yet to look the team up from. Notice how the authorization check is still there, just sourced from a different place depending on whether the resource already exists.

Continue to Step 9 — The Shared Contract: shared/types.ts, where the Rust Tag struct from Step 7 gets its TypeScript twin.