Rust Web Server with Axum 0.8: JSON In and Out with Serde
October 25, 2026
Episode 2 of the Rust Backend series, where we build the backend of a blog step by step. In episode 1 our Axum server answered with plain text. Today it speaks JSON, the language of web APIs: we send JSON with Serialize, receive it with Deserialize, answer 201 Created, and meet three request errors. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project from episode 1.
1. What JSON looks like
JSON is text made of keys and values. One post could look like this: an id, a title and a body.
{"id": 5, "title": "Hello", "body": "My first post"}2. Add Serde
In Rust, the Serde crate turns structs into JSON and back. Let's add it:
cargo add serde3. Return a Post as JSON
A Post struct with an id, a title and a body. #[derive(Serialize)] lets Serde turn it into JSON. The handler returns Json<Post>: Axum turns the struct into JSON and sets the content-type header for us. Replace src/main.rs with:
use axum::{extract::Path, routing::get, Json, Router};
use serde::Serialize;
#[derive(Serialize)]
struct Post {
id: u32,
title: String,
body: String,
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/posts/{id}", get(get_post));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
async fn get_post(Path(id): Path<u32>) -> Json<Post> {
Json(Post {
id,
title: format!("Post number {id}"),
body: String::from("Hello from Rust!"),
})
}4. Error: cannot find derive macro Serialize
cargo run fails with two errors. Always read the first one:
error: cannot find derive macro `Serialize` in this scope
--> src/main.rs:4:10
|
4 | #[derive(Serialize)]
| ^^^^^^^^^
|
note: `Serialize` is a trait, not a derive macro
error[E0277]: the trait bound `fn(Path<u32>) -> _ {get_post}: Handler<_, _>` is not satisfied
--> src/main.rs:13:54
error: could not compile `blog_api` (bin "blog_api") due to 2 previous errorsThe derive macros are an optional feature of Serde, and plain cargo add serde doesn't turn it on. The second error is caused by the first one: Post can't become JSON, so get_post isn't a valid handler. Fix the first error and the second one goes away.
5. The fix: the derive feature
Add serde again, this time with the derive feature:
cargo add serde --features deriveCargo.toml now has:
[dependencies]
axum = "0.8.9"
serde = { version = "1.0.229", features = ["derive"] }
tokio = { version = "1.54.0", features = ["full"] }Run cargo run again. It compiles and prints Listening on http://localhost:3000. In a second terminal:
curl -i localhost:3000/posts/5HTTP/1.1 200 OK
content-type: application/json
content-length: 58
{"id":5,"title":"Post number 5","body":"Hello from Rust!"}The content type is application/json, and the body is our post.
6. Receive JSON: NewPost and Deserialize
Now the other direction: the client sends JSON to us. Stop the server with Ctrl + C.
NewPost has only a title and a body, because the server picks the id. It derives Deserialize, so Serde can read it from JSON. The create_post handler takes Json<NewPost>: Axum reads the request body and fills in the struct for us. It answers with a tuple: status 201 Created and the new post as JSON. Finally, a POST to /posts goes to create_post.
The final src/main.rs:
use axum::{extract::Path, http::StatusCode, routing::{get, post}, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct Post {
id: u32,
title: String,
body: String,
}
#[derive(Deserialize)]
struct NewPost {
title: String,
body: String,
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/posts", post(create_post))
.route("/posts/{id}", get(get_post));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
async fn get_post(Path(id): Path<u32>) -> Json<Post> {
Json(Post {
id,
title: format!("Post number {id}"),
body: String::from("Hello from Rust!"),
})
}
async fn create_post(Json(new): Json<NewPost>) -> (StatusCode, Json<Post>) {
let post = Post { id: 1, title: new.title, body: new.body };
(StatusCode::CREATED, Json(post))
}7. Test it with curl: 415, 201, 422 and 400
Run cargo run again. -d sends a request body (and makes curl send a POST).
Without a content type, Axum refuses the body with 415 Unsupported Media Type: we never said the body is JSON.
curl -i localhost:3000/posts -d '{"title":"Hi","body":"My first post"}'HTTP/1.1 415 Unsupported Media Type
content-type: text/plain; charset=utf-8
content-length: 54
Expected request with `Content-Type: application/json`Add the header content-type: application/json, and we get 201 Created with our post and its id:
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hi","body":"My first post"}'HTTP/1.1 201 Created
content-type: application/json
content-length: 44
{"id":1,"title":"Hi","body":"My first post"}Leave out the body field. The JSON is valid, but it doesn't fit NewPost: 422 Unprocessable Entity.
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hi"}'HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 98
Failed to deserialize the JSON body into the target type: missing field `body` at line 1 column 14And broken JSON, with a comma at the end, is 400 Bad Request:
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hi",}'HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 76
Failed to parse the request body as JSON: trailing comma at line 1 column 15(The date: lines are left out of the outputs above.)
Recap
- Serde needs the
derivefeature for#[derive(Serialize)]and#[derive(Deserialize)]. Serializesends JSON: returnJson<T>from a handler.Deserializereceives JSON: takeJson<T>as a handler argument.- Return
(StatusCode::CREATED, Json(post))for201 Created. - Three request errors: 415 (no JSON content type), 422 (JSON that doesn't fit the struct), 400 (broken JSON).
Next episode: we keep posts in memory and build create, read, update and delete. Subscribe on YouTube so you don't miss it.