Form Handling and Validation

HTML forms are the primary input mechanism in a hypermedia-driven application. The browser collects data, the server validates and processes it, and the response is HTML. There is no JSON serialisation layer, no client-side state management for form data, and no separate API to keep in sync.

This section covers extracting form data in Axum handlers, sanitising and validating it, building a custom ValidatedForm extractor that combines all three steps, displaying errors with Maud and htmx, and the Post/Redirect/Get pattern for safe form submissions.

Extracting form data

Use the Form<T> extractor from axum-extra (not the one in axum itself). The axum-extra version uses serde_html_form under the hood, which correctly handles multi-value fields: multiple <input> elements with the same name (checkboxes, for example) and <select> elements with the multiple attribute. The standard axum::extract::Form uses serde_urlencoded, which does not support these cases.

[dependencies]
axum-extra = { version = "0.10", features = ["form"] }

Define a struct with Deserialize:

use serde::Deserialize;

#[derive(Deserialize)]
struct CreateContact {
    name: String,
    email: String,
    phone: Option<String>,
}

Extract it in a handler:

use axum_extra::extract::Form;
use axum::response::Redirect;

async fn create_contact(
    Form(input): Form<CreateContact>,
) -> Redirect {
    // input.name, input.email, input.phone are ready to use
    Redirect::to("/contacts")
}

For multi-value fields, collect into a Vec:

#[derive(Deserialize)]
struct SurveyResponse {
    name: String,
    #[serde(rename = "interest")]
    interests: Vec<String>,
}
html! {
    fieldset {
        legend { "Interests" }
        label {
            input type="checkbox" name="interest" value="rust";
            " Rust"
        }
        label {
            input type="checkbox" name="interest" value="web";
            " Web development"
        }
        label {
            input type="checkbox" name="interest" value="databases";
            " Databases"
        }
    }
}

Each checked box sends interest=rust&interest=web, and serde_html_form collects them into the Vec<String>.

Wire the handler to a POST route:

use axum::{routing::{get, post}, Router};

let app = Router::new()
    .route("/contacts", get(list_contacts))
    .route("/contacts", post(create_contact));

The corresponding HTML form:

use maud::{html, Markup};

fn contact_form() -> Markup {
    html! {
        form method="post" action="/contacts" {
            label for="name" { "Name" }
            input #name type="text" name="name" required;

            label for="email" { "Email" }
            input #email type="email" name="email" required;

            label for="phone" { "Phone" }
            input #phone type="tel" name="phone";

            button type="submit" { "Save" }
        }
    }
}

The name attributes on the <input> elements must match the struct field names. serde handles the mapping. For fields with names that differ from Rust conventions, use #[serde(rename = "field-name")].

Option<String> fields map to inputs that may be left blank. If the field is absent or empty in the form submission, serde deserialises it as None.

Handling deserialisation failures

If the form body cannot be deserialised into the target struct (missing required fields, wrong types), Axum returns a 422 Unprocessable Entity by default. For a better user experience, accept a Result and handle the rejection:

use axum_extra::extract::FormRejection;

async fn create_contact(
    form: Result<Form<CreateContact>, FormRejection>,
) -> impl IntoResponse {
    match form {
        Ok(Form(input)) => {
            // process valid input
            Redirect::to("/contacts").into_response()
        }
        Err(_) => {
            // re-render the form with a general error
            (StatusCode::UNPROCESSABLE_ENTITY, contact_form()).into_response()
        }
    }
}

In practice, deserialisation failures are rare when the HTML form matches the struct. Validation errors (invalid email format, value out of range) are the common case.

Sanitising input

User input needs cleaning before validation. Leading and trailing whitespace, inconsistent casing, and stray non-alphanumeric characters cause validation failures that are not the user’s fault. The sanitizer crate provides a derive macro that declares sanitisation rules directly on struct fields, the same way validator declares validation rules.

[dependencies]
sanitizer = "1"

Add Sanitize alongside Deserialize:

use sanitizer::prelude::*;
use serde::Deserialize;

#[derive(Deserialize, Sanitize)]
struct CreateContact {
    #[sanitize(trim)]
    name: String,

    #[sanitize(trim, lower_case)]
    email: String,

    #[sanitize(trim)]
    phone: Option<String>,
}

Call .sanitize() to modify the struct in place:

let mut input = CreateContact {
    name: "  Alice   ".into(),
    email: " Alice@Example.COM ".into(),
    phone: None,
};
input.sanitize();
// input.name == "Alice"
// input.email == "alice@example.com"

Available sanitisers

