Skip to content
Select theme

Returning JSON and building a response type

Step 4 of 15. The router works but the service still answers in plain text — step 3 left three handlers returning Strings and /health returning &'static str. A short link API is a JSON API, so this step makes JSON the default: Json for bodies going out and coming in, the contract’s POST /links route, and one response type — ApiResponse<T> — that every handler returns, so status and content-type are decided in a single IntoResponse impl instead of scattered across handlers.

Start from where step 3 ended: the shortlink project with three routes (/health, /links, /{code}) and serde’s derive feature already in Cargo.toml. If you jumped straight here, git checkout api-ch03 in your clone of the examples repository puts that state on disk.

Step 3’s Cargo.toml already carries everything this step needs:

Cargo.toml
[dependencies]
axum = { version = "0.8", features = ["macros"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Serialize lives behind the same serde derive feature you enabled for Deserialize in step 3. Axum’s Json type is gated behind axum’s json feature, and json is in axum’s default feature set, which features = ["macros"] does not turn off. Nothing to edit — if your file matches the block above, move on.

Wrap anything Serialize in Json and axum sets content-type: application/json for you. That is the whole mechanism, and you can see it without starting a server, because Json’s real job is to implement one trait — IntoResponse — and into_response() is an ordinary method you can call in a test or a demo:

use axum::{Json, http::StatusCode, response::IntoResponse};
use serde::Serialize;
#[derive(Serialize)]
struct Link {
code: String,
url: String,
}
#[tokio::main]
async fn main() {
let text = "ok".into_response();
let json = Json(Link {
code: "9xK2mQz".to_string(),
url: "https://example.com/very/long/path".to_string(),
})
.into_response();
let created = (
StatusCode::CREATED,
Json(Link {
code: "stub".to_string(),
url: "https://example.com".to_string(),
}),
)
.into_response();
for (label, r) in [
("a &str", &text),
("Json<Link>", &json),
("(StatusCode, Json<Link>)", &created),
] {
println!(
"{:<24} {} {}",
label,
r.status(),
r.headers()["content-type"].to_str().unwrap()
);
}
let body = axum::body::to_bytes(json.into_body(), 1024).await.unwrap();
println!("body: {}", String::from_utf8(body.to_vec()).unwrap());
}
a &str 200 OK text/plain; charset=utf-8
Json<Link> 200 OK application/json
(StatusCode, Json<Link>) 201 Created application/json
body: {"code":"9xK2mQz","url":"https://example.com/very/long/path"}

Three things are worth noticing. Every value a handler has ever returned in this track went through IntoResponse — &'static str implements it and that is why /health answers text/plain. Json<T> implements it too and serialises with the right header. And the tuple (StatusCode, Json<Link>) implements it as well: axum has impls for tuples of response parts, status first, and that is the standard way to send a non-200 status with a JSON body. A handler returning Json<Vec<Link>> would already be a working JSON API.

The same Json type is a body extractor when it appears in a handler’s arguments. Json<CreateLink> reads the request body and deserialises it into the struct — and it is a body extractor, so it must be the last argument, the rule step 3’s fail block demonstrated. The route gains a second method on the same path:

.route("/links", get(list_links).post(create_link))

get(...) returns a method router and .post(...) adds a handler for POST on the same path, so one .route call now owns both directions of /links.

src/main.rs
use axum::{
Json, Router,
extract::{Path, Query},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Pagination {
page: Option<usize>,
limit: Option<usize>,
}
#[derive(Deserialize)]
struct CreateLink {
url: String,
}
#[derive(Serialize)]
struct Link {
code: String,
url: String,
}
#[derive(Serialize)]
struct LinkPage {
page: usize,
limit: usize,
links: Vec<Link>,
}
#[derive(Serialize)]
struct ResolvedLink {
code: String,
target: Option<String>,
}
struct ApiResponse<T> {
status: StatusCode,
data: T,
}
impl<T: Serialize> ApiResponse<T> {
fn ok(data: T) -> Self {
Self {
status: StatusCode::OK,
data,
}
}
fn with_status(status: StatusCode, data: T) -> Self {
Self { status, data }
}
}
impl<T: Serialize> IntoResponse for ApiResponse<T> {
fn into_response(self) -> Response {
(self.status, Json(self.data)).into_response()
}
}
async fn health() -> &'static str {
"ok"
}
async fn create_link(Json(input): Json<CreateLink>) -> ApiResponse<Link> {
// Nothing is stored yet - step 8 adds the insert, step 9 the code
// generation. The stub code keeps the response shape right.
ApiResponse::with_status(
StatusCode::CREATED,
Link {
code: "stub".to_string(),
url: input.url,
},
)
}
async fn list_links(Query(p): Query<Pagination>) -> ApiResponse<LinkPage> {
// The database arrives in step 5; until then the listing has one
// hardcoded row so the JSON shape is visible.
let links = vec![Link {
code: "9xK2mQz".to_string(),
url: "https://example.com/very/long/path".to_string(),
}];
ApiResponse::ok(LinkPage {
page: p.page.unwrap_or(1),
limit: p.limit.unwrap_or(20),
links,
})
}
async fn resolve_link(Path(code): Path<String>) -> ApiResponse<ResolvedLink> {
// No database yet, so there is nothing to look the target up in.
// Step 8 replaces this with the real lookup.
ApiResponse::ok(ResolvedLink {
code,
target: None,
})
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/health", get(health))
.route("/links", get(list_links).post(create_link))
.route("/{code}", get(resolve_link));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

Four of the five handlers now return ApiResponse<T>. The response data is honest about what exists: the listing has one hardcoded row because the database arrives in step 5, the created link carries "stub" as its code because generation is step 9, and resolve reports target: null because there is nothing to look the target up in yet.

ApiResponse<T> is a thin type — a status and the data — but putting it between the handlers and axum is what buys the track its consistency. Every handler now answers the same way, because into_response is written once, in the impl highlighted above:

impl<T: Serialize> IntoResponse for ApiResponse<T> {
fn into_response(self) -> Response {
(self.status, Json(self.data)).into_response()
}
}

The body of that impl is the tuple from the demo. ApiResponse delegates: status and JSON body, one line, one place. Without it, create_link would return StatusCode and Json separately or reimplement the tuple by hand, and when the responses need a shared header or a different default status you would be editing every handler. When step 10 adds validation errors, the error side lands in this same type and every handler inherits the fix.

/health still returns &'static str, and that is deliberate. Its answer is a two-byte string a load balancer greps for; an envelope around it would be ceremony.

Start the server and try the create route. The -H header matters — without it axum refuses the request before your handler runs, which is the next section’s first row. The single quotes around the JSON body keep the shell from eating the braces:

Terminal window
curl -si -X POST http://127.0.0.1:8000/links \
-H 'content-type: application/json' \
-d '{"url":"https://example.com/very/long/path"}'
HTTP/1.1 201 Created
content-type: application/json
content-length: 58
date: Sun, 18 Oct 2026 02:27:28 GMT
{"code":"stub","url":"https://example.com/very/long/path"}

The listing and the resolve route answer in the same envelope, 200 with JSON:

Terminal window
curl -si "http://127.0.0.1:8000/links?page=2&limit=10"
HTTP/1.1 200 OK
content-type: application/json
content-length: 93
date: Sun, 18 Oct 2026 02:27:34 GMT
{"page":2,"limit":10,"links":[{"code":"9xK2mQz","url":"https://example.com/very/long/path"}]}
Terminal window
curl -si http://127.0.0.1:8000/9xK2mQz
HTTP/1.1 200 OK
content-type: application/json
content-length: 32
date: Sun, 18 Oct 2026 02:27:34 GMT
{"code":"9xK2mQz","target":null}

The rejections are worth one look each, because the Json extractor fails in three distinct ways with three distinct status codes, none of them 500:

What you sent Status Body
No content-type header 415 Expected request with \Content-Type: application/json``
Valid JSON, wrong shape ({}) 422 Failed to deserialize the JSON body into the target type: missing field \url` at line 1 column 2`
Malformed JSON ({"url":) 400 Failed to parse the request body as JSON: url: EOF while parsing a value at line 1 column 7

Note the 422 names the missing field, and the 400’s parse error carries the field path (url:) — serde_json’s error is wrapped so it points at where the JSON went wrong. Step 3 made the same observation about Query: the rejection happens before the handler, and intercepting it to send your own body is step 10’s job.

Every contract route exists and every handler answers through one type: POST /links parses a JSON body and answers 201, GET /links returns a page, GET /{code} reports what it will resolve, and /health stays plain text. The end state is tagged api-ch04 in the examples repository — git checkout api-ch04 inside axum-sqlx-api/ puts your tree where this article left it.

All of it runs on hardcoded data. Step 5 brings in Postgres through SQLx — a connection pool, the first migration’s groundwork, and the wrong DATABASE_URL you are going to write at least once. The track overview keeps the full list current as articles land.