skip to content

Server Actions

Server Actions let a form call server code without you hand-writing an endpoint, which feels elegant until you remember an endpoint is exactly what got generated. Interviewers ask how they're invoked, how you revalidate after a write, and how you keep them safe.

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

explore

questions

23

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

Inside a Next.js Server Action declared as async function createUser(formData: FormData), what does formData.get('email') actually return, and why can that value not be passed straight into your database call?

level: juniorimportance: must knowfreq 62%

basics

~20 s

formData.get('email') returns FormDataEntryValue | null — a string, a File, or null if the field was absent. The FormData type annotation is erased at runtime and the body is whatever the caller posted, so the action must parse and validate before using the value.

open as a page

In the Next.js App Router, what does the 'use server' directive actually do to a function, and why is it wrong to think of it as the mirror image of 'use client'?

level: juniorimportance: must knowfreq 70%

basics

~20 s

'use server' marks a function as a server function that client code may call over the network — Next turns it into an RPC entry point. It does not make anything a Server Component; App Router components already run on the server by default.

open as a page

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%

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.

open as a page

In a Next.js App Router app, a Server Action can finish a write by calling either revalidatePath or revalidateTag from 'next/cache'. What does each one key on, how does data get associated with a tag, and how do you decide which to call?

level: middleimportance: must knowfreq 60%

basics

~20 s

revalidatePath keys on a route path and invalidates what that route cached; revalidateTag keys on a label you attached to the reads themselves, for example fetch(url, { next: { tags: ['posts'] } }). Use paths when you know the affected route, tags when the same data appears on many routes.

open as a page

A Next.js App Router Server Action updates a row in the database and returns without throwing, but when the user navigates back to /posts they still see the old list. Explain why the write did not change what they see, and what the action must do so the list is fresh.

level: middleimportance: must knowfreq 72%

basics

~20 s

The write succeeded but nothing told Next.js its caches were wrong, so the navigation is answered from the client Router Cache and the server Data Cache. Call revalidatePath('/posts') or revalidateTag(...) from 'next/cache' inside the action, after the write.

open as a page

In a Next.js App Router app, a deleteInvoice Server Action is only reachable from a delete button that the page renders for admins. Explain why rendering the button conditionally is not an authorization control, and where the check has to live instead.

level: middleimportance: must knowfreq 74%

basics

~20 s

A Server Action compiles into a public POST endpoint that anyone can call directly with its generated id, so hiding the button hides nothing. Authentication and authorization must run inside the action body before it reads or writes anything.

open as a page

In a Next.js App Router project, what is the difference between putting 'use server' on the first line of a module and putting it as the first statement inside a single async function?

level: middleimportance: must knowfreq 62%

basics

~20 s

Module-level 'use server' turns every export of that file into a separately callable server endpoint; the inline form marks only the one async function it sits in. Inline actions can also close over values from the surrounding render scope, which module-level ones cannot.

open as a page

A Next.js Server Action is invoked as updateProfile.bind(null, userId) and updates the row whose id equals that first argument. The action does verify that a session exists. What is still wrong, and how would you fix it?

level: seniorimportance: must knowfreq 52%

basics

~20 s

The row being updated is chosen by an argument that travelled through the client, so any logged-in user can substitute another user's id. Checking that a session exists is authentication, not authorization: derive the subject from the session, or scope the write by the session's id.

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 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

A Next.js App Router Server Action wraps its whole body in try/catch and calls redirect('/posts') at the end of the try block. The browser stays on the form and the catch block logs an unexpected error. What is happening, and how should the action be structured?

level: middleimportance: should knowfreq 48%

basics

~20 s

redirect() from 'next/navigation' works by throwing a control-flow error that Next catches to issue the navigation. A surrounding try/catch swallows it, so the redirect never happens and the error lands in your catch. Call redirect after the try/catch.

open as a page

A Next.js file whose first line is 'use server' exports an async createUser function, a MAX_TITLE_LENGTH constant and a small synchronous formatName helper. The build fails. Why, and what is the correct fix?

level: middleimportance: should knowfreq 40%

basics

~20 s

A module marked 'use server' may export only async functions, because Next turns every export into a network-callable endpoint and a constant or synchronous function cannot be one. Move the constant and the helper into a separate module and import them.

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

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

A team's Next.js App Router codebase ends every Server Action with revalidatePath('/', 'layout'). Reads to the upstream API have grown far faster than traffic. Explain what that call invalidates, why it produces this symptom, and how you would narrow it without reintroducing stale data.

level: seniorimportance: should knowfreq 44%

basics

~20 s

Passing the root path with 'layout' invalidates that layout and every route nested under it — effectively the whole app — so one unrelated write discards every cached read and the next navigations all refetch. Narrow it with per-entity tags or specific paths.

open as a page

Next.js rejects a Server Action request whose Origin header does not match the app's host. What does that built-in check actually buy you, what does it not cover, and when do you have to configure allowed origins for it?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Next.js only dispatches Server Actions over POST and compares the request's Origin against the host, rejecting mismatches — that stops another site invoking your action with the user's cookies. It does not authenticate, authorize, or validate anything, and proxies that rewrite the host need allowed origins configured.

open as a page

In a Next.js Server Component you define an inline 'use server' action that closes over a value computed during render, and pass it to a form. Where does that captured value live between render and the moment the user submits, and what does that imply about what you may close over?

level: seniorimportance: should knowfreq 33%

basics

~20 s

The captured value does not stay on the server. Next serializes it into the payload sent to the browser and the browser sends it back when the action is invoked, so it must be serializable, and a secret closed over this way has effectively been handed to the client.

open as a page

Your Next.js codebase has around forty Server Actions and every security review finds one that forgot its authorization check. How would you make that check structural instead of a per-action convention?

level: principalimportance: should knowfreq 31%

basics

~20 s

Stop relying on each action to remember. Route every mutation through one data-access layer that resolves the session and enforces policy itself, keep the exported action surface thin, and make the absence of that layer detectable by lint, review, or tests rather than by audit.

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