SanitiserEffect
trimRemove leading and trailing whitespace
lower_caseConvert to lowercase
upper_caseConvert to UPPERCASE
camel_caseConvert to camelCase
snake_caseConvert to snake_case
screaming_snake_caseConvert to SCREAMING_SNAKE_CASE
numericRemove all non-numeric characters
alphanumericRemove all non-alphanumeric characters
e164Convert phone number to E.164 international format
clamp(min, max)Clamp an integer to a range
clamp(max)Truncate a string to a maximum length
custom(function_name)Apply a custom sanitisation function

Custom sanitisation functions

For rules beyond the built-ins, write a function that takes &str and returns String:

use sanitizer::StringSanitizer;

fn collapse_whitespace(input: &str) -> String {
    let mut s = StringSanitizer::from(input);
    s.trim();
    s.get()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

#[derive(Deserialize, Sanitize)]
struct CreatePost {
    #[sanitize(custom(collapse_whitespace))]
    title: String,
}

Sanitisation runs before validation. Trim whitespace, normalise casing, and clean up formatting first, then validate the cleaned values. This order matters: " " (a single space) fails a length(min = 1) check only if you trim it first.

Server-side validation with validator

The validator crate provides a Validate derive macro that adds declarative validation rules to structs. Validation runs on the server after sanitisation and before any database or business logic.

[dependencies]
validator = { version = "0.20", features = ["derive"] }

Add Validate to the struct:

use sanitizer::prelude::*;
use serde::Deserialize;
use validator::Validate;

#[derive(Deserialize, Sanitize, Validate)]
struct CreateContact {
    #[sanitize(trim)]
    #[validate(length(min = 1, max = 255, message = "Name is required"))]
    name: String,

    #[sanitize(trim, lower_case)]
    #[validate(email(message = "Enter a valid email address"))]
    email: String,

    #[sanitize(trim)]
    #[validate(length(max = 20, message = "Phone number too long"))]
    phone: Option<String>,
}

Built-in validators

ValidatorUsageChecks
email#[validate(email)]Valid email format per HTML5 spec
url#[validate(url)]Valid URL
length#[validate(length(min = 1, max = 100))]String or Vec length bounds
range#[validate(range(min = 0, max = 150))]Numeric value bounds
must_match#[validate(must_match(other = "password_confirm"))]Two fields have the same value
contains#[validate(contains(pattern = "@"))]String contains a substring
does_not_contain#[validate(does_not_contain(pattern = "admin"))]String does not contain a substring
regex#[validate(regex(path = *RE_PHONE))]Matches a compiled regex
custom#[validate(custom(function = "check_slug"))]Runs a custom function

Every validator accepts an optional message parameter that provides the error text shown to users. Without it, the crate produces a default message keyed by the validation rule name.

Custom validation functions

For rules that don’t fit the built-in validators, write a function that returns Result<(), ValidationError>:

use validator::ValidationError;

fn validate_no_profanity(value: &str) -> Result<(), ValidationError> {
    let blocked = ["spam", "scam"];
    if blocked.iter().any(|w| value.to_lowercase().contains(w)) {
        return Err(ValidationError::new("profanity")
            .with_message("Contains blocked content".into()));
    }
    Ok(())
}

#[derive(Deserialize, Sanitize, Validate)]
struct CreatePost {
    #[sanitize(trim)]
    #[validate(length(min = 1, max = 200))]
    title: String,

    #[sanitize(trim)]
    #[validate(custom(function = "validate_no_profanity"))]
    body: String,
}

Nested validation

Structs containing other validatable structs use #[validate(nested)]:

#[derive(Deserialize, Sanitize, Validate)]
struct Address {
    #[sanitize(trim)]
    #[validate(length(min = 1))]
    street: String,
    #[sanitize(trim)]
    #[validate(length(min = 1))]
    city: String,
}

#[derive(Deserialize, Sanitize, Validate)]
struct CreateUser {
    #[sanitize(trim)]
    #[validate(length(min = 1))]
    name: String,
    #[sanitize]
    #[validate(nested)]
    address: Address,
}

The ValidatedForm extractor

Every form handler follows the same sequence: deserialise the body, sanitise the fields, validate, then branch on the result. A custom ValidatedForm<T> extractor wraps axum-extra’s Form and performs all three steps, so handlers never repeat the boilerplate.

The extractor uses FormRejection only for deserialisation failures (malformed request bodies). Validation failures are not rejections; they are a normal part of form handling. The extractor returns both the sanitised input and any validation errors, so the handler always has access to the user’s data for re-rendering the form.

use axum::extract::{FromRequest, Request};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum_extra::extract::{Form, FormRejection};
use sanitizer::prelude::*;
use validator::{Validate, ValidationErrors};

pub struct ValidatedForm<T> {
    pub input: T,
    pub errors: Option<ValidationErrors>,
}

impl<S, T> FromRequest<S> for ValidatedForm<T>
where
    S: Send + Sync,
    T: serde::de::DeserializeOwned + Sanitize + Validate,
    Form<T>: FromRequest<S, Rejection = FormRejection>,
{
    type Rejection = FormRejection;

    async fn from_request(
        req: Request,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        let Form(mut input) = Form::<T>::from_request(req, state).await?;
        input.sanitize();
        let errors = input.validate().err();
        Ok(ValidatedForm { input, errors })
    }
}

The handler pattern becomes:

async fn create_contact(
    validated: ValidatedForm<CreateContact>,
) -> impl IntoResponse {
    if let Some(errors) = &validated.errors {
        return (
            StatusCode::UNPROCESSABLE_ENTITY,
            render_contact_form(&validated.input, errors),
        ).into_response();
    }

    save_contact(&validated.input).await;
    Redirect::to("/contacts").into_response()
}

The ValidatedForm extractor handles the mechanical work. The handler deals only with the business logic: render errors or save and redirect.

Place the ValidatedForm definition in a shared crate in your workspace (e.g., common or web). Every form handler across the application can use it.

HTML5 client-side validation

Use HTML5 validation attributes as the first line of defence. They provide instant feedback without a server round-trip and reduce unnecessary requests. The server always validates too, because client-side validation is trivially bypassed.

The relevant attributes:

AttributePurposeExample
requiredField must not be emptyinput required;
type="email"Must look like an emailinput type="email";
type="url"Must look like a URLinput type="url";
minlength / maxlengthText length boundsinput minlength="1" maxlength="255";
min / maxNumeric or date boundsinput type="number" min="0" max="150";
patternRegex matchinput pattern="[A-Za-z]+" title="Letters only";

Apply these in your Maud templates alongside the server-side validator rules. Keep the constraints consistent: if the server requires length(min = 1, max = 255), set required minlength="1" maxlength="255" on the input.

fn contact_form_fields(input: Option<&CreateContact>) -> Markup {
    let name_val = input.map(|i| i.name.as_str()).unwrap_or("");
    let email_val = input.map(|i| i.email.as_str()).unwrap_or("");

    html! {
        label for="name" { "Name" }
        input #name type="text" name="name" value=(name_val)
            required minlength="1" maxlength="255";

        label for="email" { "Email" }
        input #email type="email" name="email" value=(email_val)
            required;
    }
}

HTML5 validation is not a substitute for server-side validation. It is a UX optimisation that catches obvious mistakes before they hit the network.

Displaying validation errors with Maud

When validation fails, re-render the form with the user’s input preserved and error messages next to the relevant fields. The ValidationErrors struct from validator maps field names to a list of ValidationError values, each with a message field.

A helper to extract the first error message for a given field:

use validator::ValidationErrors;

fn field_error(errors: &ValidationErrors, field: &str) -> Option<String> {
    errors
        .field_errors()
        .get(field)
        .and_then(|errs| errs.first())
        .and_then(|e| e.message.as_ref())
        .map(|msg| msg.to_string())
}

An error message component:

fn field_error_message(errors: Option<&ValidationErrors>, field: &str) -> Markup {
    let msg = errors.and_then(|e| field_error(e, field));
    html! {
        @if let Some(msg) = msg {
            span.field-error role="alert" { (msg) }
        }
    }
}

Wire it into the form:

fn render_contact_form(
    input: &CreateContact,
    errors: &ValidationErrors,
) -> Markup {
    html! {
        form method="post" action="/contacts" {
            div.form-error role="alert" {
                p { "Please fix the errors below." }
            }

            div.field {
                label for="name" { "Name" }
                input #name type="text" name="name" value=(input.name)
                    required minlength="1" maxlength="255";
                (field_error_message(Some(errors), "name"))
            }

            div.field {
                label for="email" { "Email" }
                input #email type="email" name="email" value=(input.email)
                    required;
                (field_error_message(Some(errors), "email"))
            }

            button type="submit" { "Save" }
        }
    }
}

The handler using ValidatedForm:

async fn show_contact_form() -> Markup {
    contact_form()
}

async fn create_contact(
    validated: ValidatedForm<CreateContact>,
) -> impl IntoResponse {
    if let Some(errors) = &validated.errors {
        return (
            StatusCode::UNPROCESSABLE_ENTITY,
            render_contact_form(&validated.input, errors),
        ).into_response();
    }

    save_contact(&validated.input).await;
    Redirect::to("/contacts").into_response()
}

Inline field validation with htmx

The full-form pattern above works without JavaScript. For a more responsive experience, add inline validation that checks individual fields as the user fills them in, using htmx to swap error messages without a full page reload.

Create a validation endpoint that accepts a single field value and returns just the error markup:

#[derive(Deserialize)]
struct FieldValidation {
    name: Option<String>,
    email: Option<String>,
}

async fn validate_field(
    Form(input): Form<FieldValidation>,
) -> Markup {
    // Build a partial struct for validation
    let mut contact = CreateContact {
        name: input.name.clone().unwrap_or_default(),
        email: input.email.clone().unwrap_or_default(),
        phone: None,
    };
    contact.sanitize();

    let errors = contact.validate().err();
    // Determine which field was submitted and return its error
    if input.name.is_some() {
        return field_error_message(errors.as_ref(), "name");
    }
    if input.email.is_some() {
        return field_error_message(errors.as_ref(), "email");
    }
    html! {}
}

Add htmx attributes to the form inputs. Each field posts its value on blur and swaps the error message next to it:

fn contact_form_with_inline_validation(
    input: Option<&CreateContact>,
    errors: Option<&ValidationErrors>,
) -> Markup {
    let name_val = input.map(|i| i.name.as_str()).unwrap_or("");
    let email_val = input.map(|i| i.email.as_str()).unwrap_or("");

    html! {
        form method="post" action="/contacts" {
            div.field {
                label for="name" { "Name" }
                input #name type="text" name="name" value=(name_val)
                    required minlength="1" maxlength="255"
                    hx-post="/contacts/validate"
                    hx-trigger="blur"
                    hx-target="next .field-error-slot"
                    hx-swap="innerHTML";
                span.field-error-slot {
                    (field_error_message(errors, "name"))
                }
            }

            div.field {
                label for="email" { "Email" }
                input #email type="email" name="email" value=(email_val)
                    required
                    hx-post="/contacts/validate"
                    hx-trigger="blur"
                    hx-target="next .field-error-slot"
                    hx-swap="innerHTML";
                span.field-error-slot {
                    (field_error_message(errors, "email"))
                }
            }

            button type="submit" { "Save" }
        }
    }
}

Register the validation endpoint:

let app = Router::new()
    .route("/contacts/new", get(show_contact_form))
    .route("/contacts", post(create_contact))
    .route("/contacts/validate", post(validate_field));

This layered approach gives three levels of validation feedback:

  1. HTML5 attributes catch basic mistakes instantly in the browser.
  2. htmx inline validation checks fields against server rules on blur, before submission.
  3. Full-form server validation on POST is the final authority. It always runs, catching anything the first two layers missed.

The form works without JavaScript (levels 1 and 3). htmx enhances it progressively.

Post/Redirect/Get

The Post/Redirect/Get (PRG) pattern prevents duplicate form submissions when users refresh the page after a POST. Without it, refreshing re-submits the form, potentially creating duplicate records.

The pattern:

  1. The browser POSTs the form data.
  2. The server processes it and responds with a 303 See Other redirect.
  3. The browser follows the redirect with a GET request.
  4. Refreshing the page repeats only the GET, not the POST.

In Axum:

use axum::response::Redirect;

async fn create_contact(
    validated: ValidatedForm<CreateContact>,
) -> impl IntoResponse {
    if let Some(errors) = &validated.errors {
        return (
            StatusCode::UNPROCESSABLE_ENTITY,
            render_contact_form(&validated.input, errors),
        ).into_response();
    }

    save_contact(&validated.input).await;
    Redirect::to("/contacts").into_response()
}

Redirect::to() sends a 303 See Other by default, which is correct for PRG. The browser converts the redirect to a GET regardless of the original method.

For success feedback after the redirect (a “Contact saved” flash message), store the message in the session before redirecting and display it on the next GET. Session management is covered in the Authentication section.

When the form is submitted via htmx (not a full page navigation), PRG is unnecessary. htmx replaces a targeted DOM fragment, and there is no browser history entry for the POST. The server can return an HTML fragment directly. Use the HxRequest extractor from axum-htmx to branch:

use axum_htmx::HxRequest;

async fn create_contact(
    HxRequest(is_htmx): HxRequest,
    validated: ValidatedForm<CreateContact>,
) -> impl IntoResponse {
    if let Some(errors) = &validated.errors {
        return (
            StatusCode::UNPROCESSABLE_ENTITY,
            render_contact_form(&validated.input, errors),
        ).into_response();
    }

    save_contact(&validated.input).await;

    if is_htmx {
        // Return updated content fragment
        render_contact_list().await.into_response()
    } else {
        // Standard PRG for non-htmx submissions
        Redirect::to("/contacts").into_response()
    }
}

CSRF protection

Every form that performs a state-changing action (POST, PUT, DELETE) needs protection against cross-site request forgery. Without it, a malicious page can submit a hidden form to your application using the victim’s authenticated session. CSRF protection is not specific to authentication forms; it applies to every form in the application, including the contact form examples above.

Apply the CSRF middleware layer to the router so it covers all routes with form handlers. The setup, configuration, and layer ordering are covered in the Authentication section.

File uploads

For forms that include file uploads, the browser sends multipart/form-data instead of URL-encoded data. The axum-typed-multipart crate provides a derive macro that handles multipart parsing with the same type-safe pattern as Form<T>.

[dependencies]
axum-typed-multipart = { version = "0.16", features = ["tempfile_3"] }
tempfile = "3"

The tempfile_3 feature streams uploads to temporary files instead of holding them in memory.

Define the upload struct:

use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use tempfile::NamedTempFile;

#[derive(TryFromMultipart)]
struct CreateDocument {
    title: String,

    #[form_data(limit = "10MB")]
    file: FieldData<NamedTempFile>,
}

FieldData<NamedTempFile> streams the upload to a temporary file on disk. The FieldData wrapper provides metadata: file.metadata.file_name for the original filename and file.metadata.content_type for the MIME type.

The handler:

async fn upload_document(
    TypedMultipart(input): TypedMultipart<CreateDocument>,
) -> impl IntoResponse {
    let file_name = input.file.metadata.file_name
        .unwrap_or_else(|| "unnamed".to_string());
    let content_type = input.file.metadata.content_type
        .unwrap_or_else(|| "application/octet-stream".parse().unwrap());

    // input.file.contents is the NamedTempFile
    // Move it to permanent storage or upload to S3
    let temp_path = input.file.contents.path();

    // ... process the file

    Redirect::to("/documents")
}

The form needs enctype="multipart/form-data":

html! {
    form method="post" action="/documents" enctype="multipart/form-data" {
        label for="title" { "Title" }
        input #title type="text" name="title" required;

        label for="file" { "File" }
        input #file type="file" name="file" required accept=".pdf,.doc,.docx";

        button type="submit" { "Upload" }
    }
}

For processing and storing uploaded files (S3-compatible storage, permanent paths, serving files back), see the File Storage section.

Alternatives

garde is an alternative validation crate with a different API style. Where validator uses string-based attribute arguments (#[validate(length(min = 1))]), garde uses Rust expressions (#[garde(length(min = 1))]) and supports context-dependent validation through a generic context parameter. Both crates are actively maintained. This guide uses validator because it is more widely adopted and its API is sufficient for typical web form validation.

Gotchas

Field names must match. The name attribute in the HTML form must match the struct field name exactly (or the #[serde(rename)] value). A mismatch causes deserialisation to silently use the default or fail entirely, depending on whether the field is Option.

Sanitise before validating. The ValidatedForm extractor handles this order automatically. If you call .validate() without sanitising first, a value like " " (whitespace) passes a length(min = 1) check even though it contains no meaningful content.

Validation runs after deserialisation. If serde cannot parse the form body at all (e.g., a required field is completely missing), Axum rejects the request before sanitisation or validation ever runs. The ValidatedForm extractor surfaces this as a FormRejection.

validator checks values, not business rules. Format and range checks belong on the struct. Rules that require database access (uniqueness, referential integrity) belong in the handler or service layer, after validation passes.

Optional fields need special handling with validator. #[validate(email)] on an Option<String> only validates the inner value when it is Some. An empty optional field passes validation, which is usually what you want. If a field should be non-empty when present, add #[validate(length(min = 1))].

CSRF protection is not optional. Every POST form needs CSRF middleware, not just login and registration. A contacts form, a settings page, a comment box: if it changes state, it needs protection. See CSRF protection for setup.

multipart/form-data for file uploads. Standard Form<T> only handles URL-encoded bodies. If the form includes a file input, the enctype must be multipart/form-data and the handler must use TypedMultipart<T> instead of Form<T>. The ValidatedForm extractor does not apply to multipart forms.