DEV Community

Cover image for Next.js Server Actions: Mutations & Security (Cheat Sheet)
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

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

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.

What you'll learn

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

Who this is for

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.

Table of contents

The problem: a function call that isn't one

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

// 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');
}
Enter fullscreen mode Exit fullscreen mode
// A button inside a Client Component
<button onClick={() => deleteComment(comment.id)}>Delete</button>
Enter fullscreen mode Exit fullscreen mode

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: an RPC endpoint wearing a function's clothes

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:

  1. Leaves the function's body on the server, and strips it entirely out of the client JavaScript bundle.
  2. Replaces every reference to it in client code with an encrypted, opaque ID.
  3. 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.

Stage 1: defining a Server Action

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:

// 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>;
}
Enter fullscreen mode Exit fullscreen mode

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:

// app/actions.ts
'use server';

export async function deleteComment(commentId: string) { /* … */ }
export async function likePost(postId: string) { /* … */ }
Enter fullscreen mode Exit fullscreen mode

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.

Stage 2: wiring it to a form

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

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

Stage 3: pending and result state with useActionState

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:

'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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Stage 4: invalidating data after a mutation

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.)
'use server';
import { revalidateTag } from 'next/cache';

export async function createComment(postId: string, text: string) {
  await db.comment.create({ data: { postId, text } });
  revalidateTag('comments');
}
Enter fullscreen mode Exit fullscreen mode

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

Stage 5: instant UI with useOptimistic

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:

'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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Stage 6: what Next.js secures for you

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.

Stage 7: what you still owe it

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:

'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');
}
Enter fullscreen mode Exit fullscreen mode

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.

Edge cases and gotchas

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

Best practices

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

FAQ

Do Server Actions replace API Routes entirely?

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.

Is useActionState the same as useFormState?

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.

Are Server Actions secure by default?

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.

Can a Server Action be called from outside my app?

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.

Why did my optimistic update stay stuck after an error?

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.

Cheat sheet

Task Code Notes
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 file Every 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 argument myAction.bind(null, id) Bound value ships inside the encrypted payload, not a hidden input.
Pending + result state useActionState(fn, initialState)[state, formAction, isPending] React 19; successor to useFormState.
Pending flag only useFormStatus() inside a form's child Reads the nearest parent <form>'s submission state.
Instant UI useOptimistic(value, reducer) Rolls back only if the action throws, not on a returned error.
Invalidate a route's cache revalidatePath('/posts') From next/cache, called inside the action.
Invalidate by tag revalidateTag('comments') Pairs with cacheTag('comments') on the read side.
Navigate after mutating redirect('/posts') From next/navigation; call outside any try/catch.
Cross-origin protection Automatic (Origin vs Host) Extra trusted origins via experimental.serverActions.allowedOrigins.
Auth check Your own code, every action Never assumed by the framework — treat it like an API route.

Key takeaways

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

🎮 Try it yourself

▶️ Open the interactive playground →

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

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

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:

Top comments (0)