The exact problem: a server-rendered form works perfectly until the JavaScript bundle fails — a flaky network, a blocked CDN, an old browser — and then the submit button does nothing at all, because it was never a submit button.
Context and Prerequisites
This builds on hydration sync for SSR forms, which covers keeping the two renders identical. Progressive enhancement is the other half: making the server-rendered form work before, and without, the client-side code that improves it.
The framing that makes this tractable is that enhancement is additive. Start from a form that posts to an endpoint and reloads. Everything the client adds — inline validation, optimistic rendering, no full reload — is an improvement on a thing that already worked.
Core Pattern: The Baseline, Then the Enhancement
<!-- The baseline. This submits, validates and reports errors with no
JavaScript at all. Note: a real action, a real method, real constraints. -->
<form method="post" action="/signup" novalidate>
<label for="email">Email address</label>
<input id="email" name="email" type="email" required
aria-describedby="email-error" value="{{ values.email }}">
<p id="email-error">{{ errors.email }}</p>
<button type="submit">Create account</button>
</form>
/**
* The enhancement. Intercepts the submit, does the same thing over fetch, and
* falls back to the native submission for anything it cannot handle.
*/
function enhance(form: HTMLFormElement): void {
form.addEventListener('submit', async (e) => {
// Let the browser do it natively when the reader asked for a new tab, or
// when a non-standard submitter is involved.
if (e.defaultPrevented) return;
e.preventDefault();
const body = new FormData(form, (e as SubmitEvent).submitter ?? undefined);
form.setAttribute('aria-busy', 'true');
try {
const res = await fetch(form.action, { method: form.method, body,
headers: { 'accept': 'application/json' } });
if (!res.ok) return renderErrors(await res.json());
onSuccess(await res.json());
} catch {
// The enhancement failed; the baseline still exists. Submit natively
// rather than showing a client-side error the reader cannot act on.
form.submit();
} finally {
form.removeAttribute('aria-busy');
}
});
}
novalidate on the form is deliberate. The server validates regardless, so native bubbles would be a second, differently worded validation layer that only some readers see. Turning it off and keeping the constraint attributes gives you the semantics — required is still announced — without the browser’s own UI.
Step-by-Step Walkthrough
-
Write the baseline first. A real
action, a realmethod, and a server that validates and re-renders with values and errors. -
Echo the values back. A failed submission that empties the form is the fastest way to lose a reader.
-
Render server errors beside their fields. With
aria-describedby, in the HTML, before any script runs. -
Enhance on top. Intercept
submit, send the sameFormDatato the same endpoint, render the same errors. -
Fall back on failure. If the fetch throws, call
form.submit()— the baseline is still there. -
Keep one error renderer. The server’s HTML and the client’s DOM updates should produce the same markup, or the two paths drift.
Failure Modes and Edge Cases
1. The endpoint only speaks JSON
An enhanced-only endpoint means the baseline posts and gets JSON back. Content-negotiate: return HTML for a normal form post, JSON when the request asks for it.
2. The submitter is lost
new FormData(form) omits the button that submitted, so “Save” and “Save and add another” become indistinguishable. Pass e.submitter.
3. Double submission during the fetch
The native submit is prevented but the button is still enabled. Set aria-busy and disable the submitter for the duration — the same guard as any other submit.
4. Enhancement applied before the DOM is ready
Attaching the listener to a form that has not parsed yet silently does nothing. Enhance on DOMContentLoaded, or use event delegation on the document.
5. The reader opens the submit in a new tab
Modifier-clicking a submit button, or an Enter on a link inside the form, may produce a navigation you should not intercept. Check defaultPrevented and the submitter’s target before preventing.
Verification Checklist
Common Pitfalls
- Writing the baseline last. A baseline added after the enhanced path is written to fit it, and stops being self-sufficient — which is the only property that mattered.
- An endpoint that only speaks JSON. The baseline then posts and receives a JSON body the browser renders as text. Content-negotiate on one endpoint rather than maintaining two.
- Losing the submitter. Two submit buttons with different meanings become indistinguishable on the enhanced path unless
event.submitteris passed toFormData. - Leaving native validation on. The browser’s bubbles are a second, differently worded validation layer that only some readers see. Keep the constraint attributes, add
novalidate. - Two error renderers. The server’s HTML and the client’s DOM updates drift within a release, and the drift shows up as an error that looks different depending on how it was triggered.
Related
- Hydration Sync for SSR Forms — keeping the two renders identical
- Reading Values with FormData on Submit — the payload both paths share
- Server Error Reconciliation — rendering the response on the enhanced path
← Hydration Sync for SSR Forms
Frequently Asked Questions
Is progressive enhancement still worth it for an app behind a login?
The no-JavaScript reader is not the main beneficiary — the reader whose bundle failed to load is, and that happens on flaky connections, blocked CDNs and old browsers regardless of authentication. A form with a real action degrades to slow rather than to broken. It also gives you a free integration test: if the baseline works, the endpoint, the validation and the error rendering are all correct independently of the client.
Should the form use novalidate?
Usually yes, while keeping the constraint attributes. The attributes carry semantics that assistive technology uses — required is announced — but the browser’s native error bubbles are a second validation layer with wording you do not control and behaviour that varies. With novalidate the submit reaches your handler or the server, and there is exactly one source of messages.
How do I keep the server and client error rendering identical?
Render from one template. If the server produces HTML and the client updates the DOM, extract the error markup into something both can produce — a small template function shared through the build, or a server-rendered fragment the client fetches. Two hand-written renderers drift within a release, and the drift shows up as an error that looks different depending on how it was triggered.