Progressive Enhancement Without JavaScript
What does a form’s validation look like when JavaScript has not loaded yet — because the network is slow, a script failed, a corporate proxy blocked it, or the user turned it off? For most modern forms the honest answer is “broken”: the submit button does nothing, or the form posts and the server’s response is a bare error page. This recipe builds the baseline that every enhanced form should rest on: a plain HTML form that posts to the server, a server that validates with the shared schema and re-renders the same form with errors beside each field, preserved values, an error summary with links, and an “Error:” page title — and then a thin script that adds setCustomValidity() and reportValidity() from the Constraint Validation API on top without changing the server’s behaviour.
When to Build the No-JavaScript Path First
Build it first for any form whose failure costs something real: sign-up, sign-in, checkout, applications, government and healthcare services, contact and support forms. It matters most when:
- Users are on slow or unreliable networks, where scripts arrive seconds after the HTML.
- The form is the product’s front door, where a broken submit loses the user entirely.
- Accessibility and resilience are requirements, as in public-sector services whose standards require working without JavaScript.
Framework actions give you most of this automatically — see Next.js server actions, React Router actions and SvelteKit form actions — but the principles are framework-independent, and this recipe shows them with nothing but HTML, a server handler and a small script.
Minimal Working No-JavaScript Form
// server/render-contact.ts — a plain template function (any server framework)
type Errors = Partial<Record<"name" | "email" | "message", string>>;
type Values = Partial<Record<"name" | "email" | "message", string>>;
const esc = (s = "") => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
const FIELDS = [
{ name: "name", label: "Your name", type: "text", autocomplete: "name" },
{ name: "email", label: "Email address", type: "email", autocomplete: "email" },
{ name: "message", label: "Message", type: "textarea", autocomplete: "off" },
] as const;
export function renderContact(values: Values = {}, errors: Errors = {}): string {
const count = Object.keys(errors).length;
const summary = count
? `<div class="error-summary" role="alert" tabindex="-1" id="error-summary">
<h2>There ${count === 1 ? "is a problem" : `are ${count} problems`}</h2>
<ul>${FIELDS.filter((f) => errors[f.name]).map((f) => `<li><a href="#${f.name}">${esc(errors[f.name])}</a></li>`).join("")}</ul>
</div>`
: "";
const field = (f: (typeof FIELDS)[number]) => {
const err = errors[f.name];
const common = `id="${f.name}" name="${f.name}" autocomplete="${f.autocomplete}" required${err ? ` aria-invalid="true" aria-describedby="${f.name}-err"` : ""}`;
const control = f.type === "textarea"
? `<textarea ${common} rows="6" minlength="10">${esc(values[f.name])}</textarea>`
: `<input ${common} type="${f.type}" value="${esc(values[f.name])}">`;
return `<div class="field${err ? " field--error" : ""}">
<label for="${f.name}">${f.label}</label>
${err ? `<p id="${f.name}-err" class="field-error"><span class="visually-hidden">Error:</span> ${esc(err)}</p>` : ""}
${control}
</div>`;
};
return `<!doctype html><html lang="en"><head>
<title>${count ? `Error: ` : ""}Contact us</title>
<script type="module" src="/js/enhance-contact.js"></script>
</head><body><main>
<h1>Contact us</h1>
<form method="post" action="/contact" novalidate>
${summary}
${FIELDS.map(field).join("")}
<button type="submit">Send message</button>
</form>
</main></body></html>`;
}
// server/contact-route.ts
export async function POST(request: Request): Promise<Response> {
const fd = await request.formData();
const values: Values = { name: String(fd.get("name") ?? ""), email: String(fd.get("email") ?? ""), message: String(fd.get("message") ?? "") };
const parsed = contactSchema.safeParse(values); // shared schema
if (!parsed.success) {
const errors: Errors = {};
for (const i of parsed.error.issues) errors[i.path[0] as keyof Errors] ??= i.message;
return new Response(renderContact(values, errors), { status: 422, headers: { "content-type": "text/html; charset=utf-8" } });
}
await sendMessage(parsed.data);
return new Response(null, { status: 303, headers: { location: "/contact/sent" } });
}
Five details make this work without any script. The page title begins with “Error:”, which is the first thing a screen reader announces on the new page. The error summary is the first element inside the form, marked role="alert", with links whose href jumps to each field’s id. Each error sits above its input, inside the label’s field group, with a visually hidden “Error:” prefix. Values are echoed back, escaped. And success redirects with 303, so refreshing the confirmation page never resubmits.
The Thin Enhancement Layer
Once the page works without script, the enhancement adds speed and focus management — nothing else. It must never be the only place a rule lives.
// /js/enhance-contact.ts
const form = document.querySelector<HTMLFormElement>("form[action='/contact']");
if (form) {
// 1. If the server returned a summary, focus it so keyboard users start there.
document.querySelector<HTMLElement>("#error-summary")?.focus();
// 2. Native pre-check on submit: instant feedback for required/type/minlength.
form.addEventListener("submit", (event) => {
if (!form.reportValidity()) event.preventDefault();
// Valid → let the native post proceed; the server validates again.
});
// 3. Clear a server-rendered error as soon as its field is edited.
form.addEventListener("input", (event) => {
const el = event.target as HTMLInputElement | HTMLTextAreaElement;
if (el.getAttribute("aria-invalid") !== "true") return;
el.removeAttribute("aria-invalid");
document.getElementById(`${el.id}-err`)?.remove();
});
}
The enhancement deliberately leaves the native post in place for valid submissions rather than switching to fetch. That is a legitimate choice: the server round trip is already correct and accessible, and the script only prevents avoidable ones. Upgrading to fetch submission later is an additive change, as shown in the framework guides.
Baseline Option Reference
| Element | Where | Purpose | Notes |
|---|---|---|---|
method="post" + action |
form | Native submission | The whole no-script path depends on it |
novalidate |
form | No inconsistent native bubbles | Server reports everything; script calls reportValidity() |
| Error summary | top of form | Navigation to errors | role="alert", links to field ids |
| “Error:” title prefix | <title> |
First announcement after reload | Remove when there are no errors |
| Echoed values | inputs | No retyping | Escape; never echo secrets |
| 422 status | response | Correct semantics for tools and tests | 303 redirect on success |
| Visually hidden “Error:” | each message | Context when read in isolation | Pairs with aria-describedby |
Verification Steps
import { test, expect } from "@playwright/test";
test.use({ javaScriptEnabled: false });
test("the form validates without JavaScript", async ({ page }) => {
await page.goto("/contact");
await page.getByLabel("Your name").fill("Ada");
await page.getByRole("button", { name: "Send message" }).click();
await expect(page).toHaveTitle("Error: Contact us");
await expect(page.getByRole("alert")).toContainText("There are 2 problems");
await page.getByRole("link", { name: /email address/i }).click();
await expect(page.getByLabel("Email address")).toBeFocused();
await expect(page.getByLabel("Your name")).toHaveValue("Ada");
});
Edge Cases and Failure Modes
CSRF protection that needs JavaScript. Some setups inject CSRF tokens with script. Render the token as a hidden input in the server template instead, so the native post carries it.
Buttons that only work with script. <button type="button" onclick="submit()"> does nothing without JavaScript. Use a real type="submit" button inside the form.
Client-rendered forms. If the form itself is rendered by client-side JavaScript, there is nothing to submit before the script runs. Server-render the form markup; hydrate or enhance it afterwards.
Field-level widgets without fallbacks. A custom date picker or select that only exists after hydration leaves no input to post. Render a native input first and replace or enhance it, as described in date and time validation.
Measuring How Often the Baseline Matters
It is tempting to dismiss the no-JavaScript path as serving a tiny minority who disabled scripts deliberately. In practice the audience is everyone during the seconds before scripts load, plus everyone whose scripts failed: an ad blocker that matched your bundle name, a flaky connection that dropped one chunk, a browser extension that threw during hydration, an old device that cannot parse modern syntax. You can measure this directly. Log, on the server, form submissions that arrive as native posts (no Accept: application/json header, or a missing hidden field your script adds); the ratio of native to enhanced submissions is the share of users who would have been stuck without the baseline. Teams are regularly surprised by how far above zero it is, and it is exactly the population for which the error summary, echoed values and page title are the entire user experience. The same resilience thinking applies to the upload and file paths in validating drag and drop file uploads, where the native file input must remain usable when the drop zone script is absent.
Frequently Asked Questions
How do I validate a form without JavaScript?
Post the form to the server, validate there with your shared schema, and on failure re-render the same form with a 422 status, the user's values, an error next to each field, an error summary at the top and a page title starting with "Error:".
Should a progressively enhanced form use novalidate?
Yes. It prevents the browser's inconsistent built-in bubbles, so without JavaScript the server reports every error and with JavaScript your code calls reportValidity() and controls the messages.
How do users find errors on a server-rendered error page?
Through the error summary: it is the first thing in the form, is announced as an alert, and links to each field. Prefixing the page title with "Error:" also tells screen reader users immediately.
Does the enhancement script need to validate everything?
No. It only needs to make feedback faster and manage focus. Every rule must still run on the server, so a failed or blocked script changes speed, not correctness.
Related Guides
- Server Actions and Progressive Enhancement — how frameworks automate this baseline.
- Building an Accessible Error Summary — the summary component in depth.
- Prevent Default Form Submission Without Losing Validation — the enhancement’s submit handling.
- Validating FormData on the Server with Zod — parsing the native post.