Next.js App Router

Server Actions, Forms and Mutations

Forms that call server functions, validation, pending and error states, revalidating after a change, and the security rules for actions.

Lesson 0315 min read#nextjs#server-actions#forms#security

On this page

  1. Words used in this lesson
  2. Your first Server Action
  3. What really happens: HTML, then fetch
  4. Showing errors and a pending state
  5. Refreshing the screen after a mutation
  6. Actions are public endpoints
  7. Calling actions one at a time
  8. Deploying actions: a note
  9. Server Action or Route Handler?
  10. Common mistakes
  11. Try it
  12. Interview questions
  13. Key takeaways
  14. What's next

In one line: A Server Action is an async function marked 'use server' that you hand to a form's action. The browser sends the form data to the server, your function runs there, and Next.js sends back the new screen in the same round trip. It works even with JavaScript off. But an action is a public HTTP endpoint in disguise: anyone can POST to it with curl, so every action must check who is calling and what they are allowed to touch, and must validate its input.

In the old way you wrote an API route, then wrote a fetch call with JSON.stringify, then handled loading and error state, then refreshed the list by hand. Server Actions replace most of that with one function and one attribute. They are small to write, which is why they are easy to get wrong. This lesson shows the happy path first, then attacks it.

This is lesson 3 of ten. Lesson 1 explained the server and client boundary, and lesson 2 explained caches and revalidation. Actions are where those two meet: you change data on the server, then you refresh the right cache. The next lesson, Route Handlers, Middleware (Proxy) and APIs, covers the other way to accept requests, for cases where an action is not the right tool. For the React side of forms (controlled inputs, useActionState), see Forms, Events and Controlled Inputs.

