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.
No new dependencies
Section titled “No new dependencies”Step 3’s Cargo.toml already carries everything this step needs:
[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.
The shortest path to JSON
Section titled “The shortest path to JSON”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-8Json<Link> 200 OK application/json(StatusCode, Json<Link>) 201 Created application/jsonbody: {"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.
Json on the way in: POST /links
Section titled “Json on the way in: POST /links”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.
The whole main.rs now
Section titled “The whole main.rs now”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.
One envelope, one IntoResponse
Section titled “One envelope, one IntoResponse”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.
What the routes answer now
Section titled “What the routes answer now”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:
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 Createdcontent-type: application/jsoncontent-length: 58date: 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:
curl -si "http://127.0.0.1:8000/links?page=2&limit=10"HTTP/1.1 200 OKcontent-type: application/jsoncontent-length: 93date: Sun, 18 Oct 2026 02:27:34 GMT
{"page":2,"limit":10,"links":[{"code":"9xK2mQz","url":"https://example.com/very/long/path"}]}curl -si http://127.0.0.1:8000/9xK2mQzHTTP/1.1 200 OKcontent-type: application/jsoncontent-length: 32date: 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.
Where this leaves the service
Section titled “Where this leaves the service”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.