Skip to content
Chapter 44Lesson 7

Progressive enhancement

Why the chapter's native forms and Server Actions still submit before JavaScript loads.

Picture a user on hotel Wi-Fi or a subway train. The page paints fast, because the server sent ready-made HTML, but the JavaScript bundle is still crawling down the wire. The user fills in the new-invoice form, taps Create invoice, and hits enter before the bundle has finished downloading and hydrating.

At that moment, none of the machinery you built over the last six lessons exists yet. React hasn’t attached its onSubmit interceptor. There’s no background POST, no useActionState watching the result, no pending spinner. The submit fires into a page that has rendered but isn’t interactive yet.

Does the click just fail? Does the invoice get lost?

No. The invoice still gets created. The browser does what <form> has always done: it collects the inputs into a FormData payload and issues a plain HTTP POST to the form’s action URL. The framework routes that POST to the same createInvoice Server Action. The action parses, mutates, revalidates, and redirects, and the user lands on a page showing their new invoice. No spinner, no inline banner, but the work got done.

That property has a name: the form is progressively enhanced , and you didn’t build it on purpose. Every choice in the last six lessons was already a progressive-enhancement choice: the action prop instead of onSubmit plus fetch, uncontrolled inputs instead of per-field useState, the Server Action instead of a client fetch.

Single-page apps trained a reflex that a form needs JavaScript to work, which makes progressive enhancement read like extra effort you bolt on for an accessibility audit. In the pattern this chapter taught, it’s the default, and you have to actively work to lose it.

So here is the precise definition for this chapter. A form is progressively enhanced when its submit succeeds with JavaScript disabled, or before the JavaScript bundle has loaded. The form’s function works in both modes; the experience degrades without JavaScript. The entire lesson turns on that split.

Carry one mental model out of this lesson: one Server Action, two front doors. Both lead into the same room, the action body you built earlier: createInvoice parses FormData, authorizes, mutates, revalidates, redirects. The action is written once and never knows which door the request came through.

The first door is the one you’ve used all chapter. Call it the JavaScript-enhanced door. Once the bundle loads and React has hydrated , React intercepts the submit, serializes the named inputs into FormData, POSTs to the bound action in the background, flips isPending, reads the returned Result, and re-renders in place with a banner, field errors, or a reset on success. Nothing navigates, so the user never leaves the page.

The second door is what this lesson is about. Call it the native-browser door. With no JavaScript running, the browser falls back to its built-in form handling: it collects the named inputs into FormData and issues a real HTTP POST to the form’s action URL. You never wrote that URL. It’s the opaque action ID the build generated, which doubles as a registered POST endpoint. So even with the runtime dead, there is a real URL to submit to and a real handler on the other side.

Both doors converge on the same FormData. This is where the name contract from the start of the chapter pays off: every input’s name becomes a FormData key in both modes, because the browser reads name attributes off the DOM whether or not React is alive. That is the deeper reason the chapter kept inputs uncontrolled by default: the lighter the client state, the more the platform can do for you when the client isn’t there.

Walk the same submit through both doors below. Drag the slider to scrub from the click to the response.

With
JavaScript
Click
submit fired
React intercepts
background POST
Without JS /
pre-hydration
Click
submit fired
Browser native POST
built-in form handling
FormData
name attrs -> keys
createInvoice
the one action body
Result -> inline re-render
banner / fields / reset, no nav
303 redirect -> navigation
browser loads a fresh page
Same click, two mechanisms. With JavaScript, React's submit handler intercepts and POSTs in the background. Without it, the browser issues its built-in form POST.
With
JavaScript
Click
submit fired
React intercepts
background POST
Without JS /
pre-hydration
Click
submit fired
Browser native POST
built-in form handling
FormData
name attrs -> keys
createInvoice
the one action body
Result -> inline re-render
banner / fields / reset, no nav
303 redirect -> navigation
browser loads a fresh page
Both produce identical FormData. The browser reads each input's name attribute either way, so the name contract is mode-agnostic.
With
JavaScript
Click
submit fired
React intercepts
background POST
Without JS /
pre-hydration
Click
submit fired
Browser native POST
built-in form handling
FormData
name attrs -> keys
createInvoice
the one action body
Result -> inline re-render
banner / fields / reset, no nav
303 redirect -> navigation
browser loads a fresh page

One action body. createInvoice runs its five seams (parse, authorize, mutate, revalidate, return) with no idea which door the request came through.

With
JavaScript
Click
submit fired
React intercepts
background POST
Without JS /
pre-hydration
Click
submit fired
Browser native POST
built-in form handling
FormData
name attrs -> keys
createInvoice
the one action body
Result -> inline re-render
banner / fields / reset, no nav
303 redirect -> navigation
browser loads a fresh page

