Type something to search...
Next.js Server Actions and Mutations

Next.js Server Actions and Mutations

The "Mutating Data" article earlier in this series covers how to write and call a Server Action — the tutorial-level mechanics. This one is the deeper layer underneath that: what actually happens on the wire when a Server Action runs, why calling three of them in quick succession doesn't behave the way Promise.all intuition would suggest, and the specific security posture Next.js takes toward what is, under the hood, just another POST endpoint anyone can hit directly.

A Server Action is formally a React Server Function invoked through one of React's action mechanisms — <form action>, <button formAction>, or a client-side transition. You create one with the 'use server' directive; this article assumes you already know that much, and picks up from there.

Actions dispatch one at a time, per client

This is the single most surprising behavioral detail if you're coming in with a mental model shaped by ordinary client-side fetch calls: Next.js dispatches Server Actions sequentially, per client. Trigger three actions in quick succession, and the second genuinely waits for the first to finish before it starts; the third waits for the second.

// This does NOT run three actions in parallel on the client
await Promise.all([actionOne(), actionTwo(), actionThree()]);

Despite the Promise.all wrapping, these still dispatch one after another from the client's perspective — the sequencing is enforced by Next.js's own dispatcher, not something Promise.all can override. The reasoning behind this constraint is worth understanding rather than just accepting: each Server Action that triggers a revalidation also re-renders the current route server-side, and that re-render needs to reflect a consistent server state. If three actions ran genuinely in parallel and each triggered its own re-render, you'd have no reliable guarantee about which mutation's effects the resulting UI actually reflects — the re-rendered tree could end up representing an incoherent, interleaved mix of the three mutations' side effects.

If you genuinely need parallel work, the fix isn't fighting this constraint — it's moving the parallelism to where it's actually safe: inside a single Server Action (where you control the ordering yourself), via parallel data fetching in a Server Component, or through a Route Handler for requests that aren't really mutations at all. Worth being precise about scope here too: this sequencing is a property of the client dispatcher specifically, not of Server Functions as a general concept — server-side, once an action is actually running, it's an ordinary async function that can do anything an async function can do, including genuinely parallel work internally.

One response, carrying both the result and the updated UI

This is the part that makes Server Actions feel meaningfully different from "a fetch call to an endpoint that happens to run on your server" — when an action triggers an immediate revalidation, Next.js does the mutation and the resulting re-render inside one single HTTP request, and the response streams back both pieces together in the same Flight stream: the action's own return value (what useActionState or an awaited call receives), and a freshly rendered RSC Payload for the current route.

The practical consequence is genuinely significant: your application code never needs a follow-up fetch to see the current page reflect a change the action just made. Compare this to a conventional REST mutation, where you'd typically POST, get a success response back, and then separately re-fetch or manually patch your client-side state to reflect the change — Next.js collapses that into one round trip, automatically, whenever the action does any of a specific set of things:

  • Calls updateTag or revalidatePath to immediately invalidate cached data.
  • Calls refresh to refetch the current route's RSC Payload without touching the cache at all.
  • Mutates cookies via cookies() — setting or deleting one automatically triggers a re-render so the UI reflects the change.
  • Calls redirect — the response navigates and streams the destination's own RSC Payload instead.
// app/posts/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

export async function createPost(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");

  await db.post.create({
    data: { title: String(formData.get("title")), authorId: session.user.id },
  });

  revalidatePath("/posts");
}

Mutation, cache invalidation, and page re-render — all in one roundtrip. There's a subtle ordering trap worth knowing about explicitly: redirect throws, as a control-flow mechanism, which means any code written after a redirect() call in the same function simply never runs. If a redirect's destination needs to reflect fresh data, your revalidation call has to come before the redirect in the function body — reversing that order silently drops the revalidation.

