← blog
NextjsSeptember 8, 2026 · 15 min read

Next.js Server Actions: Mutations & Security (Cheat Sheet)

Next.js Server Actions look like plain functions but compile to public POST endpoints. Learn the mutation flow, built-in CSRF checks, and the auth you owe.

Parsa Jiravand · Frontend engineer · building bestpractic
Next.js Server Actions: Mutations & Security (Cheat Sheet)
  1. 1Next.js Cache Components Explained (with Cheat Sheet)14 min
  2. 2Next.js Server Actions: Mutations & Security (Cheat Sheet)you are here

You write a deleteComment(commentId) Server Action, wire it to a trash-can button, and it just works — no fetch, no /api route, no JSON.stringify. It feels like the network disappeared, like you called a local function from a click handler. Ship it, move on.

A week later someone opens their browser's dev tools, finds a POST request to your app with a long encrypted ID in the body, and replays it with a commentId that isn't theirs. It works. There was never a network boundary to disappear — you just couldn't see it. Every Server Action you write compiles into a real HTTP endpoint, and Next.js never assumed you'd add the authorization check yourself.

This article is written against Next.js 16.3 (verified against the framework's own release notes and npm's latest dist-tag in September 2026). Everything here — the 'use server' directive, useActionState, useOptimistic, and the built-in CSRF protections — is current App Router behavior, not a Pages Router pattern in disguise.

By the end of this article you'll be able to:

  • Explain what 'use server' actually compiles into, and why a Server Action is a same-origin RPC endpoint rather than a function call
  • Build a full mutation flow: a progressively-enhanced form, pending and result state with useActionState, and an instant UI with useOptimistic
  • Invalidate the right data after a mutation with revalidatePath and revalidateTag, and avoid the redirect() inside try/catch trap
  • Describe exactly what Next.js secures for you (CSRF origin checks, encrypted action IDs, encrypted closures) and what it deliberately leaves to you (authentication, authorization, input validation)
  • Decide when a Server Action is the right tool and when a Route Handler still is

You've built at least a small App Router project — a page.tsx, maybe a form that posts to an API route. You don't need prior experience with Server Actions; we build the model from nothing.

Here's the naïve version of that deleteComment action — the one that looks completely reasonable in a code review:

TSX
1
2
3
4
5
6
7
8
9
10
// app/actions.ts 'use server'; import { db } from '@/lib/db'; import { revalidatePath } from 'next/cache'; export async function deleteComment(commentId: string) { await db.comment.delete({ where: { id: commentId } }); revalidatePath('/posts'); }
TSX
1
2
// A button inside a Client Component <button onClick={() => deleteComment(comment.id)}>Delete</button>

Nothing here checks who is asking. The function trusts its caller the way a same-process function normally can — because on the page, it looks like one. But 'use server' doesn't keep this code on the server in some abstract sense; it publishes it as a callable endpoint the client can reach. Open the Network tab after clicking Delete and you'll see a POST to your app's own origin, carrying an encrypted reference to this exact function and its argument. Any client that can construct that same request — not just your button — can call it, with any commentId it likes.

The bug isn't that Server Actions are insecure. It's that the syntax hides the network call so well that it's easy to forget one exists, and skip the check you'd never skip in a hand-written API route.

The mental model: a Server Action is not code that "runs on the server instead of the client." It's an RPC (remote procedure call) — a function whose body runs on the server, but whose invocation is a real HTTP request from whatever calls it, same as fetch("/api/comments/123", { method: "DELETE" }) would be.

When you mark a function with 'use server', the Next.js compiler:

  • Leaves the function's body on the server, and strips it entirely out of the client JavaScript bundle.
  • Replaces every reference to it in client code with an encrypted, opaque ID.
  • Registers a server-side handler that, given that ID and a serialized argument list, finds the matching function and runs it.

Calling deleteComment(comment.id) from a click handler, under the hood, sends a POST request carrying that ID and the arguments, and awaits the response. The syntax reads like a function call because React and Next.js serialize the request and deserialize the response for you — but the trust boundary is exactly where it would be for a REST endpoint. Nothing about who is asking crosses that boundary automatically. That's the one fact this whole article hangs off.

There are two ways to mark a function as a Server Action, and they mean different things.

Inline, inside a Server Component, 'use server' goes at the top of the function body:

TSX
1
2
3
4
5
6
7
8
9
// app/posts/[id]/page.tsx — a Server Component export default function PostPage({ params }: { params: { id: string } }) { async function likePost() { 'use server'; await db.post.update({ where: { id: params.id }, data: { likes: { increment: 1 } } }); } return <form action={likePost}><button>Like</button></form>; }

This action closes over params.id from its surrounding scope — a real convenience, and one that matters later in the security section.

At the top of a separate file, 'use server' on line one marks every exported function in that file as a Server Action:

TypeScript
1
2
3
4
5
// app/actions.ts 'use server'; export async function deleteComment(commentId: string) { /* … */ } export async function likePost(postId: string) { /* … */ }

Key concept: a Client Component can never define an inline Server Action — it can only import one from a 'use server' file. If a component needs to call a mutation from an onClick, that mutation has to live in its own server-only module.