Here the doors diverge. With JavaScript, the returned Result re-renders inline: isPending flips back and a banner, field errors, or a reset appear. Without it, the action’s redirect() returns a 303 and the browser navigates to a fresh page. Same function, different experience, and that divergence is progressive enhancement.

One precision. The native door exists because this chapter passes a Server Action to the form: a Server Function submitted before hydration becomes a real HTTP POST. A client action would behave differently, with React queuing the submit until hydration finishes, so there would be no native door at all. It’s the Server-Action shape, the chapter’s default, that earns the second door, not any action prop.

For any piece of this chapter, you should be able to answer one question: does it work without JavaScript? One rule decides every case: everything the platform provides survives; everything React-the-runtime provides does not.

The platform is the browser and the server: the form POST, the constraint checks the browser runs before submit, the redirect() the server returns, the revalidatePath() that freshens the next request, the Server Component re-render the user lands on. React-the-runtime is the JavaScript that hydrates and re-renders: useActionState’s inline Result, useFormStatus, useOptimistic, the auto-reset, the inline field errors. None of it runs without the bundle.

The one consequence worth holding onto: constraint validation is the only validation layer that survives a no-JavaScript submit. It fires in the browser before the POST, so you keep the check but get the browser’s native error bubble instead of your design system’s inline message, which was the JavaScript layer.

Works without JavaScript

The platform handles it


<form action={…}> submit

Native POST to the build-registered action URL

Constraint Validation API

required, type="email", pattern, min/max — fire in the browser before submit; the only validation layer that survives no-JS

redirect() from the action

Server returns a 303; the browser navigates

revalidatePath()

The next request reads fresh data; the redirected page is current

Server Component re-render

After the redirect: the updated invoice list, rendered server-side

Needs JavaScript

The React runtime handles it


useActionState inline render

The Result is still produced — but nothing renders it without the hook

useFormStatus pending UI

No spinner, no disabled submit — pending stays false forever

useOptimistic instant update

Pure JS; no-JS users wait for the real round-trip

Automatic form reset

React owns it — and it's moot here anyway, the redirect replaced the page

Inline field errors

Same root cause: no React to read state and render under the input

Every form feature in the chapter, sorted by whether the platform or the React runtime provides it.

How a no-JavaScript user sees a validation error

Section titled “How a no-JavaScript user sees a validation error”

Everything so far has been what the platform does for free. This is the one place where you make a call.

On the JavaScript door, a validation failure returns { ok: false, error }, useActionState renders it inline, and the user fixes the field without leaving the page. On the native door there is no hook to render that Result. The action still produces it, but the browser is mid-navigation. So the question is open: how does a no-JavaScript user see a validation error?

There are two answers, and the right one depends on the form.

The encode-and-redisplay path is thorough. On failure, instead of returning the Result, the action redirects back to the form’s route with the error in a URL search param. The form’s Server Component reads searchParams and renders the error on the server, so the no-JavaScript user sees a real inline error. The cost is more code and a second error-rendering path the JavaScript door never uses.

The degraded path is the pragmatic default. You let the no-JavaScript error experience be a plain re-render, leaning on the browser’s native constraint bubbles for the cheap cases (an empty required field, a malformed email) and accepting that richer server-authored field errors render only on the JavaScript door. The form still works: a no-JavaScript user who submits something that slipped past constraint validation gets a rough failure, but cannot create a bad row, because the server still rejected it.

For a typical 2026 SaaS at this stage, accept the degraded experience. The no-JavaScript cohort is small, the common success path already redirects cleanly, the cheap validation cases are caught by the constraint API in both modes, and the encode-and-redisplay machinery rarely earns its weight.

What flips the call is a form that must be flawless without JavaScript: a regulatory form, a high-traffic public unauthenticated form, an audience you know runs with JavaScript off. Reach for the thorough path when the stakes demand it, not by reflex.

You have already built the success half without trying. Recall the action shape: success calls redirect() to the new resource, and failure returns a Result. That redirect() lands cleanly on both doors, so a correctly shaped action is already progressive-enhancement-ready for success. The only open question is how hard you work the failure path.

The two tabs below show that decision as the same action’s failure branch, before and after.

app/invoices/actions.ts
export async function createInvoice(
prevState: Result<Invoice> | null,
formData: FormData,
): Promise<Result<Invoice>> {
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
// No JS: this Result has nowhere to render — the browser's native bubble is the fallback.
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const invoice = await createInvoiceRecord(parsed.data);
revalidatePath('/invoices');
redirect(`/invoices/${invoice.id}`);
}

