Skip to content

Building an API with Axum

If you come from Node.js, you probably know Express or NestJS. Axum is Rust’s answer to that space — a high-level web framework that lets you write async fn route handlers, parse request data with type-safe extractors, and share state across routes.

But unlike Express, Axum is not batteries-included in the monolith sense. It is deliberately thin and delegates middleware to Tower, an industrial-strength, composable middleware library. The result is:

  • Compile-time type safety across the entire request/response cycle.
  • Zero-cost abstractions — no garbage collector, no hidden allocations.
  • First-class async built on Tokio, the de facto Rust async runtime.
FeatureExpressNestJSAxum
LanguageTypeScript / JSTypeScriptRust
Routingapp.get(...)@Get(...) decoratorRouter::new().route(...)
Middlewareapp.use(...)@Middleware / guardsTower layers
Request parsingreq.body, req.params@Body(), @Param()Extractors (Json, Path, Query)
Stateapp.localsDependency injectionState(...) extractor
Error handlingnext(err) middlewareException filtersResult<T, E> + IntoResponse
Type safetyPartial (runtime)Partial (runtime)Full (compile-time)
TypeScript
// Express
import express from 'express';
const app = express();
app.get('/', (req, res) => {
res.json({ message: 'Hello, World!' });
});
app.listen(3000, () => console.log('Listening on :3000'));
Rust
use axum::{routing::get, Json, Router};
use serde_json::{json, Value};
async fn hello() -> Json<Value> {
Json(json!({ "message": "Hello, World!" }))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on :3000");
axum::serve(listener, app).await.unwrap();
}

Run locally — Axum binds a port and runs forever, so these server snippets cannot run in the browser sandbox. Use cargo run after following the project setup in the next lesson.

Throughout this module you will build a small Users API with:

  • GET /users — list users (pagination with Query)
  • POST /users — create a user (Json body)
  • GET /users/{id} — get a single user (Path)
  • DELETE /users/{id} — delete a user
  • Shared in-memory state (Arc<Mutex<Vec<User>>>)
  • Structured JSON errors
  • A Tower CORS layer
  • SQLx database integration (conceptual)
  • Handler tests with tower::ServiceExt::oneshot

Each concept maps directly to something you already know from Express or NestJS.

This module targets Axum 0.8. If you are porting a 0.7 codebase, or reading an older tutorial, these are the changes that will bite you:

0.70.8
.route("/users/:id", ...).route("/users/{id}", ...) — and /*rest becomes /{*rest}
#[async_trait] on FromRequestPartsnot needed; the trait uses native async fn
Option<Path<T>> swallowed every errorit now rejects the request in most cases — implement OptionalFromRequestParts if you want the old behaviour
Host extractor in axummoved to the axum-extra crate
Message::Text(String)Message::Text(Utf8Bytes), and Message::Binary(Bytes)
axum::body::{Empty, Full} re-exportsuse axum::body::Body

Handlers and services also have to be Sync now. The rest of the API — extractors, State, Tower layers — is unchanged, so a port is mostly a find-and-replace on route strings.

Which async runtime does Axum use?
How does Axum handle middleware compared to Express?
What replaces `req.body` and `req.params` in Axum?