The idiomatic entry point is a <form>'s action prop, not a click handler:

TSX
1
2
3
4
5
6
7
8
9
import { deleteComment } from '@/app/actions'; export function CommentRow({ comment }: { comment: Comment }) { return ( <form action={deleteComment.bind(null, comment.id)}> <button type="submit">Delete</button> </form> ); }

.bind(null, comment.id) pre-supplies the argument so the form doesn't need a hidden input for it — the bound value travels inside the encrypted action payload, not as plain form data.

Key concept: because this is a real <form>, it works before React hydrates and even with JavaScript disabled — the browser submits it as a normal POST and Next.js handles the round trip. That's progressive enhancement you get for free, and it's a strong reason to prefer action={} over an onClick that calls the function directly.

A raw form submission doesn't give you a pending spinner or an error message. useActionState (a React 19 hook; it replaced the older useFormState) wraps an action and gives you both:

TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
'use client'; import { useActionState } from 'react'; import { createComment } from '@/app/actions'; const initialState = { error: null as string | null }; export function CommentForm({ postId }: { postId: string }) { const [state, formAction, isPending] = useActionState( async (prevState: typeof initialState, formData: FormData) => { const text = formData.get('text'); if (typeof text !== 'string' || text.trim().length === 0) { return { error: 'Comment cannot be empty.' }; } await createComment(postId, text); return { error: null }; }, initialState, ); return ( <form action={formAction}> <textarea name="text" disabled={isPending} /> <button disabled={isPending}>{isPending ? 'Posting…' : 'Post comment'}</button> {state.error && <p role="alert">{state.error}</p>} </form> ); }

Key concept: the function you pass to useActionState receives the previous state as its first argument and the submitted FormData as its second, and whatever it returns becomes the new state on the next render. That's how a form gets validation feedback without a separate useState and a manual fetch.

A mutation that doesn't invalidate anything leaves stale data on screen. Two functions from next/cache handle this:

  • revalidatePath('/posts') — throws away the cached render for that path (and re-renders it on next visit).
  • revalidateTag('comments') — throws away every cached entry tagged 'comments', wherever it lives, which pairs directly with the cacheTag('comments') call inside the cached function that produced it. (We covered cacheTag and the caching layers themselves in Cache Components Explained — this article assumes you have somewhere to invalidate into, not how that cache is built.)
TSX
1
2
3
4
5
6
7
'use server'; import { revalidateTag } from 'next/cache'; export async function createComment(postId: string, text: string) { await db.comment.create({ data: { postId, text } }); revalidateTag('comments'); }

If the mutation should also navigate — say, after creating a post — call redirect() from next/navigation. It belongs at the end of the action, never inside a try block (see Edge cases).

Waiting for a round trip before showing a "liked" heart feels slow. useOptimistic lets you render the assumed result immediately, then reconcile once the action resolves:

TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
'use client'; import { useOptimistic } from 'react'; import { likePost } from '@/app/actions'; export function LikeButton({ postId, likes }: { postId: string; likes: number }) { const [optimisticLikes, addOptimisticLike] = useOptimistic(likes, (state) => state + 1); return ( <form action={async () => { addOptimisticLike(undefined); await likePost(postId); }} > <button>❤️ {optimisticLikes}</button> </form> ); }

Key concept: the optimistic value rolls back automatically only if the action throws. If your action instead catches its own error and returns a value, the optimistic state sticks around until the real props change — so a Server Action backing a useOptimistic update should let real failures propagate, not swallow them into a returned { error } object the way Stage 3's form does.

This is the part worth being precise about, because getting it wrong in either direction is expensive — either you re-invent protections that already exist, or you assume protections that don't.

Next.js secures the transport:

  • CSRF protection is automatic. A Server Action request only succeeds if its Origin header matches the app's own Host (or X-Forwarded-Host behind a proxy). A cross-site form or script trying to trigger your action from another origin gets rejected before your code runs. If you sit behind a reverse proxy or CDN on a different domain, add it to experimental.serverActions.allowedOrigins in next.config.ts — otherwise your own legitimate traffic gets blocked.
  • Action IDs are encrypted and non-deterministic, recalculated between builds, so they can't be guessed or reused across deployments.
  • Only referenced functions ship at all. An exported Server Action your client code never calls is stripped from the client bundle entirely — it has no public endpoint.
  • Closed-over values are encrypted. In Stage 1's inline example, params.id is captured from the surrounding scope; Next.js encrypts that captured value before it round-trips to the client and back, so it isn't readable or tamperable in the browser. Self-hosting on multiple instances needs a stable NEXT_SERVER_ACTIONS_ENCRYPTION_KEY shared across them, or instances can't decrypt each other's action payloads.

None of the above answers who is allowed to call this. That's Stage 7, and it's on you, exactly as it would be inside a Route Handler:

TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use server'; import { auth } from '@/lib/auth'; export async function deleteComment(commentId: string) { const session = await auth(); if (!session) throw new Error('Not authenticated.'); const comment = await db.comment.findUnique({ where: { id: commentId } }); if (!comment || comment.authorId !== session.userId) { throw new Error('Not authorized.'); } await db.comment.delete({ where: { id: commentId } }); revalidatePath('/posts'); }

