Form validation
Server validates and returns a per-field error map plus the submitted values. The page reads both via actionData and renders inputs with error styling while keeping the user's typing intact. A successful submit 303s to a confirmation URL — clear distinction between failure and success.
Tip
Returning a plain object from the action (rather than a Response) triggers the action → loader → render path: the page receives errors via actionData without leaving the current URL. The 303 redirect on success makes the confirmation URL refreshable and prevents form re-submission.
Try it
Try submitting empty, a bad email, or a username that's too short.
// Returning a plain object on failure re-renders the page with
// actionData — field errors + the user's typed values, no redirect.
// A successful submit 303s to a shareable confirmation URL.
export async function action({ request }) {
const form = await request.formData();
const { values, errors } = validate(form);
if (Object.keys(errors).length > 0) {
return { ok: false, values, errors }; // re-renders page via actionData
}
return new Response(null, {
status: 303,
headers: { Location: "/examples/validation?ok=" + values.username },
});
}
// Page reads errors and prefilled values from actionData:
function field({ name, errors, values, ...rest }) {
return (
<label>
<input
name={name}
value={values[name] ?? ""}
aria-invalid={errors[name] ? "true" : undefined}
{...rest}
/>
{errors[name] ? <span class="err">{errors[name]}</span> : null}
</label>
);
}