The action you already wrote. Success redirects, carrying the no-JavaScript success experience for free. On failure the Result renders inline only when React is alive; without it, the browser’s constraint bubble is the fallback. Ship this for a typical SaaS: solid success path, rough rare no-JavaScript failure, no extra code.

The encode-and-redisplay tab is illustrative: a raw ?error= param, not a production schema. The point is the decision. The project’s CRUD forms in a later chapter ship the degraded default only.

One note on useActionState’s optional third argument, permalink, a URL string. When the action is a Server Function and the form is submitted before the JavaScript bundle loads, the browser navigates to that URL; it does not POST to it. For React to carry the state across the navigation, the destination must render the same form. Once the page is interactive, the argument does nothing. The framework’s default routing already covers common layouts, so the reflex is to omit it unless a pre-hydration submit needs to land somewhere other than the current route.

Progressive enhancement is not only insurance for the JavaScript-disabled. Every user passes through the pre-hydration window on first load, the gap between seeing the page and the page becoming interactive. On a fast connection it is milliseconds; on a subway train it is seconds. A submit during that gap takes the native door whether or not JavaScript is enabled, so progressive enhancement is about every user during first paint, not a niche audience.

Five reflexes that keep the native door open

Section titled “Five reflexes that keep the native door open”

Progressive enhancement isn’t something you add; it’s something you avoid removing. So these aren’t watch-outs, they’re a removal list: five ways to break the native door, each the flip side of a reflex you already built this chapter.

  1. Use the action prop, not onSubmit plus fetch. The fetch reflex requires JavaScript for the submit itself, so break this and there’s no native door at all: the form is dead without the bundle. The “who owns the endpoint” rule pointed here already, and progressive enhancement is the reason behind it.

  2. Keep inputs uncontrolled. A controlled input needs JavaScript to hold its value; an uncontrolled input round-trips through the platform’s native POST. “defaultValue, never value” is this same reflex wearing a different hat.

  3. Don’t put load-bearing UI behind a React-only hook. useOptimistic and useFormStatus are enhancements. If the result of a mutation is only ever visible through useOptimistic, a no-JavaScript user never sees it. Pair the mutation with the action’s redirect() and revalidatePath() so the server-rendered page carries the truth regardless of the runtime.

  4. Don’t gate the submit button on JavaScript-only state. A disabled={someClientState} that’s wrong or absent without JavaScript can block the button or mis-enable it, so default it enabled and let the server be the boundary of correctness. There’s a real edge here: before hydration the button is enabled and isPending can’t fire yet, so a double-submit is possible. Don’t gate your way out with more JavaScript; rely on the action’s idempotency, which is the actual defense.

  5. Keep the HTML semantic. Native elements like <button type="submit">, <label for>, and <form action> work in both doors. A <div onClick> masquerading as a submit button has no native door, so the browser’s form machinery can’t see it.

None of these is new: they’re reflexes you already hold, re-seen as the things that protect what the platform gives you for free.

If progressive enhancement comes free with the pattern, why test it? Because a refactor can break it silently. Someone rewrites a form to onSubmit plus fetch to add one feature; someone wraps the action in an arrow function and severs the native binding. Neither throws an error, and neither fails the type-checker, because the breakage lives in string props and JSX the compiler can’t reason about. The form just quietly loses its native door.

So the discipline is one cheap manual pass, at feature-launch time, on every important form.

  1. Open DevTools and the command palette: Cmd+Shift+P on Mac, Ctrl+Shift+P on Windows or Linux. Type “javascript” and choose Disable JavaScript. It stays off while DevTools is open.

  2. Reload the form. You now see exactly what a pre-hydration user sees.

  3. Submit an invalid value first, and confirm the browser’s native constraint bubble catches the cheap cases.

  4. Submit valid data. Confirm the row is created, the redirect fires, and you land on a page with a fresh server-rendered list.

Be clear about what “pass” means, because it’s easy to fail it for the wrong reason. Passing means the function works: the row is created, the navigation happens, and constraint validation catches the cheap errors. It does not mean the experience matches the JavaScript path. It won’t, by design: no spinner, no inline banner, a full navigation instead of an in-place patch. That’s the degraded experience you chose, not a bug.

Calibrate the cost too. Automated progressive-enhancement testing in CI is heavyweight and rarely worth it for a web app at this stage. The value is this one manual pass: the disciplines above prevent the regressions, and the pass confirms they held.

One last case to know: a form with <input type="file"> needs enctype="multipart/form-data" for the native door to send the actual file, not just its filename. The framework sets this when the action prop is wired, but adding the explicit enctype is the safe move for the no-JavaScript path. File uploads get their own chapter later.