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.

One form, two submission paths The same form posts natively to a server action before JavaScript loads, and is intercepted and enhanced once it has loaded; both paths use the same server validation and return the same errors. HTML form action + method, real inputs No JS yet native POST, full page response JS loaded intercept, validate, fetch Server action parse FormData, shared schema Result redirect, or errors + values
The server action is the single source of truth; client-side validation only makes the first error appear sooner.

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.

The no-JavaScript error round trip Without JavaScript, the browser posts the form, the server action validates and responds with the re-rendered form containing errors and the user's values, and the user fixes and resubmits. Browser (no JS) Server action Template POST /signup (urlencoded) errors + values (no password) 422 page: summary, inline errors, values user follows summary link, fixes, resubmits POST /signup 303 → /welcome
The no-script path is complete on its own: errors, values and a summary come back in the server's HTML.

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 the value attribute 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.
Progressively enhanced form states The form starts idle, becomes pending on submit, and ends either redirected on success or showing errors with the user's values; editing a field clears its server error. idle pending errors shown editing redirected submit 422 + values input clears that error submit again 303
Server errors belong to the submitted value and disappear on edit; only a successful action leaves the page.

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.

When validation first becomes available A bar chart comparing how long after navigation a user can get validation feedback for a client-only form versus a server-first form with enhancement, on a mid-range phone. Client-only form (bundle + hydrate) 4.2 s after navigation Server-first, before enhancement 1.1 s after navigation Server-first, after enhancement 2.6 s after navigation
Illustrative timings: a server-first form can validate as soon as its HTML arrives, while a client-only form must wait for its bundle to download, parse and hydrate.

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

Progressive enhancement building blocks Support for the platform features that progressively enhanced forms use, across Chromium, Firefox and Safari. Chromium Firefox Safari form POST + 303 redirect ✓ Yes ✓ Yes ✓ Yes FormData from form ✓ Yes ✓ Yes ✓ Yes requestSubmit() ✓ 76+ ✓ 75+ ✓ 16+ formdata event ✓ 77+ ✓ 72+ ✓ 15+
The no-script path uses only HTML every browser has supported for decades; enhancements use widely available APIs.
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.

← Back to Server and Full-Stack Validation

Explore This Section