revalidateTag used with a stale-while-revalidate profile is the deliberate exception to all of this. It marks a tag for background refresh but explicitly does not include a re-render in the action's own response — the current page keeps showing what it already had, and reflects the change only on a subsequent, separate read, once the background refresh completes. And an action that triggers none of the above — no cache invalidation, no cookie mutation, no redirect — carries only its return value; there's no re-render bundled in at all, because nothing signaled that one was needed.

Every action is a public endpoint — treat it that way

This is the section worth internalizing most seriously, because getting it wrong is a genuine security vulnerability, not a performance nitpick. At build time, the 'use server' directive causes the compiler to replace the function's actual implementation, in every client bundle, with a reference — an action ID plus a small dispatcher that POSTs back to your server. The real implementation stays server-side, which is good, but it means the route backing that action is reachable by anyone who can construct the same POST request — not just by clicking through your UI. Render-time gating (only ever rendering a delete button on a page the user is authorized to see) controls what your UI offers, but it controls nothing about what requests actually reach your server, because a request to that action's endpoint doesn't have to originate from your rendered UI at all.

Next.js does provide real, framework-level protections, worth knowing what they cover (and, just as importantly, what they don't):

A CSRF check. The request's Origin header is compared against Host (or X-Forwarded-Host), and a mismatch is rejected outright. If you're running behind a proxy or CDN on a different domain, you'll need serverActions.allowedOrigins configured, or legitimate traffic through that proxy gets rejected as a false-positive mismatch.

A body size cap, 1MB by default, configurable via serverActions.bodySizeLimit for actions that genuinely need larger payloads.

Encrypted action IDs plus dead-code elimination. Action references are encrypted at build time, and any Server Function your client code never actually references gets stripped from the client bundle entirely — meaning it has no public endpoint reachable at all, since there's no reference to it anywhere the client could construct a valid request from.

Closure variable encryption. Variables an inline action captures from its surrounding scope get encrypted before ever reaching the client. In a multi-instance or self-hosted deployment specifically, this requires NEXT_SERVER_ACTIONS_ENCRYPTION_KEY set to one stable value shared across every instance — covered in more depth in this series' self-hosting article — or an action encrypted by one instance simply can't be decrypted by another.

None of that is a substitute for checking things yourself, inside every single action, without exception:

Authenticate and authorize inside the action itself, every time, regardless of what gating exists in the UI that happens to call it. This is the one rule worth repeating until it's reflexive: render-time gating is a UX convenience, never a security boundary.

Validate every input as untrustedFormData, query parameters, headers — exactly as you would for a public API endpoint, because that's precisely what a Server Action is.

Shape return values deliberately. Whatever an action returns gets serialized straight to the client — return exactly what the UI needs to render, not a raw database record that might carry fields (internal flags, other users' data joined in, whatever) the client was never meant to see.

// app/posts/actions.ts
"use server";

import { auth } from "@/lib/auth";

export async function deletePost(postId: string) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");
  if (!(await canDelete(session.user, postId))) throw new Error("Forbidden");

  await db.post.delete({ where: { id: postId } });
}

Destructive actions specifically — deletes above all — often warrant going further still: elevated session checks, a re-authentication step, and a loud, explicit failure the moment those checks miss, rather than a silent no-op. If the experimental authInterrupts flag is enabled, unauthorized() and forbidden() from next/navigation let you throw directly into the corresponding unauthorized.tsx/forbidden.tsx segment automatically, rather than hand-rolling that flow yourself.

The subtle trap: trusting the shape of an object isn't the same as trusting its ownership

This is worth its own callout because it's genuinely easy to get wrong even while believing you've done the validation correctly:

// Unsafe: the whole item, including its id, comes from the client —
// anyone who can POST here can mark ANY item complete, not just their own.
export async function completeItemUnsafe(item: Item) {
  await db.item.update({ where: { id: item.id }, data: { completed: true } });
}

// Safe: take only the change itself; derive identity from the session,
// and look the row up scoped to that session's ownership.
export async function completeItem(itemId: string) {
  const session = await auth();
  if (!session?.user) return;

  const item = await db.item.findFirst({
    where: { id: itemId, ownerId: session.user.id },
  });
  if (!item) return;

  await db.item.update({ where: { id: item.id }, data: { completed: true } });
}

Schema validation (zod or similar) checks that an incoming object has the right shape — it says nothing at all about whether the caller actually owns the specific row that ID refers to. A perfectly well-formed Item object, passing every schema check you could write, can still refer to a row belonging to a different user entirely. The fix pattern is worth memorizing: accept from the client only a reference (an ID) and the specific change being requested, then re-derive everything else — identity, ownership — from the trusted session on the server, rather than trusting anything about identity or ownership that arrived in the request body itself.

Choosing the right cache-update mechanism

After a mutation, you generally want something to reflect the new state — but which mechanism you reach for should follow directly from what you actually need, not habit:

  • updateTag — immediate tag expiration. The very next read, including the re-render bundled into this action's own response, waits for genuinely fresh data. Reach for this specifically when the user needs to see their own change reflected right away — Server Actions only, it can't be called from a Route Handler.
  • revalidateTag — stale-while-revalidate, with a cache-life profile. Subsequent reads get the stale value immediately while a fresh fetch happens in the background — meaning, deliberately, the action's own response does not wait on the new data.
  • revalidatePath — invalidate by URL path directly, when exactly one route is affected and setting up a whole tag for it would be overkill.
  • refresh — refetch the current route's RSC Payload without invalidating any cache at all. The right tool when the view depends on state genuinely outside the cache system (something read fresh from a session, say) that the action just changed.

Worth noting as a mechanical distinction from redirect: none of these four throw — an action can call any of them and still return a plain value to its caller afterward, which redirect (being throw-based) can never do.

Configuration and deployment realities

The framework-level knobs live under serverActions in next.config.js:

// next.config.js
module.exports = {
  experimental: {
    serverActions: {
      allowedOrigins: ["my-proxy.com", "*.my-proxy.com"],
      bodySizeLimit: "2mb",
    },
  },
};

And the closure encryption key belongs in your deployment environment directly, as NEXT_SERVER_ACTIONS_ENCRYPTION_KEY — not in this config file.

There's a genuinely easy-to-hit production issue worth knowing about ahead of time rather than discovering via a support ticket: every Server Action is identified by an action ID baked into its build artifacts, and Next.js rotates these IDs at most every 14 days, even when the underlying source code hasn't changed at all. A new deployment typically means new IDs. A user whose browser tab is still running the previous build can end up invoking an action ID that no longer exists on the new deployment, surfacing as "Failed to find Server Action" — a genuinely confusing error to debug blind if you don't already know this is a normal, expected deployment-timing issue rather than a bug in your code.

Mitigate it deliberately: prefer rolling deployments over an abrupt full cutover when you have active users likely mid-mutation at deploy time; keep the encryption key stable across every instance so references stay decryptable everywhere consistently; and surface this specific error in the UI as an inviting "please refresh" retry path rather than a hard, dead-end failure — a simple page refresh recovers the user instantly, since it picks up the current build's valid action IDs.

Key Takeaways

QuestionAnswer
Do actions from one client run in parallel?No — sequential dispatch, one at a time, per client
Does the client need a follow-up fetch after a mutation?No — the action's response includes both the result and a fresh RSC Payload
Is render-time gating a security boundary?No — every action is a POST endpoint reachable independent of your UI
What Next.js protects automaticallyCSRF origin check, body size limit, encrypted/tree-shaken action IDs, closure encryption
What you must still check yourselfAuth/authorization inside every action, input validation, return-value shaping
Common post-deploy error"Failed to find Server Action" — mitigate with rolling deploys and a UI retry path

The core mental shift this article is really asking for: stop thinking of a Server Action as "a function I call," and start thinking of it as "an endpoint I've published," because that's what it mechanically is the moment 'use server' compiles. Everything else — the sequencing, the single-roundtrip response, the security checklist — follows directly from taking that framing seriously.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper