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?
answer
- one is a real POST target
- the other needs a listener attached
- view source, not the inspector
- hydration is a prerequisite for handlers
- action prop survives, onSubmit does not
basics
~20 sOnly 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 sWhen 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// 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
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.
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.
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.
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