skip to content

Invoking from Forms and Handlers

Actions can be wired straight to a form's action prop or called from an event handler, and each route has its own way of surfacing pending state and errors. Interviewers ask how you'd show a spinner and render a validation message.

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

explore

questions

4

In a Next.js App Router app a hydrated page renders <form action={saveNote}> where saveNote is a Server Action. The user submits, the URL never changes, no fetch or setState appears anywhere in the code, and yet the page shows updated content. Walk through what actually happens between the click and the update.

level: middleimportance: must knowfreq 60%

answer

  1. no fetch, no endpoint, no JSON
  2. named controls become FormData
  3. POST lands on the current URL
  4. response is an RSC payload
  5. router patches the mounted tree

basics

~20 s

React intercepts the submit, packs the named form controls into FormData, and POSTs it to the current URL, naming the action to run. Next executes the function on the server and returns an RSC payload that the router merges into the live page.

solid answer

~50 s

Because the page is hydrated, React intercepts the native submit instead of letting the browser navigate. It builds a `FormData` object from the form's named controls and issues a POST to the URL the page is already on, with the request identifying which Server Action to run. Next executes `saveNote` on the server — real server process, real environment variables, direct database access — and the function's code never enters the client bundle. The response is not JSON and not HTML: it is an RSC payload, the same wire format server-rendered trees are streamed in. The router applies that payload to the tree already mounted, so the URL stays put, there is no navigation and no scroll reset, and client component state that stays mounted survives. The action's return value rides back in the same response, which is how a result or a validation message reaches the UI.

go deeper

for a junior

Know that you can hand a server function straight to a form's action prop, that the form's named fields arrive as FormData, and that you write no fetch call yourself.

for a middle

Be ready to narrate the round trip: submit intercepted, FormData POSTed to the current URL, function executed on the server, RSC payload returned and merged into the mounted tree with no navigation.

for a senior

Show that you can debug it — read the POST in the network tab, explain why the response is not JSON, and separate 'the mutation ran' from 'the screen shows new data', which depends on invalidation rather than on the call succeeding.

for a principal

Own the boundary decision: which mutations belong on this in-page RPC path versus a versioned HTTP endpoint that other clients, jobs or partners can call, and what that choice costs in coupling, observability and testability.

