skip to content

Progressive Enhancement

Because an action bound to a form is a real POST target, the form works before hydration finishes and even with JavaScript disabled. Interviewers raise it to see whether you know which usage patterns silently give that property up.

part ofNext.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In a Next.js App Router app, one form is written as <form action={createTodo}> where createTodo is a Server Action, and another as <form onSubmit={handleSubmit}> whose handler calls the same Server Action. Which one still submits if the page's JavaScript never loads, and why?

level: juniorimportance: must knowfreq 52%

answer

  1. one is a real POST target
  2. the other needs a listener attached
  3. view source, not the inspector
  4. hydration is a prerequisite for handlers
  5. action prop survives, onSubmit does not

basics

~20 s

Only the form using the action prop still submits. Next renders it as an ordinary POST form carrying the action's identity in hidden fields, so the browser can submit it natively. An onSubmit handler cannot run until JavaScript loads and hydrates the page.

solid answer

~40 s

When you pass a Server Action to a form's `action` prop, the HTML that ships from the server is a real `<form method="post">` whose hidden fields identify which action to run. The browser can submit that form on its own — no client JavaScript required — so it works with JS disabled, blocked, or simply not downloaded yet. The `onSubmit` version has no such fallback: the submit handler is a JavaScript function attached during hydration, so until React hydrates that component (or forever, if the bundle fails) nothing calls the action, and the browser just performs its default submission. The rule I apply is that the mutation must be expressible as "a form posting to a URL"; anything that only starts on a JS event handler is enhancement, not baseline.

code

tsx · 14 lines
tsx
// app/todos/page.tsx — Server Component
export default function TodosPage() {
  async function createTodo(formData: FormData) {
    'use server'
    console.log(formData.get('title'))
  }

  return (
    <form action={createTodo}>
      <input name="title" />
      <button type="submit">Add</button>
    </form>
  )
}

go deeper

for a junior

Be able to say plainly that <form action={serverAction}> still submits without JavaScript while an onSubmit handler does not, and that the difference is visible in the page's HTML source.

for a middle

Explain the mechanism: server rendering emits a real POST form with hidden fields identifying the action, whereas a handler is a listener that only exists after hydration. Name what is lost in the no-JS path.

for a senior

Show how you would audit a codebase for this — view-source checks, a JS-disabled pass over critical flows — and how you would keep pending and error UI as an enhancement layered over a working baseline.

for a principal

Own the standard: decide which flows must hold the baseline, make the form-action pattern the default the team reaches for, and ensure the guarantee is verified automatically rather than asserted in a doc.

