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.

Three layers, and what survives when each is missing The HTML layer is a form with a real action and method, real constraint attributes, values echoed from the server and errors rendered beside their fields. With only this layer the form submits, validates on the server and reports errors after a reload — slower, but complete. The CSS layer presents those errors and states. Without it the messages are still present and still associated, just unstyled. The JavaScript layer intercepts the submit, validates inline and avoids the reload. Without it, nothing is lost except speed, because the layer beneath already did the job. JavaScript — intercept, validate inline, no reload absent: the form still submits and still reports errors — it is just slower CSS — present the errors and the states absent: messages are unstyled, but present, associated and announced HTML — a real action, real constraints, echoed values, rendered errors this layer alone is a complete, working, accessible form — everything above it is an improvement Build downwards: if the bottom layer is written last, it is written to fit the enhancement and stops being self-sufficient. One endpoint, two response shapes A normal form post arrives with an accept header of text/html and no custom header, and should be answered with a redirect on success or a re-rendered form with values and errors on failure. An enhanced post arrives asking for application/json, and should be answered with a JSON body on both paths. Content negotiation on one endpoint keeps a single validation implementation and a single error vocabulary; two endpoints is how the two paths start disagreeing. Request Success Failure accept: text/html redirect to the confirmation re-render with values and errors accept: application/json a JSON record a JSON error body either one validation implementation one error vocabulary Two endpoints for the same submission is how the enhanced path and the baseline start rejecting different things.

Step-by-Step Walkthrough

  1. Write the baseline first. A real action, a real method, and a server that validates and re-renders with values and errors.

  2. Echo the values back. A failed submission that empties the form is the fastest way to lose a reader.

  3. Render server errors beside their fields. With aria-describedby, in the HTML, before any script runs.

  4. Enhance on top. Intercept submit, send the same FormData to the same endpoint, render the same errors.

  5. Fall back on failure. If the fetch throws, call form.submit() — the baseline is still there.

  6. 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.

The test that proves the baseline still exists Disable JavaScript in the browser, load the form, submit it with a deliberately invalid value, and check that the page comes back with the reader's values still in the fields and the error rendered beside the right one. Then submit a valid one and check it succeeds. Four steps, no tooling, and it verifies the endpoint, the validation, the error rendering and the value echo independently of every line of client code. The test that proves the baseline still exists disable JS and load the form normally submit invalid the page returns with values intact read the error beside the right field, from the HTML submit valid it succeeds without any client code If step two loses the values, the baseline was never finished — and the enhanced path has been hiding it.

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.submitter is passed to FormData.
  • 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

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.