Server Actions and Progressive Enhancement
Full-stack frameworks have brought an old idea back into fashion: the HTML form that posts to the server and works before — or without — any JavaScript. Next.js server actions, React Router (formerly Remix) actions and SvelteKit form actions all receive a FormData object, validate it on the server, and return either a redirect or the errors to show. Done well, this gives you the most robust validation architecture available: the server is authoritative, the form works on a slow connection while the bundle downloads, and client-side validation becomes an enhancement that makes feedback faster rather than the only thing standing between the user and a broken submission. Done badly, it produces forms that lose every typed value on an error, show errors nowhere near the fields, and double-validate with two different rule sets. This topic covers the pattern across frameworks, with the site’s canonical <form novalidate> plus Constraint Validation API layer added on top once JavaScript is available.
The core problem this solves is the “blank page on slow networks” failure: a form whose submit button does nothing until a large bundle has loaded, or whose validation only exists in client code that has not arrived yet.
Prerequisites for Progressively Enhanced Forms
| Requirement | Minimum version | Why it is needed |
|---|---|---|
| A framework with form actions | Next.js 14+ (React 19 hooks in 15+), React Router 7, SvelteKit 2 | Server-side handling of native posts |
| Shared validation schema | Zod 3.23+ or equivalent | Same rules in the action and in the browser |
Real <form> with action |
HTML | The no-JavaScript path |
name on every input |
HTML | Values reach FormData only by name |
| Post/redirect/get on success | HTTP 303 | Prevents resubmission on refresh |
| Error summary markup | HTML | Focus and navigation when no script moves focus |
Form Action API Reference
| Framework | Server entry point | Returning errors | Reading them in the UI |
|---|---|---|---|
| Next.js (App Router) | "use server" action receiving (prevState, formData) |
Return a state object | useActionState(action, initial) |
| React Router 7 | export async function action({ request }) |
return data({ errors }, { status: 422 }) |
useActionData() or fetcher.data |
| SvelteKit 2 | export const actions = { default: async ({ request }) => … } |
return fail(422, { errors, values }) |
form prop / $page.form |
| Plain HTML server | POST route handler |
Re-render the form with status 422 | Server template |
| All | <form method="post"> |
— | Works without JavaScript |
Step-by-Step Implementation
1. Start with a form that works with no JavaScript at all
<form method="post" action="/signup" novalidate>
<div id="error-summary" role="alert" tabindex="-1" hidden></div>
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" required
aria-describedby="email-err" value="">
<p id="email-err" class="field-error" hidden></p>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="new-password"
required minlength="12" aria-describedby="password-err">
<p id="password-err" class="field-error" hidden></p>
<button type="submit">Create account</button>
</form>
Note novalidate. Without JavaScript that means the browser does not block submission on its own, so every rule — including required — must be enforced by the server and shown by the server’s response. That is deliberate: it makes the server path complete, rather than relying on native bubbles that differ by browser and cannot be styled. Once script loads, the enhancement calls reportValidity() itself.
2. Validate in the action with the shared schema and return values
// shared: signup-schema.ts
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().trim().min(1, "Enter your email address.").email("Enter an email address like name@example.com."),
password: z.string().min(12, "Password must be at least 12 characters."),
});
export type FieldErrors = Partial<Record<"email" | "password", string>>;
export function validateSignup(fd: FormData): { ok: true; data: z.output<typeof signupSchema> } | { ok: false; errors: FieldErrors; values: Record<string, string> } {
const parsed = signupSchema.safeParse(Object.fromEntries(fd));
if (parsed.success) return { ok: true, data: parsed.data };
const errors: FieldErrors = {};
for (const issue of parsed.error.issues) errors[issue.path[0] as keyof FieldErrors] ??= issue.message;
// Echo back what the user typed — except secrets.
return { ok: false, errors, values: { email: String(fd.get("email") ?? "") } };
}
Returning values alongside errors is what keeps the user’s input on a no-JavaScript error round trip. The browser renders a fresh page from the server’s response, so anything not echoed back is lost. Never echo passwords, card numbers or file inputs.
3. Render errors next to fields and in a summary
The action’s response renders each error in its field’s described-by container, sets aria-invalid, and fills an error summary at the top whose links jump to each field. Without JavaScript, nothing moves focus automatically, so the summary is the navigation mechanism — the pattern in building an accessible error summary. Setting the page <title> to begin with “Error:” also helps screen reader users, who hear the title on page load.
4. Enhance with client-side validation once JavaScript loads
const form = document.querySelector<HTMLFormElement>("form[action='/signup']")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
// Client check first: same schema, instant feedback.
const result = validateSignup(new FormData(form));
applyErrors(form, result.ok ? {} : result.errors);
if (!form.reportValidity()) return;
// Then the real submission — the server still validates everything.
const res = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { accept: "application/json" } });
if (res.status === 422) {
applyErrors(form, (await res.json()).errors);
form.reportValidity();
return;
}
if (res.redirected) window.location.assign(res.url);
});
function applyErrors(form: HTMLFormElement, errors: Record<string, string | undefined>): void {
for (const el of form.querySelectorAll<HTMLInputElement>("input[name]")) {
el.setCustomValidity(errors[el.name] ?? "");
el.toggleAttribute("aria-invalid", Boolean(errors[el.name]));
}
}
Frameworks provide this interception for you — useActionState in Next.js, <Form> and fetcher.Form in React Router, use:enhance in SvelteKit — but the principle is identical. The framework-specific guides are Next.js server actions with useActionState, React Router action validation and SvelteKit form actions validation.
State Management and Edge Cases
A progressively enhanced form has two sources of state: the server’s last response (errors and echoed values) and the client’s live state (what the user is typing, client-side errors). The rules for merging them are simple but easy to break.
- Server errors clear on edit. A server error describes the value that was submitted. When the user edits that field, clear the error, as the mapping server field errors guide shows; do not wait for the next submission.
- Echoed values are defaults, not controlled state. Render them as
defaultValue(or thevalueattribute in plain HTML). Making inputs fully controlled by the action’s return value causes the typed text to snap back to the submitted value on re-render. - Pending state is shared. While the action runs, mark the form busy and prevent double submission; frameworks expose a pending flag (
isPending,navigation.state,submitting) for this. - Back and forward navigation. After a no-script error round trip, the browser’s back button returns to the previous page, not to the form before submission. That is usually what users expect, but it means the error page must be complete on its own — never rely on client state that only existed before the post.
- Redirect after success. Always redirect (303) after a successful post, so a refresh does not resubmit and the browser history is clean.
Accessibility Compliance for Server-Rendered Errors
The accessibility bar is the same as for client-side validation, but the mechanisms differ because the page reloads on the no-script path. WCAG 3.3.1 and 3.3.3 need text errors next to fields — the server template renders them. 2.4.3 Focus Order and general usability need the user to find the errors after reload; with no script to move focus, put the error summary first in the form, mark it role="alert" so it is announced, and prefix the document title with “Error:”. 4.1.3 Status Messages applies to the enhanced path: announce success and form-level failures through live regions rather than focus jumps. And 3.3.7 Redundant Entry is the reason the action must echo values back — making a user retype a whole form because one field failed is exactly what that criterion forbids.
// Server template helper: page title reflects the error state.
export const pageTitle = (base: string, errorCount: number) =>
errorCount ? `Error: ${errorCount} problem${errorCount > 1 ? "s" : ""} — ${base}` : base;
Common Gotchas and Debugging
Inputs without name. Controlled inputs in React often have value and onChange but no name, so the native post sends nothing. Every input needs a name matching the schema key.
Losing values on error. An action that returns only errors wipes the form on the no-script path.
// Before
return { errors };
// After: echo non-sensitive values so the re-rendered form keeps them
return { errors, values: { email: String(formData.get("email") ?? "") } };
Client-only rules. A rule that exists only in a client-side hook is skipped on the no-script path and by any direct request. Put rules in the shared schema; use client-only code for experience features like strength meters.
required without novalidate blocking the enhanced path. Without novalidate, the browser’s own bubble blocks submission before your enhanced handler runs, producing inconsistent UI. Keep novalidate and call reportValidity() yourself.
Redirecting with 302 after POST. Most browsers follow a 302 after POST with GET, but 303 states that intent explicitly and is what frameworks use by default for action redirects. Use 303.
Performance: Why Server-First Validation Feels Faster
It seems counter-intuitive that sending a form to the server could feel faster than validating it locally, but on real devices it often does. A form whose validation lives in a large client bundle cannot validate anything until that bundle has downloaded, parsed and hydrated — seconds on a mid-range phone on a mobile network. A server-first form is interactive the moment its HTML arrives: the user can fill it in and submit, and the server answers with errors in one round trip. Client-side enhancement then shaves that round trip off for the common mistakes once the script is available. The measurable effect is on Interaction to Next Paint and on abandonment during the loading window, which is exactly when many users try to submit. Keep the enhancement script small by importing only the shared schema the form needs, and load it with the form rather than in the global bundle.
Security Checks That Belong in Every Action
Because actions are ordinary POST endpoints, they inherit every web form risk, and validation is only one of the checks they need. Cross-site request forgery: a native post from another site carries the user’s cookies, so an action must verify origin. Next.js server actions compare the Origin header with the host automatically; React Router and SvelteKit rely on SameSite cookies and SvelteKit adds an origin check for form submissions by default; plain handlers should check Origin (or a CSRF token) explicitly. Authorisation: an action is callable directly, not only from the page that renders its form, so check that the signed-in user may perform this change inside the action itself. Size limits: set body limits before parsing FormData, especially when files are allowed. And mass assignment: parse into a schema that lists exactly the allowed fields, so a crafted role=admin field in the body is stripped rather than written to the database. The broader rationale is in why client-side validation is not security.
export function assertSameOrigin(request: Request): void {
const origin = request.headers.get("origin");
const host = new URL(request.url).origin;
if (origin !== null && origin !== host) throw new Response("Forbidden", { status: 403 });
}
Deciding What to Echo Back
Echoing submitted values keeps users from retyping, but not every value should travel back. Echo free text, selections and checkbox states. Never echo passwords, one-time codes, card numbers or security codes: browsers deliberately do not prefill type="password" from value, and rendering card data back into HTML widens your PCI scope. Files cannot be echoed at all — a file input cannot be prefilled — so for forms with uploads, either upload files separately as soon as they are chosen (storing an upload ID that can be echoed in a hidden field) or tell the user plainly that they need to attach the file again. Escaping is essential: echoed values go into HTML attributes, and every framework’s template layer escapes them by default; bypassing it with raw HTML insertion turns the error page into a reflected cross-site scripting vector.
Choosing Between Actions and a Separate API
Framework actions and a standalone JSON API are not mutually exclusive, and the choice affects validation more than it first appears. Actions shine when the form’s only consumer is the page that renders it: the action’s return value is typed end to end, the no-script path comes for free, and there is no separate error contract to design. A standalone API is better when mobile apps, partners or other services submit the same data; then the action becomes a thin adapter that calls the API and translates its validation error contract into the shape the page renders. The mistake to avoid is validating in both the action and the API with different rule sets — pick one owner of the rules (usually the API, or a shared schema used by both) and let the other layer pass errors through unchanged.
Multi-Step Forms With Actions
Multi-step flows fit the action model well if each step is its own action that validates only its own fields and stores the accumulated, validated data server-side (in a session or a draft record) rather than in hidden inputs the client can tamper with. The final step validates the complete object again with the full schema, because steps can be revisited and data can change. On the client, the enhancement can still validate each step instantly with the step’s slice of the shared schema, as described in validating multi-step forms per step; the server remains the authority at every step and again at the end.
Testing Both Submission Paths
A progressively enhanced form has two paths, and both need tests. Run the Playwright suite twice: once normally, and once with javaScriptEnabled: false in the test configuration. The no-script run proves the server path is complete — errors render, values survive, the summary links work, success redirects. The scripted run proves the enhancement does not break anything and that client and server errors look the same. A form that passes only the scripted run has quietly become a client-only form.
import { test, expect } from "@playwright/test";
test.describe("without JavaScript", () => {
test.use({ javaScriptEnabled: false });
test("errors and values survive the round trip", async ({ page }) => {
await page.goto("/signup");
await page.getByLabel("Email address").fill("ada@example");
await page.getByLabel("Password").fill("short");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page).toHaveTitle(/^Error:/);
await expect(page.getByLabel("Email address")).toHaveValue("ada@example");
await expect(page.getByRole("alert")).toContainText("Password must be at least 12 characters.");
});
});
Browser Compatibility Matrix
| Feature | Chromium | Firefox | Safari | Notes |
|---|---|---|---|---|
| Native form post | All | All | All | The baseline path |
fetch with FormData body |
Yes | Yes | Yes | Enhanced path |
requestSubmit() |
76+ | 75+ | 16+ | Triggers submit event and validation |
| View Transitions for form responses | 111+ | 144+ | 18+ | Optional polish for the enhanced path |
Frequently Asked Questions
What is progressive enhancement for forms?
Building the form so it works as a plain HTML post validated on the server, then adding JavaScript that validates in the browser and submits without a full page load. The form still works if the script is slow or fails.
Do I still need client-side validation with server actions?
Not for correctness — the server action validates everything. Add client-side validation from the same shared schema for faster feedback once JavaScript has loaded.
How do I keep the user's input when a server action returns errors?
Return the submitted, non-sensitive values with the errors and render them as the inputs' default values. Never echo passwords, card numbers or files.
Why use novalidate on a progressively enhanced form?
So the browser never blocks submission with its own inconsistent bubbles. Without JavaScript the server reports every error; with JavaScript your handler calls reportValidity() itself.
Related Guides
- Next.js Server Actions with useActionState — the pattern in the App Router.
- React Router Action Validation — actions, useActionData and fetchers.
- SvelteKit Form Actions Validation — fail(), use:enhance and form props.
- Progressive Enhancement Without JavaScript — the framework-free baseline.
← Back to Server and Full-Stack Validation