## What is actually being compared Progressive enhancement here means one narrow, checkable property: **can the mutation start without the page's JavaScript having run?** Not "does the app look nice", not "is it accessible" — just whether the browser alone can get the request to the server. Two forms look almost identical in a React file, but they compile to very different HTML. ## The `action` prop version ```tsx async function createTodo(formData: FormData) { 'use server' await db.todo.create({ title: String(formData.get('title')) }) } <form action={createTodo}> <input name="title" /> <button type="submit">Add</button> </form> ``` When this is rendered on the server, what the browser receives is an ordinary HTML form: `method="post"`, targeting the page's own URL, plus hidden fields that identify which Server Action should run. Nothing in that markup depends on the client runtime. A browser with JavaScript turned off, a browser whose JS bundle 404'd, or a browser that simply has not finished downloading and hydrating yet will still do what browsers have always done with a form: serialize the named inputs and POST them. Next receives that POST, matches the identifier to the action, runs it on the server, and answers with a fresh page. ## The `onSubmit` version ```tsx 'use client' <form onSubmit={async (e) => { e.preventDefault() await createTodo(new FormData(e.currentTarget)) }}> ``` This form has no action target and no hidden identity fields. The only thing that would call `createTodo` is a JavaScript event listener, and that listener does not exist until React hydrates the component. Before hydration the submit button either does nothing useful or triggers the browser's default submission, which here is a plain navigation with no action attached. If the bundle never loads at all, the form is permanently inert. Note what did *not* save it: the action is still a real server endpoint, and `'use server'` still applied. Having a callable endpoint is worthless if nothing in the HTML tells the browser how to address it. ## The pattern rule The property is a property of **how you invoke the action**, not of the action itself, and not of which component type declares it: - `<form action={serverAction}>` — baseline behaviour, works pre-hydration. - `<form onSubmit={handler}>` — requires hydration. - `<button onClick={() => serverAction()}>` — requires hydration. A Server Action passed as the `action` prop keeps the property even when the form is rendered from a Client Component, because server rendering still emits the real form markup. What breaks it is switching from "the browser starts the request" to "our JavaScript starts the request". ## What you still need JavaScript for Being honest about this in an interview matters. Without JS the submission is a full-page navigation: the page reloads, scroll position resets, and the client-side niceties layered on top of the action — in-flight pending states, optimistic UI, inline error rendering without a reload — do not happen, because those are client-runtime features. Progressive enhancement does not promise an identical experience; it promises the mutation is not lost. Once hydration completes, React intercepts the same form and upgrades the submission to a client-side one, and the user gets the richer version of the same flow. ## How to check it Don't reason about it — look. Open the page, view source (not the DevTools element inspector, which shows the hydrated DOM), and search for `<form`. If it has `method="post"` and hidden fields, the mutation survives without JS. If the markup is a bare `<form>` or just a `<button>`, it does not. Disabling JavaScript in DevTools and clicking submit is the same check from the user's side.

  • Does putting the form inside a Client Component break the no-JavaScript submission?
    No. Client Components are still rendered to HTML on the server, so a form whose `action` prop is a Server Action ships as a real POST form either way. What breaks the property is changing how the request starts — moving to `onSubmit` or `onClick` — not which side of the `'use client'` boundary the component sits on.
  • If the user submits before hydration finishes, what do they see afterwards?
    A full-page navigation: the browser posts, Next runs the action and returns freshly rendered HTML for the page, and the document reloads. Scroll position and any transient client state are lost, and there is no pending spinner because no client code ran. The mutation itself completes exactly as it would after hydration.
  • Why is `event.preventDefault()` in a submit handler relevant to this discussion at all?
    It is the line that deliberately cancels the browser's native submission so JavaScript can take over. That is fine when JavaScript is guaranteed to be running, but it signals the design: the flow now has exactly one path, the scripted one. With the `action` prop, React takes over the same way after hydration while leaving the native path intact beforehand.

saying these in an interview costs you the question

  • Says both work because Server Actions are server code
  • Thinks 'use server' alone guarantees a no-JS fallback
  • Believes Client Components are never server-rendered to HTML
  • Claims progressive enhancement means an identical experience without JS
  • Checks the DevTools element inspector instead of view-source

context

open as a page

A Next.js App Router form posting to a Server Action must send a record id the user never types. Compare passing it as action={deleteNote.bind(null, id)} with rendering a hidden input named id: what does each do to the submitted payload and to the form's ability to submit without JavaScript?

level: middleimportance: should knowfreq 38%

basics

~20 s

Both keep the form submittable without JavaScript. A bound argument arrives as the action's first parameter, ahead of the FormData, and travels as an opaque encoded value; a hidden input arrives inside FormData as plain text that is readable and editable in the page source. Neither is a security control.

open as a page

A Next.js App Router page renders <form action={saveNote}> where saveNote is a Server Action, and a browser submits it without ever running the page's JavaScript. Trace that request: where does the POST go, how does Next decide which action to run, and what comes back?

level: middleimportance: should knowfreq 45%

basics

~20 s

The browser POSTs to the page's own URL. The submitted body carries hidden fields identifying the Server Action, which Next matches to the action module and runs on the server. Next then re-renders the route and returns a full HTML document, or a redirect if the action called redirect().

open as a page

In a Next.js App Router product list, the Delete control is a Client Component button that calls a Server Action from an onClick handler. QA reports that on a cold load over a slow connection, clicking Delete appears to do nothing for a second or two. What is happening, and how would you restructure the control?

level: seniorimportance: should knowfreq 42%

basics

~20 s

The click depends entirely on hydration: until React hydrates that component, no handler is attached, so the request cannot start. Restructure the control as a form whose action is the Server Action with the id bound, so the browser owns the submission from the moment the HTML paints.

open as a page

Your team is debating whether to adopt a rule that every mutation in a Next.js App Router app must work without JavaScript. How do you make that call, what does the rule actually cost, and how would you keep it true over time?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Decide per surface, not globally. The rule's real payoff is the gap before hydration and cases where the bundle fails, not users who disable JavaScript. Mandate it for the critical funnel, allow reasoned exceptions for interactions with no native equivalent, and verify it in CI or it silently rots.

open as a page