## The call site A Server Action is an ordinary async function that only ever executes on the server, but that the browser is permitted to *call*. Next arranges for that function to be reachable from the client (how that endpoint comes into existence is a separate topic). What matters here is the consequence: at the call site you write no client-side transport code at all. There is no `fetch`, no URL string, no `Content-Type`, no `JSON.parse`, no error-status handling. You hand the function itself to the form. ```tsx // app/notes/page.tsx (a Server Component) export default function Page() { async function saveNote(formData: FormData) { 'use server' await db.note.create({ data: { body: String(formData.get('body')) } }) } return ( <form action={saveNote}> <textarea name="body" /> <button type="submit">Save</button> </form> ) } ``` ## Step by step **1. The submit is intercepted.** Once the page has hydrated, React handles the submit event rather than letting the browser perform its default form POST-and-navigate. You do not write `onSubmit` and you do not call `preventDefault()`. **2. FormData is built.** React serializes the form's *named* controls into a `FormData` instance. Only controls with a `name` are included, an unchecked checkbox contributes nothing at all, and every value is either a string or a `File` — there are no numbers and no booleans on the wire. **3. A POST goes to the current URL.** The request targets the route the user is already on, not some `/api/...` path, and it carries an identifier for the action to invoke plus the encoded arguments. Open the network tab during a submit and you see a POST to the page's own URL with an opaque body — that surprises people expecting a REST call. **4. The server runs the function.** It runs in the server runtime with whatever privileges that process has: secrets, database handles, internal services. The body of the function is never shipped to the browser, which is exactly why data access can sit inline in it. **5. The response is an RSC payload.** Not JSON, not a redirect, not a full HTML document. It is the React Server Components wire format, streamed back, and it can carry two things: the action's return value (which must be serializable across that boundary) and re-rendered server UI for the current route. **6. The router applies it in place.** React reconciles the incoming tree against the mounted one. The URL is unchanged, there is no navigation and no scroll reset, and any client component that is not unmounted keeps its state — a scroll position in a sidebar, an open dropdown, a `useState` value elsewhere on the page. ## Why a payload rather than JSON In the App Router the unit of update is a *server-rendered tree*, not a record. A REST endpoint hands you a row and leaves you to work out which parts of the UI now need to change; a Server Action response can carry the recomputed UI directly, which is why a mutation can update the screen without any client state management at all. One caveat worth stating plainly: the mutation running successfully does not by itself guarantee the *new* data appears. Whether the freshly rendered tree reflects your write depends on the relevant caches being invalidated — a separate concern with its own API, and the usual explanation for "it saved but the list still shows the old row". ## What still needs wiring The transport is free; the UX is not. The action's return value is available to you, but you have to route it into state to render a message; a pending indicator needs a hook to read the in-flight status; a reset or a focus move after success is yours to write. Those hooks are React's form and action primitives — the framework part being described here is only how the call travels and what comes back. ## Where teams get this wrong - Writing an `onSubmit` handler *and* an `action`, then wondering why the flow runs twice or not at all. - Returning something non-serializable from the action — a class instance, a function, a database cursor — and getting a serialization error rather than a value. - Assuming the code in the action is somehow "shared" and worrying about it leaking; it is not in the client bundle. - Assuming a full page reload happened, and re-fetching or re-initializing client state defensively after every submit.

  • What does the server actually receive for an unchecked checkbox, or for an input with no name attribute?
    Nothing in either case. `FormData` only carries controls that have a `name`, and an unchecked checkbox contributes no entry at all — so `formData.get('subscribe')` is `null` rather than `false`. Everything present is a string or a `File`, never a number or boolean, so numeric fields need explicit parsing and coercion on the server before use.
  • The same page has a Delete button that calls the action from an onClick handler instead of a form. What changes about the invocation?
    No `FormData` is built for you: you call the action like a normal async function and pass your own serializable arguments. The round trip and the RSC response are the same, but nothing is collected from the DOM, no form is reset, and you own the pending and error handling entirely rather than getting it from form plumbing.
  • Why does the response come back as an RSC payload instead of plain JSON with the return value?
    Because the response can carry more than the return value — it can carry re-rendered server UI for the current route, expressed in the same wire format the initial render used. That lets the router patch the existing tree directly. A JSON body would force you to translate a record back into UI changes by hand, which is exactly the work the action model removes.

saying these in an interview costs you the question

  • Thinks the browser performs a normal navigation and full reload
  • Says the action function runs in the browser
  • Expects a JSON REST response from a dedicated API path
  • Believes the action's body is shipped in the client bundle
  • Assumes all client component state is lost after a submit

context

open as a page

A Next.js Server Action deletePost needs the id of the post to delete, but that id is not one of the form's inputs. When the action is wired to <form action={...}>, what are your options for getting the id to it, and what is the tradeoff of each?

level: juniorimportance: should knowfreq 50%

basics

~20 s

Bind the id to the action with deletePost.bind(null, id) so it arrives as the first argument and the FormData second, or render it as a hidden input and read it from FormData. Either way, re-check the id on the server.

open as a page

A Next.js App Router team implements editor autosave by calling a Server Action from an onChange handler, once per keystroke. The app grows laggier the longer the user types, and saves occasionally land out of order. What is wrong with invoking a Server Action this way, and what would you do instead?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Every Server Action call is an uncacheable server round trip whose response patches the page tree, so per-keystroke calls cost a request and a re-render each. Debounce or batch instead, and version writes so a late response cannot win.

open as a page

In a production Next.js build, a Server Action invoked from a form throws an unexpected exception — a database call failed. What does the browser actually receive, where do you find the real error, and what does that imply about how the action should report an expected validation failure?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Production builds do not send the message or stack to the browser: the client sees a generic error carrying a digest that matches a server-side log entry. Expected failures should be returned as values from the action, not thrown.

open as a page