Forms & Content Negotiation
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 and axum-extra 0.12.6 on Rust 1.99 (edition 2024), checked on 2026-10-06. The
Bookshelf catalogue has server-rendered pages with plain HTML forms, and its API accepts JSON. Some endpoints serve
both — a review can be posted from the HTML page or by an API client. File uploads (multipart/form-data) are on
File Uploads.
The Form extractor
Form<T> deserialises an application/x-www-form-urlencoded body into T. For GET and HEAD requests it reads the
query string instead, so the same extractor handles a search form submitted with method="get":
use axum::{
Form, Router,
response::{Html, Redirect},
routing::get,
};
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Search {
q: String,
#[serde(default)]
available_only: bool, // an unchecked checkbox sends nothing: default to false
}
#[derive(Deserialize, Debug)]
struct NewReview {
isbn: String,
rating: u8,
text: String,
}
async fn search_page(Form(search): Form<Search>) -> Html<String> {
Html(format!("<p>results for {:?} (available only: {})</p>", search.q, search.available_only))
}
async fn review_form() -> Html<&'static str> {
Html(r#"<form method="post" action="/reviews">
<input name="isbn" value="9780441013593">
<input name="rating" type="number" min="1" max="5">
<textarea name="text"></textarea>
<button>Send</button>
</form>"#)
}
async fn submit_review(Form(review): Form<NewReview>) -> Redirect {
tracing::info!(?review, "review submitted");
// Post/Redirect/Get: a reload of the result page does not resubmit the form
Redirect::to(&format!("/books/{}", review.isbn))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/search", get(search_page))
.route("/reviews", get(review_form).post(submit_review));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum: Form and axum example: form
$ curl -s 'localhost:3000/search?q=dune&available_only=true'
<p>results for "dune" (available only: true)</p>
$ curl -si -d 'isbn=9780441013593&rating=5&text=Classic' localhost:3000/reviews | head -2
HTTP/1.1 303 See Other
location: /books/9780441013593
Source: axum: Form and axum example: form
-
A wrong
Content-Typeon aPOSTis a415; a field that does not parse (rating=five) is a422naming the field (see Extractors). -
HTML checkboxes send
name=onwhen checked and nothing when unchecked; serde’sbooldoes not accept"on", so the example only works fortrue/falsevalues. Use#[serde(default)]together with a small deserialiser (or anOption<String>and.is_some()) for real checkboxes. -
Formis also a response: returningForm(value)serialises it as a URL-encoded body. -
RawFormgives the undecoded bytes, for signature checks or forms with unusual encodings.
Multi-value fields: axum-extra’s Form
axum’s Form uses serde_urlencoded, which keeps only the last of repeated keys. Multi-selects and checkbox groups send
genre=sf&genre=fantasy; axum-extra’s Form (feature form, backed by serde_html_form) collects them into a Vec:
use axum::{Router, routing::post};
use axum_extra::extract::Form;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct ShelfPreferences {
#[serde(default)]
genre: Vec<String>,
newsletter: Option<String>, // checkbox: Some("on") when checked
}
async fn save(Form(prefs): Form<ShelfPreferences>) -> String {
format!("genres {:?}, newsletter: {}\n", prefs.genre, prefs.newsletter.is_some())
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/account/preferences", post(save));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum-extra: Form
$ curl -s -d 'genre=sf&genre=fantasy&newsletter=on' localhost:3000/account/preferences
genres ["sf", "fantasy"], newsletter: true
Source: axum-extra: Form
Accepting JSON or a form
An endpoint shared by HTML forms and API clients can choose the parser from the Content-Type header. The official
parse-body-based-on-content-type example does this with a custom extractor:
use axum::{
Form, Json, Router,
extract::{FromRequest, Request},
http::{StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
routing::post,
};
use serde::{Deserialize, de::DeserializeOwned};
/// Deserialises `T` from JSON or from a URL-encoded form.
struct JsonOrForm<T>(T);
impl<S, T> FromRequest<S> for JsonOrForm<T>
where
S: Send + Sync,
T: DeserializeOwned + 'static,
{
type Rejection = Response;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let content_type = req.headers().get(CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
if content_type.starts_with("application/json") {
let Json(value) = Json::<T>::from_request(req, state).await.map_err(IntoResponse::into_response)?;
return Ok(Self(value));
}
if content_type.starts_with("application/x-www-form-urlencoded") {
let Form(value) = Form::<T>::from_request(req, state).await.map_err(IntoResponse::into_response)?;
return Ok(Self(value));
}
Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response())
}
}
#[derive(Deserialize, Debug)]
struct NewReview {
rating: u8,
text: String,
}
async fn add_review(JsonOrForm(review): JsonOrForm<NewReview>) -> String {
format!("{review:?}\n")
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/api/v1/books/{isbn}/reviews", post(add_review));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
$ curl -s -H 'content-type: application/json' -d '{"rating":5,"text":"A classic"}' localhost:3000/api/v1/books/1/reviews
NewReview { rating: 5, text: "A classic" }
$ curl -s -d 'rating=4&text=Good' localhost:3000/api/v1/books/1/reviews
NewReview { rating: 4, text: "Good" }
Answering in HTML or JSON: the Accept header
Content negotiation also works in the other direction: the same resource can be returned as an HTML page or as JSON
depending on the client’s Accept header. A tiny extractor makes the preference available to handlers:
use std::convert::Infallible;
use axum::{
Json, Router,
extract::FromRequestParts,
http::{header::ACCEPT, request::Parts},
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Serialize;
enum Wants {
Html,
Json,
}
impl<S: Send + Sync> FromRequestParts<S> for Wants {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
let accept = parts.headers.get(ACCEPT).and_then(|v| v.to_str().ok()).unwrap_or("");
// simplified: a full implementation parses q-values (see the `accept-header` crate)
Ok(if accept.contains("text/html") { Wants::Html } else { Wants::Json })
}
}
#[derive(Serialize)]
struct Book {
isbn: &'static str,
title: &'static str,
}
async fn show(wants: Wants) -> Response {
let book = Book { isbn: "9780441013593", title: "Dune" };
let mut response = match wants {
Wants::Html => Html(format!("<h1>{}</h1><p>ISBN {}</p>", book.title, book.isbn)).into_response(),
Wants::Json => Json(book).into_response(),
};
// caches must store the two variants separately
response.headers_mut().insert("vary", "accept".parse().expect("static header value"));
response
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/books/{isbn}", get(show));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: RFC 9110: Content Negotiation
Most APIs keep negotiation simple: JSON under /api, HTML elsewhere. Negotiating on Accept pays off for error pages
(HTML for browsers, Problem Details for API clients) and for progressive enhancement with htmx, which marks its requests
with an HX-Request header instead (see htmx).
Re-displaying a form with errors
When server-side validation fails for an HTML form, the correct response is the same form, status 422, with the
user’s input preserved and a message next to each invalid field — not a JSON error and not a redirect that loses the
input. The pattern:
-
extract
Form<Input>(withOption/Stringfields so that even invalid input deserialises); -
validate it (see Validation) into a map of field errors;
-
on errors, render the template with the input and the errors and return
(StatusCode::UNPROCESSABLE_ENTITY, Html(...)); -
on success, perform the action and
Redirect::to(303) the result page.
The full Bookshelf review form, with askama templates and htmx for inline validation, is built on Server-side Templates and htmx. Forms that change state also need CSRF protection (see CSRF & Security Hardening).
Where the books are outdated
Yoon (Beginning Axum) builds its front end as a separate React application and does not cover HTML forms. Older axum
versions named the form rejection for query strings FailedToDeserializeQueryString; since 0.6 it is
FailedToDeserializeForm, and since 0.8 the message names the failing field.