What I ran / checked. Everything ran on Linux with Node.js v22.22.0, Next.js 16.3.5 (this repo's version, default caching model), Turbopack, React 19.3.0, TypeScript 7.0.2 and zod 4.6.6. I wrote a tiny local todo "backend" in plain Node (port 4100), built a throwaway app against it, ran next build and next start, and tested with Playwright 1.56.1 and Chromium (JavaScript on and off) and with curl. Sources I read (in my own words below): inside node_modules/next/dist/docs/01-app/: "Mutating data", "Server Actions and mutations", "Forms", the use server directive page, the pages for revalidateTag, updateTag, and the data security guide headings. Not verified: I did not open nextjs.org or react.dev. I did not test useOptimistic, file uploads, authInterrupts, or multi-server setups with a shared encryption key (I report what the docs say). The login in my security tests is a toy cookie, only to have "a user"; real sessions are in lesson 5. Timings are from one machine and will vary.

Words used in this lesson

  • Server Function is an async function that runs on the server and is called from the client over the network.
  • Server Action is a Server Function used for a mutation, usually from a form or button. By convention it runs inside a React transition.
  • 'use server' is the directive that marks a function (or all exports of a file) as a Server Function.
  • FormData is the browser's object that holds the fields of a submitted form. A form action receives it.
  • Progressive enhancement means the feature works with plain HTML first (no JavaScript), and gets better when JavaScript loads.
  • Mutation is a change to data: create, update, delete.
  • useActionState is a React hook. It gives you the last result of an action, a function to call it, and a pending flag.
  • Pending state is the short time between sending the form and getting the answer.
  • Validation means checking that input has the right shape and size before using it.
  • Authentication is "who are you?". Authorization is "what are you allowed to do?".
  • IDOR (insecure direct object reference) is when a user changes an id in a request and touches somebody else's row, because the server did not check ownership.
  • CSRF is an attack where another site makes your browser send a request to a site you are logged in to.
  • Read-your-own-writes means that after you change something, you see your change at once.

Your first Server Action

Here is the smallest useful action: add a todo from a form. Two files. First, the action:

JavaScript
// app/basic/actions.ts'use server'import { updateTag } from 'next/cache'import { createTodo } from '@/lib/todos'export async function addTodo(formData: FormData) {  const title = String(formData.get('title') ?? '')  console.log('[action] addTodo running on the server, title =', JSON.stringify(title))  await createTodo(title, 'asha')   // talk to the database / API  updateTag('todos')                // refresh the cached list (lesson 2)}

Then the page, a plain Server Component, no 'use client' anywhere:

JavaScript
// app/basic/page.tsximport { listTodos } from '@/lib/todos'import { addTodo } from './actions'export default async function Page() {  const todos = await listTodos()  return (    <main>      <h1>Todos</h1>      <ul id="list">{todos.map(t => <li key={t.id}>{t.title}</li>)}</ul>      <form action={addTodo}>        <input name="title" />        <button type="submit">Add</button>      </form>    </main>  )}

The key line is <form action={addTodo}>. React lets the action prop of a form be a function. When the form is submitted, the function gets a FormData object with all fields, found by their name. You read them with formData.get('title'). (Note that get returns string | File | null, so I wrap it in String(...). We will validate properly soon.)

The 'use server' line at the top of the file marks every exported function as a Server Function. You can also put 'use server' as the first line inside one function body, which lets you define the action inline in a Server Component. But a Client Component cannot define one. It must import it from a file that has the directive.

Action or Server Function? The docs use both words. A Server Function is any async function that runs on the server and is called over the network. A Server Action is one used for a mutation (a form action, a button formAction, or inside a transition). In daily talk people say "server action" for both.

What really happens: HTML, then fetch

How does a function on the server get called from a form? I looked at the HTML that next start sent:

HTML
<form action="" encType="multipart/form-data" method="POST">  <input type="hidden" name="$ACTION_ID_40d786f41e06edc2145f4eb993282a59cdd6247da7"/>  <input name="title"/>  <button type="submit">Add</button></form>

It is a normal HTML form. It does a POST, with multipart/form-data, and it carries a hidden field. The field's name has a long id, which names the action. The function itself is not in the HTML or in the browser's JavaScript. At build time the compiler replaced it with a reference.

So it should work without JavaScript. I tested with Playwright, once with JavaScript off and once on, submitting the form each time and counting requests:

TEXT
JS off: before=["Buy milk","Read lesson 3"]        after=["Buy milk","Read lesson 3","with JS off"]        document requests=["GET /basic","POST /basic"]  POSTs=[{"type":"document", "no Next-Action header"}]JS on : before=[..., "with JS off"]        after=[..., "with JS off","with JS on"]        document requests=["GET /basic"]  POSTs=[{"type":"fetch", "has Next-Action header"}]

And the server log showed both calls:

TEXT
[action] addTodo running on the server, title = "with JS off"[action] addTodo running on the server, title = "with JS on"
  • JS off: the browser did a classic form post. The page was reloaded as a new document (POST /basic), and the new todo was there. It worked.
  • JS on: React caught the submit. It sent a fetch POST with a Next-Action header, got back the new screen, and updated the page in place. No new document was loaded.

This is progressive enhancement, and you get it for free when the form is rendered by a Server Component. The docs say it too: forms that call Server Actions are submitted even if JavaScript has not loaded or is disabled. In a Client Component, submissions are queued until JavaScript loads, and that part of the page is prioritised for hydration.

One round trip

Look at the JS-on line again. The action ran, the todo was saved, the cache tag was updated, and the list on the screen changed, all from one request. The docs call this the single-roundtrip model: the response carries the action's return value and a freshly rendered payload for the current route, when the action did one of these things:

  • called updateTag or revalidatePath,
  • called refresh(),
  • set or deleted a cookie,
  • called redirect.

You do not write a second fetch to reload the list. That is the biggest saving compared with API routes.

Showing errors and a pending state

A form that does nothing while the server works, and says nothing when input is wrong, is a bad form. React has a hook for this: useActionState. It wraps your action and gives you three things: the state (what the action last returned), a new action to put on the form, and a pending flag.

First, the action. It validates with zod (any validation library works, or hand-written checks) and returns the errors instead of throwing them:

JavaScript
// app/form/actions.ts'use server'import { z } from 'zod'import { updateTag } from 'next/cache'import { createTodo } from '@/lib/todos'const Schema = z.object({  title: z.string().trim().min(3, 'Use at least 3 characters').max(50, 'Use at most 50 characters'),})export type FormState = {  ok: boolean  message?: string  errors?: { title?: string[] }  values?: { title: string }}export async function addTodoValidated(_prev: FormState, formData: FormData): Promise<FormState> {  const raw = { title: String(formData.get('title') ?? '') }  const parsed = Schema.safeParse(raw)  if (!parsed.success) {    return { ok: false, errors: parsed.error.flatten().fieldErrors, values: raw }  }  await createTodo(parsed.data.title, 'asha')  updateTag('todos')  return { ok: true, message: `Saved "${parsed.data.title}"` }}

Notice the new first parameter, _prev. When you use useActionState, React passes the previous state as the first argument, and the FormData second. Return the new state.

Then the form, which must be a Client Component because it uses a hook:

JavaScript
// app/form/todo-form.tsx'use client'import { useActionState } from 'react'import { addTodoValidated, type FormState } from './actions'const initial: FormState = { ok: false }export default function TodoForm() {  const [state, action, pending] = useActionState(addTodoValidated, initial)  return (    <form action={action}>      <input name="title" defaultValue={state.values?.title} aria-invalid={!!state.errors?.title} />      <button type="submit" disabled={pending}>{pending ? 'Saving...' : 'Add'}</button>      {state.errors?.title && <p id="err" role="alert">{state.errors.title[0]}</p>}      {state.ok && <p id="ok">{state.message}</p>}    </form>  )}

The page (a Server Component) imports TodoForm and renders it, next to the list. I tested it in Chromium. First an invalid value ("ab"), then a valid one, and I checked the button 100 ms after clicking:

TEXT
--- JS oninvalid submit -> error: Use at least 3 characters | input kept: abduring save (100ms in): button text = Saving... | disabled = trueafter save: ok msg = Saved "Water plants" | list = [..., "Water plants"]--- JS offinvalid submit -> error: Use at least 3 characters | input kept: abafter save: ok msg = Saved "Call mum" | list = [..., "Call mum"]

Three things to see:

  1. The error message came back from the server and showed under the field. The text the user typed stayed in the box, because I returned values and used it as defaultValue. (React resets uncontrolled form fields after an action, so without this the user would have to type again.)
  2. While the server worked (my fake database waits 400 ms), the button said "Saving..." and was disabled. That is pending.
  3. It even worked with JavaScript off. The error and the success message came back in a full page response. useActionState supports this when the action is a Server Function.

Expected errors are return values

I want to stress a design rule. For expected problems (bad input, "email already used"), return a value from the action and show it. For unexpected problems (the database is down), let it throw. A thrown error goes to the nearest error.tsx from lesson 0, and in production the user sees only a generic message with a digest. In my security tests below, a thrown Error('Forbidden') came back to the caller as plain HTTP 500. That is right for a bug or an attack, but a poor way to say "title too short".

Other ways to call an action

  • <button formAction={action}> lets one form have several buttons with different actions.
  • In a Client Component you can call an action from an event handler like onClick, inside startTransition. The docs give a "like" button as the example.
  • To pass extra arguments that are not form fields, use bind: const updateUserWithId = updateUser.bind(null, userId) and pass updateUserWithId to the form. The action receives userId first, then the FormData. The docs show this in the forms guide. Bound values are not a safe place for secrets: see the closure section below.
  • useFormStatus (from react-dom) reads the pending state of the parent form from a child component, such as a reusable submit button.
  • useOptimistic can show the new item before the server answers. I did not test it here; the React lesson on forms covers the idea.

Refreshing the screen after a mutation

An action changes data. The page you are looking at may show cached data (lesson 2). If the action does not tell Next.js, you keep seeing the old list. I tested every option on one page. The page reads the list with fetch(..., { next: { tags: ['todos'] } }) and is static (○). Five forms, each one adds a todo and ends differently:

JavaScript
export async function addNone(fd: FormData) { await createTodo('none ' + fd.get('n'), 'asha') }export async function addRevalidateTag(fd: FormData) { await createTodo(/*...*/); revalidateTag('todos', 'max') }export async function addUpdateTag(fd: FormData) { await createTodo(/*...*/); updateTag('todos') }export async function addRevalidatePath(fd: FormData) { await createTodo(/*...*/); revalidatePath('/compare') }export async function addRefresh(fd: FormData) { await createTodo(/*...*/); refresh() }

For each one, a fresh browser opened the page, clicked the button with a unique title, waited, and checked whether that exact todo was on the screen. Then it reloaded the page and checked again, then again 1.5 seconds later. The real result:

TEXT
                   on screen after action | after reload | reload 1.5 s lateraddNone            false                    false           falseaddRevalidateTag   false                    false           trueaddUpdateTag       true                     true            trueaddRevalidatePath  true                     true            trueaddRefresh         true                     false           false

How to read each row:

  • None: nothing told Next.js the data changed. The page stays stale. This is the classic "I saved but the list did not change" bug.
  • revalidateTag(tag, 'max'): stale-while-revalidate. The user who made the change does not see it right away. The first reload shows the old page again but starts a refresh, and the next reload (after the refresh was done) shows the new item. The docs say that this call intentionally skips the immediate re-render in the action response.
  • updateTag(tag): expires the tag at once. The action's own response carried a fresh page, so the todo was on screen immediately, and a reload also showed it. This is read-your-own-writes, and the right default for "I just pressed save".
  • revalidatePath(path): also immediate in my test. Use it when one page is affected and tagging seems like too much.
  • refresh(): re-renders the current route inside the action response, so the user sees the new item. But it does not touch the cache. A reload showed the old list. The docs describe it as refreshing the route without invalidating cached data. Use it only when the screen depends on something outside the cache, such as a cookie.

So my default is updateTag for the user's own changes, and revalidateTag(tag, 'max') when I tell other pages (or other users) about a change from a webhook or background job. updateTag works only inside Server Actions. revalidateTag works in Server Actions and Route Handlers.

redirect, and the try/catch trap

After creating something, you often move the user to a new page. Use redirect from next/navigation:

JavaScript
import { redirect } from 'next/navigation'export async function createPost(formData: FormData) {  const post = await db.post.create(/* ... */)  updateTag('posts')          // revalidate BEFORE redirect  redirect(`/posts/${post.id}`)}

redirect works by throwing a special control-flow error. So the code after it never runs, and you must call your revalidation functions before it. I proved it with a log line placed after the redirect:

TEXT
[action] goGood: before redirect(no "AFTER redirect" line was printed)

The same trick causes a nasty trap. If you call redirect inside a try { ... } catch { ... }, your catch catches the redirect:

JavaScript
export async function goTrapped() {  try {    redirect('/done')  } catch (e) {    console.log('caught ->', (e as Error).message)    return { error: 'Something went wrong' }  }}
TEXT
good    -> url after submit: /donetrapped -> url after submit: /redir[action] goTrapped: caught -> NEXT_REDIRECT

The "good" action went to /done. The "trapped" one stayed on the page, because my catch swallowed NEXT_REDIRECT. The fix: call redirect outside the try, or in the catch re-throw errors that you did not expect (Next.js provides unstable_rethrow for this). Also remember notFound() works the same way.

Actions are public endpoints

Now the part that matters most. The docs say it directly: a Server Action runs as a POST request against the page that uses it. At build time the compiler swaps the function body in the browser's code for a reference, so the code stays private. But the endpoint is reachable by anyone who can send the same POST. Treat every action as an untrusted entry point.

I proved it. Remember the hidden field in the HTML form, $ACTION_ID_40d7...? I took it and sent the request with curl. No browser, no form, no page:

TEXT
$ curl -s -o /dev/null -w "status %{http_code}\n" -X POST localhost:3401/basic \      -F "$ACTION_ID_40d7...=" -F "title=posted by curl"status 200(the todo list now contains "posted by curl")

If a form is only shown to logged-in users, that is not a security check. The UI is a suggestion, and the endpoint is the truth.

What Next.js protects for you

The docs list built-in protections, and I tested the ones I could:

  1. Origin check (a CSRF defence). The request's Origin header is compared with the host. I repeated the curl call with -H "Origin: https://evil.example":

    TEXT
    HTTP/1.1 500 Internal Server ErrorInternal Server Error(server log) ... does not match `origin` header with value `evil.example` from a forwarded Server Actions request. Aborting the action.⨯ Error: Invalid Server Actions request.

    So another website cannot make a visitor's browser submit your action. But look at the previous test: curl with no Origin header went through (the server only logged a warning: "Missing origin header from a forwarded Server Actions request"). The origin check protects against other sites in browsers. It does not stop a script. For a proxy or CDN in front of your app, the docs say to set serverActions.allowedOrigins. (The lesson XSS, CSRF, Clickjacking and Security Headers explains CSRF in general.)

  2. Body size limit. Action requests are capped at 1 MB by default. I sent a 2 MB field:

    TEXT
    2 MB field:   HTTP 500   ⨯ Error: Body exceeded 1 MB limit.0.5 MB field: HTTP 200

    Change it with serverActions.bodySizeLimit in the config if you really accept bigger payloads (for example, uploads).

  3. Encrypted action ids and dead-code removal. The id in the form is an opaque hash, and unused actions are removed from the client bundle so they have no public endpoint. This stops guessing, but it does not hide an id from a page that renders the form.

  4. Closure encryption. An inline action can use variables from the surrounding component. Those values are sent to the browser so the action can find them later, but they are encrypted. I made an inline action that uses const note = 'dentist at 9, door code 4417'. The HTML contained only this:

    HTML
    <input type="hidden" name="$ACTION_1:2" value="&quot;nzJ+IAaMcc7YhU10CmbETabM7I...&quot;"/>

    and grep -c "dentist\|4417" on the page gave 0. The value is there but unreadable. With several servers behind a load balancer, the docs say to set the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY on all of them, or an instance cannot decrypt what another encrypted. Still, do not rely on this for real secrets. Read secrets on the server, inside the action.

What you must do yourself

The docs say framework protections are not a substitute for application checks. Every action needs three things.

1. Authenticate: who is calling? Read the session on the server, and stop if there is none.

2. Authorize: may this user do this to this row? Send the id from the client, but never trust that the id belongs to the caller. Look the row up and compare the owner.

3. Validate: is the input what I expect? Treat FormData as hostile.

I built two versions of a "delete a todo" action, and attacked both. Ben is logged in (a toy cookie user=ben). Ben's page does not show him a delete button for Asha's todo (id 1). But Ben can still send the POST himself.

The unsafe action:

JavaScript
// UNSAFE: no login check, no ownership checkexport async function deleteTodoUnsafe(formData: FormData) {  await removeTodo(Number(formData.get('id')))  updateTag('todos')}
TEXT
DB before: [{"id":1,"title":"Buy milk","owner":"asha"}, {"id":2,"title":"Read lesson 3","owner":"ben"}]POST /unsafe id=1 as [user=ben]: HTTP 200DB after:  [{"id":2,"title":"Read lesson 3","owner":"ben"}]

Asha's row is gone. Ben only needed the action id from his own page and a different id value. This is an IDOR bug. The button hiding in the UI was the only protection, and it was not one.

The safe action:

JavaScript
// app/safe/actions.ts'use server'import { updateTag } from 'next/cache'import { z } from 'zod'import { listTodos, removeTodo } from '@/lib/todos'import { currentUser } from '@/lib/session'const Id = z.coerce.number().int().positive()export async function deleteTodoSafe(formData: FormData) {  const user = await currentUser()  if (!user) throw new Error('Unauthorized')                       // 1. who is calling?  const id = Id.parse(formData.get('id'))                          // 2. is the input valid?  const todo = (await listTodos()).find(t => t.id === id)  if (!todo || todo.owner !== user) throw new Error('Forbidden')   // 3. may THIS user touch THIS row?  await removeTodo(id)  updateTag('todos')}

Same attacks, with a new row (id 3, owner Asha):

TEXT
POST /safe id=3 as [user=ben]:  HTTP 500     (server log: Error: Forbidden)POST /safe id=3 as [no cookie]: HTTP 500     (server log: Error: Unauthorized)DB: row 3 still thereAsha deletes her own row 3:     HTTP 200     DB: row 3 gone

Both attacks failed, and the real owner could still delete. Notice what the safe action does not trust: it does not take the owner, the user name or any "is admin" flag from the form. It takes the id from the client, and gets everything else from the session and the database. The docs show the same idea: send a reference (an id) plus the user's change, then re-read the rest from a trusted source. Schema validation only checks the shape of an input. A perfectly shaped id can still point at someone else's row.

Hiding a button is not authorization. If your page renders {isOwner && <DeleteButton />}, you improved the screen. You did not protect the data. The check must be inside the action (and in the data function it calls). Lesson Access Control: IDOR, Roles and Mass Assignment covers this class of bug in depth, and lesson 5 of this series builds real sessions.

Two more rules from the docs for return values: shape what you return. An action's return value is serialized to the browser (lesson 1's rules apply). Return what the UI renders, not a raw database record with hidden fields. And for destructive actions (deleting, paying) consider stronger checks, like asking the user to log in again.

Where should the checks live?

If you put an auth() call at the top of fifty actions, you will forget it in the fifty-first. The docs recommend a data access layer: a small set of server-only functions (import 'server-only', lesson 1) that every page and action must use to reach data, and each of them checks the session. Then forgetting the check in an action is harder, because the data function would refuse. Mine was small: currentUser() plus an ownership check. Keep the idea even if your code is bigger.

Calling actions one at a time

Does Next.js run actions in parallel if the client calls several at once? I wrote a client button that calls an action three times with Promise.all. The action waits 500 ms:

JavaScript
await Promise.all([slowEcho(1), slowEcho(2), slowEcho(3)])

Result, measured in the browser:

TEXT
3 actions with Promise.all took 1556 ms

Three times 500 ms. They ran one after another. The docs state it plainly: Next.js dispatches Server Actions one at a time per client, so the screen always matches the result of the action that produced it. The advice is: do not use actions for parallel reads. For reading data, use Server Components (lesson 2) or a Route Handler (next lesson). If you need parallel work for a mutation, do it inside one action with Promise.all.

A related practical point: a user who double-clicks "Pay" will queue two payments. Disable the button while pending, and make the server side safe against repeats too (an idempotency key, as in Idempotency Keys, Retries, Webhooks and Rate Limits).

Deploying actions: a note

Each action is identified by an id that is part of the build output. A new deploy can change the ids (the docs say Next.js rotates them at most every 14 days even if code does not change). A browser tab opened before your deploy may call an id that no longer exists, and the user sees "Failed to find Server Action". Prefer rolling deploys, keep the encryption key stable across instances, and show a "please refresh" path instead of a hard failure.

Server Action or Route Handler?

You will meet this question in interviews. A short guide:

Use a Server Action whenUse a Route Handler when
The caller is your own UI (forms, buttons)The caller is someone else's code (mobile app, webhook, another service)
You are changing dataYou need a public, documented URL and standard HTTP (GET, caching, status codes, headers)
You want progressive enhancement and the one-round-trip refreshYou need parallel requests, streaming a file, or a non-form format

Next lesson builds Route Handlers. For now: actions are for your own screens.

Common mistakes

1. No auth check inside the action. Rendering a form only for logged-in users does not protect anything. I deleted another user's row with one curl.

2. Trusting ids and flags from the form. ownerId, role, isAdmin, price must come from the server. Take the id and the user's change, and look up the rest.

3. No validation. formData.get() returns string | File | null. Parse with a schema, and return field errors.

4. Forgetting to refresh the cache. With no revalidation the page stays stale (my "None" row). Use updateTag for read-your-own-writes.

5. Calling redirect inside try/catch. The catch swallows it. Redirect outside the try, or re-throw unknown errors.

6. Revalidating after redirect. Code after redirect never runs. Revalidate first.

7. Expecting parallel actions. They run one at a time. Use Server Components for parallel reads.

8. Returning a whole database row. The return value goes to the browser. Return a small object.

9. Defining an action inline in a Client Component. It is not possible. Put it in a file with 'use server' and import it.

10. Trusting the Origin check. It stops other websites in a browser, not curl.

Try it

Use the todo API idea from this lesson (or any list you can mutate). Answers follow.

  1. Make a form with action={addTodo} where addTodo is a 'use server' function that logs the title. Submit it. Where does the log appear? Then turn JavaScript off in your browser's DevTools and submit again. What changes in the Network tab?
  2. View the page source. Find the hidden input. What is its name?
  3. Use curl -X POST with -F for the hidden field and a title. Does the action run? What does that tell you about login checks in the UI?
  4. Add zod validation (min(3)). Use useActionState to show the error under the input and keep the typed value. Show "Saving..." while pending.
  5. Make the list page cached with a tag. Add an action that creates a todo and calls nothing. Is the list updated? Now add updateTag. Then try revalidateTag('todos', 'max') instead. Compare what the user sees.
  6. Write a delete action without an ownership check. Attack it with curl as another user. Then fix it.
  7. Put redirect() inside a try/catch and watch what happens.
  8. Call an action three times from a client button with Promise.all. How long does it take compared to one call?

Answers

  1. In the terminal of the Next.js server (the server), not the browser console. With JS off, the browser does a normal form post: the network tab shows a POST document request and a page reload. With JS on, it is a fetch with a Next-Action header and no reload.
  2. A hidden <input> named $ACTION_ID_ followed by a long hex string.
  3. Yes, it runs (status 200). The UI hiding a form is not a check. The action itself must authenticate and authorize.
  4. Return { errors, values } from the action, use defaultValue={state.values?.title}, and render state.errors?.title. Use the third value of useActionState as pending.
  5. With nothing, the list stays stale. With updateTag, the new item shows at once. With revalidateTag(..., 'max'), the user does not see the item right away, only after a later request.
  6. The unsafe action deletes the other user's row. The fix: read the session, load the row, compare the owner, and throw or return an error if it does not match.
  7. The catch catches NEXT_REDIRECT and the user stays on the page. Move redirect out of the try.
  8. About three times as long (about 1.5 s for three 500 ms calls), because they are dispatched one at a time.

Interview questions

What is a Server Action and how do you create one? (Must know)

A Server Action is an async function that runs on the server and is called from the client, usually from a form's action prop. I mark it with the 'use server' directive, either at the top of a file (all exports become actions) or as the first line of an inline function in a Server Component. A Client Component cannot define one, only import one. When used in a form, it receives the FormData. Behind the scenes it is a POST request.

Does a form with a Server Action work without JavaScript? (Must know)

Yes, when the form is rendered by a Server Component (and the same holds for useActionState forms). The HTML has method="POST" and a hidden field with the action id, so the browser does a normal post and gets back the new page. I tested it with JavaScript turned off in Chromium: the todo was added through a document POST, and validation errors even came back. With JavaScript on, React sends a fetch with a Next-Action header and updates in place. That is progressive enhancement.

Why must you authenticate and authorize inside a Server Action even if the form is only shown to logged-in users? (Must know)

Because an action is a public POST endpoint. Anyone with the action id, which is in the HTML of any page that renders the form, can call it with curl. In my test, a user without a delete button for another user's todo deleted it with one request. The UI is a convenience, the action is the real entry point. Inside every action I read the session, validate input, and check that this user may touch this row.

What built-in protections do Server Actions have, and what do they not cover? (Senior signal)

Next.js compares the Origin header with the host and rejects mismatches, so another website cannot make a browser submit the action (I saw "Invalid Server Actions request"). It caps the body at 1 MB by default (I got "Body exceeded 1 MB limit" with a 2 MB field). It encrypts action ids and removes unused actions from the client bundle, and it encrypts variables captured by inline actions. It does not do authentication, authorization or validation. And the origin check does not stop curl: a request without an Origin header went through with only a warning.

What is IDOR and how does it show up in actions? (Must know)

Insecure direct object reference: the client sends an id and the server acts on that row without checking that the caller owns it. In actions it looks like deleteTodo(formData.get('id')) with no ownership check. The fix is to take the id from the client, but load the row with the session's user in the query or compare row.owner with the user, and fail if they differ. Validation of shape (zod) does not fix it, because a valid id can still be someone else's.

How do you validate form input and show errors? (Must know)

I parse FormData with a schema (zod, for example), using safeParse. If it fails, the action returns the field errors and the entered values, instead of throwing. In a Client Component I use useActionState(action, initialState), which gives me the state to render, the action to put on the form, and a pending flag. The action's first parameter is then the previous state, and FormData comes second. I return the entered values so the inputs keep what the user typed, since React clears uncontrolled fields after an action.

What is the difference between updateTag, revalidateTag and revalidatePath in an action? (Senior signal)

updateTag(tag) expires the data at once and works only in Server Actions. The action's response contains a fresh page, so the user sees their change immediately. revalidateTag(tag, 'max') is stale-while-revalidate: the old data is served while a refresh runs, and the action's response does not include a re-render. In my test the person who clicked did not see the new item until a later request. revalidatePath invalidates a route and gave an immediate result in my test. refresh() re-renders the current route but does not touch the cache: the screen updated, but a reload showed old data.

What happens if you call redirect() inside try/catch in an action? (Trap)

redirect works by throwing a special error. A catch block catches it, so the redirect does not happen. I tested it: the version without try/catch went to /done, and the one with try/catch stayed on the page and logged NEXT_REDIRECT. Also, since it throws, code after it never runs, so any revalidation must come before it. I call redirect outside the try, or re-throw unexpected errors (with unstable_rethrow).

Can you call Server Actions in parallel from the client? (Trap)

No. Next.js dispatches them one at a time per client. In my test, three 500 ms actions called with Promise.all took about 1.5 seconds. For parallel reads I use Server Components or a Route Handler, and for parallel work inside a mutation I use Promise.all inside one action.

Server Action or Route Handler? How do you choose? (Senior signal)

I use a Server Action when the caller is my own UI and the job is a mutation: I get progressive enhancement, the one-round-trip refresh, and less code. I use a Route Handler when other clients need a stable public URL: a mobile app, a webhook, a third-party service, or when I need standard HTTP features like GET with caching, custom status codes, headers, streaming, or parallel requests. Both need authentication and validation.

How do you pass an extra value to an action that is not a form field? (Must know)

With bind: const updateUserWithId = updateUser.bind(null, userId), then <form action={updateUserWithId}>. The action receives userId first and the FormData after. I treat that value as client-controlled input, because a determined caller can post their own, so I still check authorization on the server. For inline actions that capture variables, Next.js encrypts them, which I confirmed in the HTML, but I never put a real secret there.

A user says "I saved, but I still see the old data". How would you debug it? (Senior signal)

First I check whether the action refreshes anything. If it returns without updateTag, revalidatePath, refresh or a cookie change, the cached page stays stale. Then I check which function it calls: revalidateTag(..., 'max') is stale-while-revalidate, so the person who saved sees old data until a later request. For their own change I use updateTag. Third, I check the tag really matches the one on the data (a typo is silent). Fourth, I check whether the action ran at all (a server log line, and the response status). And I remember redirect code order and the try/catch trap.

Key takeaways

  • A Server Action is an async function with 'use server'. Put it on a form's action, read the FormData, and the work happens on the server.
  • Forms with Server Actions work without JavaScript. With JavaScript, React uses a fetch and updates the page in the same round trip.
  • Use useActionState for field errors, kept values and a pending flag. Return expected errors, throw unexpected ones.
  • After a mutation, tell Next.js what changed. updateTag for read-your-own-writes, revalidateTag(tag, 'max') for background refresh, revalidatePath for a route, refresh() for the current screen only.
  • redirect throws. Revalidate before it, and never wrap it in a try/catch that swallows it.
  • An action is a public POST endpoint. In every action: authenticate, authorize against the specific row, validate input. I deleted another user's data with curl until I did.
  • Next.js checks Origin, limits the body to 1 MB, hides action ids and encrypts captured variables. None of that replaces your own checks, and curl skips the origin check.
  • Actions run one at a time per client. Use Server Components for parallel reads. Use Route Handlers for APIs that other clients call.

What's next

Next: Route Handlers, Middleware (Proxy) and APIs. Not everything is a form. Mobile apps, webhooks and other services need plain URLs that return JSON. You will write route.ts files with GET and POST, read headers, cookies and query strings, and see how route handlers are cached in this version. Then you will meet proxy.ts, the new name for middleware: code that runs before a request is handled, to redirect, rewrite or add headers. Practise now: take the todo form from this lesson, add a second delete action, and attack it with curl as a different user.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series