M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 2 of 42

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.

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.

json
{"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:

bash
cargo add serde

3. 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:

rust
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:

text
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 errors

The 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:

bash
cargo add serde --features derive

Cargo.toml now has:

toml
[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:

bash
curl -i localhost:3000/posts/5
text
HTTP/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:

rust
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.

bash
curl -i localhost:3000/posts -d '{"title":"Hi","body":"My first post"}'
text
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:

bash
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hi","body":"My first post"}'
text
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.

bash
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hi"}'
text
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 14

And broken JSON, with a comma at the end, is 400 Bad Request:

bash
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hi",}'
text
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 derive feature for #[derive(Serialize)] and #[derive(Deserialize)].
  • Serialize sends JSON: return Json<T> from a handler.
  • Deserialize receives JSON: take Json<T> as a handler argument.
  • Return (StatusCode::CREATED, Json(post)) for 201 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.

ยฉ 2026 All Rights Reserved By Mahmudul Alam