Progressive enhancement for free
Progressive enhancement, the property that makes the chapter's native HTML forms and Server Actions submit successfully even 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, because React hasn’t woken up on the client.
Does the click just fail? Does the invoice get lost?
No. The invoice still gets created. The browser does what <form> has done since 1995: 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 very same createInvoice Server Action. The action parses, mutates, revalidates, 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 . The part that should reframe how you think about it is that you didn’t build this on purpose. Every choice you made 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. You just didn’t have the name for it.
The 2018–2023 single-page-app era trained a reflex: a form needs JavaScript to work. With that reflex, progressive enhancement reads like a niche, extra-effort concern, something you bolt on for the accessibility audit. In the pattern this chapter taught, it’s the default. You have to actively work to lose it.
That inverts the whole lesson. This is the chapter’s closer, and it teaches almost no new code. There is no new hook here. The job is to see the progressive enhancement the chapter already built, understand exactly what survives a no-JavaScript submit and what degrades, and learn the short discipline list that keeps it intact. The senior posture is worth memorizing:
One precise definition before we go further, because the term sprawls in casual use and we want a sharp one 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. Hold onto that split between function and experience, because the entire lesson turns on it.
One Server Action, two front doors
Section titled “One Server Action, two front doors”Here’s the mental model to carry out of this lesson: one Server Action, two front doors. Both doors lead into the same room, the action body. That room is exactly the seam you built earlier: createInvoice parsing FormData, authorizing, mutating, revalidating, redirecting. The action is written once and has no idea which door the request came through.
The first door is the one you’ve been using all chapter. Call it the JavaScript-enhanced door. When the bundle has loaded and React has hydrated , React intercepts the submit, serializes the named inputs into FormData, calls the bound action as a background POST, flips isPending, reads the returned Result, and re-renders in place: a banner, field errors, or an auto-reset on success. There’s no navigation, so the user never leaves the page.
The second door is the one 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. That URL isn’t something you wrote. It’s the opaque action ID the build step generated. Recall from earlier in the chapter that when a Client Component imports a Server Action, React rewrites the import into an opaque identifier, and that identifier doubles as a POST endpoint the framework registers at build time. So even with the runtime dead, there is a real URL for the form to POST to, and a real handler waiting on the other side.
The two doors converge on the same FormData shape. This is where the name contract from the start of the chapter pays off: every input’s name attribute becomes a FormData key in both modes. The browser doesn’t care whether React is alive, because it reads name attributes off the DOM either way. This is the deeper reason the chapter insisted on uncontrolled-by-default inputs: 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 in the following diagram. Drag the slider to scrub from the click through to the response.
JavaScript
pre-hydration
JavaScript
pre-hydration
JavaScript
pre-hydration
One action body. createInvoice runs its five seams (parse, authorize, mutate, revalidate, return) with no idea which door the request came through.
JavaScript
pre-hydration
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() sends a 303 and the browser navigates to a fresh page. The function is identical and the experience differs, and that divergence is progressive enhancement.
One precision to keep you honest, because it’s tempting to over-generalize. 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, and that’s the whole trick. A client action passed to a form behaves differently: React queues the submit until hydration finishes, so there’s no native door at all. Don’t walk away thinking “any action prop works without JavaScript.” It’s specifically the Server-Action shape, the chapter’s default, that earns this. You only ever use Server Actions here, so the rule is simple in practice, but the distinction matters if you ever wonder why.
What survives without JavaScript
Section titled “What survives without JavaScript”Now the honest ledger. You want to be able to answer, for any piece of this chapter, “does it work without JavaScript?” One clean 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 URL, the constraint checks the browser runs before submit, the redirect the server sends back: all platform. React-the-runtime is the JavaScript that hydrates and re-renders: the hooks, the inline patches, the optimistic flashes. None of that runs without the bundle.
Walk down each side with that rule in hand. The submit itself survives, because it’s a native POST to the build-registered endpoint. The browser’s Constraint Validation API survives: required, type="email", pattern, min and max all fire in the browser before the submit, no JavaScript needed. That’s worth sitting with, because it means constraint validation is the only validation layer that survives a no-JavaScript submit. The trade-off is that you get the browser’s native error bubble back, not the polished inline message your design system rendered, since that rendering was the JavaScript layer. The redirect() your action calls on success survives, because it’s just a 303 the browser follows. The revalidatePath() survives, because the next request reads fresh data and the post-redirect page is current. And the Server Component re-render after that redirect survives, so the user sees the updated invoice list, rendered on the server.
Now the other side. useActionState’s inline render of the Result does not survive, because there’s no React to re-render in place. Note the subtlety: the action still produces the Result, it just has nowhere to render it without the hook. useFormStatus doesn’t survive: pending stays false forever, so there’s no in-flight affordance, no spinner and no disabled button. useOptimistic doesn’t survive: the instant UI change is pure JavaScript, so a no-JavaScript user waits for the real round-trip and sees the result only after the navigation. The automatic form reset on success doesn’t survive either, but for the create case it’s moot, because the redirect already replaced the page, so there’s nothing left to reset. And the inline, Result-driven field errors don’t survive, for the same root cause as useActionState: no React to read the state and render under each input.
The following table is the at-a-glance version. Keep both columns in view, since a ledger is most useful when you can see both sides at once.
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
Sort the pieces yourself in the following exercise. Drag each feature into the column where it belongs.
Sort each piece of the chapter's form into where it works. Drag each item into the bucket it belongs to, then press Check.
<form action={…}> submitrequired / constraint validationredirect()revalidatePath()useActionState inline errorsuseFormStatus spinneruseOptimistic instant updateThe no-JavaScript error path is the one real decision
Section titled “The no-JavaScript error path is the one real decision”Everything so far has been “here’s what the platform does.” This section is different: it’s the one place where you make a call, and it’s where the senior judgment of this lesson lives. Give it your attention.
Here’s the problem, stated precisely. On the JavaScript door, a validation failure returns { ok: false, error }, useActionState renders it inline, the user fixes the field and resubmits, and they never leave the page. On the native door, there’s no hook to render that Result. The action still produces it, but the browser is mid-navigation, headed somewhere new. So the question is genuinely open: how does a no-JavaScript user see a validation error?
There are two honest answers, and they’re worth presenting as a decision rather than a recipe, because the right one depends on what the form is for.
The first is the encode-and-redisplay path, the thorough one. On failure, instead of returning the Result, the action redirects back to the form’s own route with the error encoded in a URL search param. The form’s Server Component reads searchParams and server-renders the error state. Now the no-JavaScript user sees a real inline error, rendered on the server. The cost is real too: meaningfully more code, and a second rendering path for errors that the JavaScript door never even uses.
The second is the degraded path, the pragmatic default. You let the no-JavaScript error experience be a plain re-render, leaning on the browser’s native constraint bubbles to catch the cheap cases such as an empty required field or a malformed email, and you accept that the richer, server-authored field errors only render on the JavaScript door. The form still works. A no-JavaScript user who submits something genuinely invalid that slipped past constraint validation gets a less polished failure, but they cannot create a bad row, because the server still rejected it. The function stays intact; the experience is rough on the rare failure.
Here’s the senior call, stated plainly: for a typical 2026 SaaS at this stage, accept the degraded no-JavaScript error experience. The reasoning is a stack of small truths. The no-JavaScript cohort is small. The success path, which is the common case, already works and 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. So you default it off.
What flips the call? A form that genuinely must be flawless without JavaScript: a regulatory form, a public unauthenticated high-traffic form, an audience you know runs with JavaScript off. Those justify the thorough path. Default it off, and reach for it when the form’s stakes demand it, not reflexively.
The reassuring part is that you’ve already built the success half of the progressive-enhancement story without trying. Recall the action shape from earlier: success calls redirect() to the new resource, and failure returns a Result. That redirect() on success is exactly the right no-JavaScript move, because it’s what makes the success path land cleanly in both doors. On the JavaScript door the returned Result renders inline; on the native door it’s the success redirect() that carries the whole experience, and the failure Result is the thing the encode-and-redisplay path would have to surface by hand. So a correctly-shaped action is already progressive-enhancement-ready for the success path. The only open question is how hard you work on the failure path.
The two tabs below show that decision as the before and after of the same action’s failure branch.
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, and that line carries 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. This is the default for a typical SaaS: the success path is solid, the rare no-JavaScript failure is rough, and you ship no extra code.
export async function createInvoice( prevState: Result<Invoice> | null, formData: FormData,): Promise<Result<Invoice>> { const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { // Encode the error in the URL so the Server Component can render it without JS. redirect(`/invoices/new?error=validation`); }
const invoice = await createInvoiceRecord(parsed.data); revalidatePath('/invoices'); redirect(`/invoices/${invoice.id}`);}export default async function NewInvoicePage({ searchParams }: PageProps) { const { error } = await searchParams; return <NewInvoiceForm error={error} />;}The thorough path. On failure the action redirects back to the form’s own route with the error encoded in the URL; the Server Component reads searchParams and renders the error server-side, so even a no-JavaScript user sees it inline. The cost: a second error-rendering path the JavaScript door never uses. Worth it only when the form must be flawless without JavaScript.
The encode-and-redisplay tab is deliberately illustrative: a raw ?error= param, not a production param schema. The point is the decision, not a copy-paste pattern. When you build the project’s CRUD forms in a later chapter, they ship the degraded default only.
One last thing to name here, then set it down. useActionState takes an optional third argument, permalink. You’ll see it in the docs and wonder whether you should be using it. Almost always the answer is no. Here’s what it actually does, stated precisely, because the casual descriptions of it are wrong. The third argument of useActionState(action, initial, permalink?) is a URL string. When the action is a Server Function and the form is submitted before the JavaScript bundle loads, the browser navigates to the permalink URL; it does not POST to it. That distinction matters: you’ll see “the URL the fallback POSTs to” repeated around the web, and it’s incorrect. For React to carry the state across that navigation, the destination page must render the same form component, with the same action and the same permalink. Once the page is interactive, the argument does nothing at all.
In practice, the framework’s default routing already handles the common form layouts, so you omit permalink and everything works. The 2026 reflex is to leave it out unless a specific pre-hydration navigation needs to land somewhere other than the current route. Don’t reach for it by habit.
That last point is the one to underline. Progressive enhancement isn’t only insurance for the tiny JavaScript-disabled cohort. 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’s milliseconds; on that subway train it’s seconds. A submit during that gap takes the native door, whether JavaScript is enabled or not. So progressive enhancement is really about every user during first paint, not a niche audience.
Five disciplines that keep it working
Section titled “Five disciplines that keep it working”You don’t add progressive enhancement; you avoid removing it. So the watch-outs aren’t really watch-outs; they’re a removal list. Here are the five ways to break it, each one the flip side of a reflex you already hold from this chapter.
-
Use the
actionprop, notonSubmitplusfetch. Thefetchreflex requires JavaScript for the submit itself. Break this and there’s no native door at all, so the form is dead without the bundle. The “who owns the endpoint” rule from earlier already pointed here, and progressive enhancement is the deeper reason behind it. -
Keep inputs uncontrolled. A controlled input needs JavaScript to hold its value; an uncontrolled input round-trips through the platform’s native POST. The “
defaultValue, nevervalue” reflex is a progressive-enhancement reflex wearing a different hat. -
Don’t put load-bearing UI behind a React-only hook.
useOptimisticanduseFormStatusare enhancements, not load-bearing. If the result of a mutation is only ever visible throughuseOptimistic, a no-JavaScript user never sees it. Pair the mutation with the action’sredirect()andrevalidatePath()so the server-rendered page carries the truth regardless of the runtime. -
Don’t gate the submit button behind a JavaScript-only condition. A
disabled={someClientState}that’s wrong or absent without JavaScript can block the button or mis-enable it. Default the button enabled, since the server is the boundary of correctness. There’s a real edge here: before hydration, the button is enabled andisPendingcan’t fire yet, so a double-submit is possible. Don’t try to JavaScript-gate your way out of it. Rely on the action’s idempotency, which is the actual defense. -
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 it’s invisible to the browser’s form machinery.
Read that list again and notice what it isn’t: it isn’t five new things to learn. It’s five reflexes you already built, re-seen as the things that protect what the platform gives you. You don’t add progressive enhancement; you avoid removing it. These five are the removal list.
Test it once: submit with JavaScript off
Section titled “Test it once: submit with JavaScript off”If progressive enhancement is the default of the pattern, why test it at all? Because a refactor can break it silently. Someone rewrites a form to onSubmit plus fetch to add just one thing. Someone makes an input controlled to drive a character counter and forgets the defaultValue fallback. Someone wraps the action in an arrow function and severs the native binding. None of these throw an error. None of these fail the type-checker, because the code lives in string props and JSX the compiler can’t reason about this way. The form just quietly loses its native door.
So the 2026 reflex is one cheap manual pass, at feature-launch time, on every important form.
-
Open DevTools and the command palette:
Cmd+Shift+Pon Mac,Ctrl+Shift+Pon Windows or Linux. Type “javascript” and choose Disable JavaScript. JavaScript stays off while DevTools is open. -
Reload the form. You’re now looking at exactly what a pre-hydration user sees.
-
Fill it in and submit. Try an invalid value first, and confirm the browser’s native constraint bubble catches the cheap cases.
-
Submit valid data. Confirm the row is created and you land on a sensible page, with the redirect fired and the server-rendered list fresh.
Be clear about what “pass” means here, because it’s easy to mark it failed 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, and 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 SaaS at this stage. The value is in this one manual pass, which is the backstop for the discipline list above. The disciplines prevent the regressions; the test confirms they held. When you build the project’s full CRUD surface in a later chapter, it ends with exactly this check.
Why this was free
Section titled “Why this was free”Step back. This chapter never spent a lesson “adding” progressive enhancement, and now you can see why: the platform-native form pattern produces it as a byproduct. The action prop had its own justification, namely no manual fetch and no preventDefault. Uncontrolled inputs had theirs: no per-keystroke re-renders, with the DOM owning the value. The Server Action seam had its own: one typed body for parse, authorize, mutate, revalidate, return. Each choice paid for itself on its own terms. Progressive enhancement is the dividend they pay together, and you collected it without a line of dedicated code.
So the takeaway, one last time: don’t engineer progressive enhancement. Refuse to break it, and test once that you didn’t.
One edge case to name before you go, so it doesn’t surprise you later: a form with <input type="file">. For the native door to send the file as an actual file rather than just its filename string, the form needs enctype="multipart/form-data". The framework sets that for you when the action prop is wired, but adding the explicit enctype is the safe move for the no-JavaScript path. That’s the whole mention. Uploads get their own chapter later, so don’t go building one now.
External resources
Section titled “External resources”The action prop reference, including the dedicated 'display a form submission error without JavaScript' example — the lesson's encode-and-redisplay path.
The hooks reference, including the permalink argument and its pre-hydration navigation behavior.
How a Server Action submits as a native POST with progressive enhancement by default, and the redirect-on-success carrier.
The platform-neutral definition, and how it contrasts with graceful degradation.