Treat every Server Action as if it were a POST handler a stranger could call directly with a tool like curl — because, protected transport aside, that's exactly what it is. Check the session, check ownership of whatever's being mutated, and validate the input shape (a schema library like Zod on the FormData fields is the idiomatic App Router pattern) before touching the database.

  • redirect() inside a try/catch gets swallowed. redirect() works by throwing a special internal signal that Next.js catches higher up the tree. If you call it inside a try block, your own catch intercepts that signal first and treats it like a normal error. Call redirect() after the try/catch finishes, not inside it.
  • A Client Component cannot define an inline action. Only a Server Component function body can hold 'use server' inline; a Client Component must import the action from a server-only file, as in Stage 2.
  • Optimistic state doesn't self-correct on a caught error. As noted in Stage 5, useOptimistic only rolls back when the wrapping action throws — a caught-and-returned error leaves the optimistic UI stuck until real props update.
  • Server Actions aren't cached like fetch or use cache data. They're mutations, not reads; caching applies to what you read afterward, invalidated via revalidatePath/revalidateTag, not to the action call itself.
  • Rate limiting is not built in. The CSRF origin check stops cross-site abuse; it does nothing to stop a signed-in user from calling your action a thousand times a second. Add your own limiter (per-user, per-IP, or both) for anything sensitive.

  • Reach for a Server Action for form-driven mutations inside your own app — creating, updating, deleting data the user is looking at right now. Progressive enhancement and the built-in CSRF handling make it the right default there.
  • Reach for a Route Handler instead when the caller isn't a form in your app: a webhook from a third party, a public API consumed by non-browser clients, or anything that needs a stable, documented URL and method rather than an internal action reference.
  • Validate input with a schema, not ad-hoc if checks — FormData gives you strings and files, never trust the shape.
  • Check auth and ownership first, mutate second. Fail fast, before touching the database.
  • Pair every cacheTag with the revalidateTag call that invalidates it, and keep that pairing close together in the codebase so it's obvious which mutation clears which cache.

No. They cover form-driven mutations from your own app's UI well; a public API, a webhook receiver, or a non-browser client still wants a Route Handler with a stable URL.

useActionState is useFormState's React 19 successor — the same shape (previous state in, new state out, plus a pending flag), under a name that reflects it isn't limited to forms. useFormState still works in the interim but is deprecated in favor of it.

The transport is: CSRF origin checks, encrypted action IDs, and encrypted closures all happen automatically. Authorization is not — every action still needs its own auth and ownership checks, the same as any endpoint you'd hand-write.

Only if your code lets it. The CSRF origin check blocks requests whose Origin doesn't match your app's Host (or an explicitly configured allowed origin), so a script on another site can't trigger it. A request crafted directly against your own origin — from curl, from your own signed-in browser session — still reaches the function, which is exactly why Stage 7's checks matter.

Almost always because the action caught its own error and returned a value instead of throwing. useOptimistic reverts on a thrown error, not on a returned one — see Stage 5.

TaskCodeNotes
Define inline (Server Component only)async function f() { 'use server'; … }Can close over component scope; values are encrypted in transit.
Define in a shared file'use server' at top of the fileEvery export in that file becomes a Server Action.
Call from a form<form action={myAction}>Works before hydration; prefer over manual onClick + call.
Pre-bind an argumentmyAction.bind(null, id)Bound value ships inside the encrypted payload, not a hidden input.
Pending + result stateuseActionState(fn, initialState)[state, formAction, isPending]React 19; successor to useFormState.
Pending flag onlyuseFormStatus() inside a form's childReads the nearest parent <form>'s submission state.
Instant UIuseOptimistic(value, reducer)Rolls back only if the action throws, not on a returned error.
Invalidate a route's cacherevalidatePath('/posts')From next/cache, called inside the action.
Invalidate by tagrevalidateTag('comments')Pairs with cacheTag('comments') on the read side.
Navigate after mutatingredirect('/posts')From next/navigation; call outside any try/catch.
Cross-origin protectionAutomatic (Origin vs Host)Extra trusted origins via experimental.serverActions.allowedOrigins.
Auth checkYour own code, every actionNever assumed by the framework — treat it like an API route.

  • A Server Action is an RPC call to a real, encrypted-ID-backed HTTP endpoint — not a function call that happens to run elsewhere.
  • Next.js secures the transport (CSRF origin checks, encrypted IDs, encrypted closures); it never secures who is allowed to call this — that's your job, on every action, every time.
  • useActionState gives you pending/result state, useOptimistic gives you instant UI, and revalidatePath/revalidateTag clear the cache the mutation just invalidated.
  • redirect() throws internally — keep it out of try/catch, or your own catch will swallow the navigation.

That deleteComment bug from the top of this article has a one-line fix: a session and ownership check before the db.comment.delete call. The syntax will never remind you it's missing — the request in your Network tab is the only thing that will.

What's one Server Action in your own codebase you'd want to re-check for an authorization gap after reading this? Drop it in the comments.

Runs right in your browser — poke at it and watch the concept react live.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.