Server Actions & Mutations

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later and TypeScript 5.1 or later. It covers the App Router, the only router that has Server Actions. For the Pages Router the same job is done by API Routes, which a later section compares line by line. Caching matters here because a mutation is only useful if the pages that show the data change afterwards, so the revalidation section shows the same read helper in both models: Cache Components (cacheComponents: true, the 'use cache' directive, still opt-in) and the previous model (fetch and unstable_cache with tags, the default). Every example was built and run with Next.js 16.3.8, Node.js 24.16 and npm 11.13 on macOS in a scratch copy of the Bookshelf — the catalogue of books, authors and reviews that the rest of this section grows — in both caching models, by a production build plus curl requests (a JavaScript-less form post and raw Next-Action calls) and a headless Chrome script for the behaviour that needs hydration. Each example is followed by the official page it derives from.

The running scenario is the review feature of the Bookshelf: signed-in readers write, validate, list and delete reviews of a book. The pages read the session with Auth.js (see the section on authentication for the set-up) through a small Data Access Layer, and none of the code below assumes a database: the store is an in-memory array behind a server-only module, which a real application replaces with its own database or with the FastAPI, NestJS or Django back end of the same domain.

What Server Functions and Server Actions Are

A Server Function is an asynchronous function that runs on the server and that client code can call as if it were local. A Server Action is a Server Function used for a mutation: React runs it inside a transition, which is what happens automatically when you pass it to a <form action> or a <button formAction>. React renamed the feature "Server Functions" in React 19 and kept "Server Action" for that use; Next.js documentation uses both words, and so does this page.

Three facts shape everything else:

  • It is an HTTP endpoint. At build time the 'use server' directive makes the compiler replace the function in the client bundle with a reference (an action ID plus a small dispatcher). Calling it sends a POST request to the page that rendered the reference, with the action ID in a Next-Action header, and only POST can invoke an action. The implementation stays on the server, but the endpoint is reachable by anybody who can send the same request.

  • It is built on React, not on Next.js. (The React side of forms and actions is in React Forms and Actions; where server code runs is in Server and Client Components.) The directive, useActionState, useFormStatus, useOptimistic and the <form action> prop belong to React 19; Next.js supplies the transport, the encryption, the cache invalidation and the redirect handling around them.

  • It is for mutations, not for reading. The client dispatches actions one at a time (measured below), so reading data through an action serialises your page. Reads belong to Server Components (the section on data fetching) or to Route Handlers (the section on Route Handlers and the backend for frontend).

Creating Server Functions: Module Level and Inline

The directive goes in one of two places. At the top of a file it marks every export of that file; at the top of an async function body it marks just that function.

The module-level form is the one to use by default, because it is the only form that Client Components can import. It is also where the application’s mutations belong, next to each other, where they can be reviewed together. This is the file that holds the review actions of the running example (the three actions are shown in the sections that use them; the imports are shown once, here):

// lib/actions/reviews.ts
"use server";

import { z } from "zod";
import { revalidatePath, updateTag } from "next/cache";
import { notFound, redirect } from "next/navigation";
import { getUser } from "@/lib/dal";
import { getBook } from "@/lib/books";
import { isLocale } from "@/lib/i18n";
import { findReview, insertReview, removeReview } from "@/lib/reviews";

The inline form is for a Server Component that wants a one-off action which can see the component’s variables. The page of a book defines an action to report a problem, and the action reads id from the surrounding function:

// app/[locale]/books/[id]/page.tsx (excerpt)
export default async function Page({ params }: PageProps<"/[locale]/books/[id]">) {
  const { locale, id } = await params;
  const book = await getBook(id);
  if (!book) notFound();

  const snapshot = `snapshot-of-${id}`;

  // Inline Server Action: it closes over `id` and `snapshot`.
  async function flagBook(formData: FormData) {
    "use server";
    const user = await getUser();
    if (!user) throw new Error("Unauthorized");
    const reason = z.string().trim().min(1).max(200).parse(formData.get("reason"));
    console.log(`FLAG book=${id} by=${user.id} reason=${reason} captured=${snapshot}`);
  }

  return (
    <article>
      <h1>{book.title}</h1>
      <form action={flagBook}>
        <input name="reason" />
        <SubmitButton>Report a problem</SubmitButton>
      </form>
    </article>
  );
}

The rules that follow from how the compiler works:

  • A function marked 'use server' must be async, and a 'use server' file may export only async functions. Adding export const LIMIT = 5; to such a file fails the build with "Only async functions are allowed to be exported in a "use server" file". Types are erased at compile time, so export type ReviewFormState next to the actions is fine, and Client Components import the type from the same file.

  • A Client Component cannot define a Server Function; it imports one from a 'use server' file, or receives one as a prop from a Server Component.

  • Arguments and return values must be serialisable by React (plain objects, arrays, strings, numbers, Date, FormData, Promises and so on, not class instances or functions), because they cross the network.

  • Everything a Server Function imports stays on the server. That is why lib/reviews.ts starts with import "server-only": if a Client Component ever imported it, the build would fail instead of shipping the module.

What Travels on the Wire

A reader who knows how an action is called can test and secure it. The page produces three kinds of traffic.

sequenceDiagram autonumber participant B as Browser participant S as Next.js server participant A as Server Action B->>S: GET /en/books/1 S-->>B: HTML with the form, hidden $ACTION fields and the RSC payload Note over B: user submits, JavaScript is hydrated B->>S: POST /en/books/1 with Next-Action header and Origin header S->>S: CSRF check: Origin against Host, 1 MB or configured body limit S->>A: decode arguments, decrypt bound variables, run the action A->>A: authenticate, validate, mutate, updateTag or revalidatePath A-->>S: return value, or a thrown redirect S-->>B: one response: the return value and the re-rendered RSC payload Note over B: React commits the new tree, useActionState receives the state

A form rendered on the server carries the call in hidden inputs, so that the browser can post it with no JavaScript at all. This is the form of the inline flagBook action as it appears in the HTML of a production build (the ID is different in every build, and the encrypted value was shortened):

<form action="" encType="multipart/form-data" method="POST">
  <input type="hidden" name="$ACTION_REF_0"/>
  <input type="hidden" name="$ACTION_0:2" value="&quot;pBrNO4iLxhrM...Fh8iXh9X88=&quot;"/>
  <input type="hidden" name="$ACTION_0:1" value="[&quot;$@2&quot;]"/>
  <input type="hidden" name="$ACTION_0:0" value="{&quot;id&quot;:&quot;60d4294657dd23ee63efbd373fb76882a3c67b9d7b&quot;,&quot;bound&quot;:&quot;$@1&quot;}"/>
  <input name="reason"/>
  <button type="submit" aria-disabled="false">Report a problem</button>
</form>

The $ACTION_0:0 field names the action (a 42-character hex ID); $ACTION_0:2 is the encrypted snapshot of the closure, explained in the security section. The form has method="POST" and enctype="multipart/form-data", which is why it works before hydration.

A call from JavaScript is a POST to the current URL with the action ID in the Next-Action header and the arguments as the body. The response has the content type text/x-component and holds the action’s return value and, when the action revalidated something, the freshly rendered RSC payload of the current route, in one round trip. A response that carries a re-rendered payload has the header x-action-revalidated: 1. Redirects travel the same way.

A call from curl is the same request without the browser, which is exactly what an attacker has. The production build writes the action IDs it created to .next/server/server-reference-manifest.json, where tests can read them. The section on authorisation uses it.

Forms and Progressive Enhancement

React extends the HTML <form>: the action prop accepts a function, and a Server Function receives the form’s FormData as its argument. The browser’s own form machinery does the rest, which is what makes progressive enhancement possible: the form works before the JavaScript has loaded, and works when the JavaScript fails or is turned off.

The page of a book has a second route, /en/books/1/review, with a plain form and a module-level action that is bound to the route parameters:

// app/[locale]/books/[id]/review/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { createReviewAndGo } from "@/lib/actions/reviews";
import { isLocale, locales } from "@/lib/i18n";
import { SubmitButton } from "../submit-button";

export function generateStaticParams() {
  return locales.flatMap((locale) => ["1", "2", "3"].map((id) => ({ locale, id })));
}

async function ErrorNote({ searchParams }: { searchParams: PageProps<"/[locale]/books/[id]/review">["searchParams"] }) {
  const { error } = await searchParams;
  return error ? <p role="alert">Check your input.</p> : null;
}

export default async function Page({ params, searchParams }: PageProps<"/[locale]/books/[id]/review">) {
  const { locale, id } = await params;
  if (!isLocale(locale)) notFound();

  // bind() pre-fills the leading arguments; the action receives FormData last.
  const action = createReviewAndGo.bind(null, locale, id);

  return (
    <form action={action}>
      <Suspense>
        <ErrorNote searchParams={searchParams} />
      </Suspense>
      <input name="rating" type="number" min={1} max={5} defaultValue={5} />
      <textarea name="text" />
      <SubmitButton>Send review</SubmitButton>
    </form>
  );
}

The action validates, writes and then redirects (it is shown in the section on redirects). Posting the form without JavaScript can be tested with curl: the hidden fields are those of the page, and Origin is what a browser always adds:

curl -i -b jar-alice.txt -H "Origin: http://localhost:3602" \
  --form-string '$ACTION_REF_0=' \
  --form-string '$ACTION_0:0={"id":"<action id from the page>","bound":"$@1"}' \
  --form-string '$ACTION_0:1=["en","1"]' \
  --form-string rating=5 --form-string text='Great book' \
  http://localhost:3602/en/books/1/review
HTTP/1.1 303 See Other
Location: /en/books/1

The same request without the cookie answers 303 with Location: /en/sign-in, and a request with a one-character text answers 303 to /en/books/1/review?error=invalid. The mutation happened with no JavaScript in the browser, and the 303 See Other is the classic post/redirect/get pattern: reloading the destination cannot resubmit the form.

What "progressive enhancement" means depends on where the form is defined:

Table 1. Behaviour of a form that calls a Server Action
Where the form lives Before JavaScript has loaded, or with JavaScript off After hydration

Server Component

Submitted as a normal multipart POST; the server runs the action and answers with a full page (a 303 for redirect)

The browser does not reload: React sends the call with fetch and applies the response in place

Client Component

Next.js queues the submission and prioritises hydrating the form, so the click is not lost

Same as above

Client Component with useActionState and a Server Function

Works without JavaScript; the returned state is applied when the server renders the response (see the pitfall below)

The state arrives in the transition and the component re-renders

bind arguments work in both kinds of component and keep progressive enhancement. They are visible and editable in the page: the form above carries $ACTION_0:1 with ["en","1"] in clear text, so a client can post any other values. Treat them like any other input, validate them and authorise against them. Passing them as hidden inputs (<input type="hidden" name="bookId">) is equivalent and no better hidden.

Do not pass anything secret through bind or a hidden field. Only the variables that an inline action captures from its closure are encrypted (next section but one).

Validating Input with Zod

Everything in FormData is a string or a file chosen by the client, so the action must check it. The usual tool is a schema library; Zod 4.6.5 is used here. The action below is the one for the useActionState form of the next section: it returns a state object for expected failures (no session, invalid fields, unknown book) and lets unexpected errors throw.

// lib/actions/reviews.ts (continued)
const ReviewSchema = z.object({
  bookId: z.string().min(1),
  rating: z.coerce.number().int().min(1, "Pick 1 to 5").max(5, "Pick 1 to 5"),
  text: z.string().trim().min(3, "Write at least 3 characters").max(500, "At most 500 characters"),
});

export type ReviewFormState = {
  ok: boolean;
  message?: string;
  errors?: { rating?: string[]; text?: string[] };
  values?: { rating: string; text: string };
};

// Used with useActionState: the first argument is the previous state.
export async function createReview(
  _prev: ReviewFormState,
  formData: FormData,
): Promise<ReviewFormState> {
  const user = await getUser();
  if (!user) return { ok: false, message: "Please sign in to review." };

  const parsed = ReviewSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return {
      ok: false,
      errors: z.flattenError(parsed.error).fieldErrors,
      values: { rating: String(formData.get("rating") ?? ""), text: String(formData.get("text") ?? "") },
    };
  }

  const { bookId, rating, text } = parsed.data;
  if (!(await getBook(bookId))) return { ok: false, message: "Unknown book." };

  await insertReview({ bookId, userId: user.id, rating, text });
  updateTag(`reviews:${bookId}`); // read-your-own-writes, see the revalidation section
  return { ok: true, message: "Thanks for your review!" };
}

Points that are easy to miss:

  • Shape is not permission. The schema proves that bookId is a string; it does not prove that the book exists (hence getBook) or that this user may review it. Validation and authorisation are separate checks.

  • Coerce, then bound. A number field arrives as a string, so z.coerce.number() converts it, and .int().min(1).max(5) rejects 9. A request posted by hand can contain anything the schema does not forbid.

  • Object.fromEntries(formData) can contain extra keys. When the browser submits a hydrated form, the hidden $ACTION_…​ inputs are part of the FormData; a debugging log showed $ACTION_REF_2,$ACTION_2:0,$ACTION_2:1,$ACTION_KEY,bookId,rating,text for a hydrated submission and only bookId,rating,text for a JavaScript-less one. Zod’s default object schema strips unknown keys, which is why the example works; a strict schema or a spread of the raw object into a database write would not.

  • Echo the values back (values) so the form can refill itself after a failed validation.

  • Zod 4 changed the API that the Next.js forms guide still shows. The guide’s example passes z.string({ invalid_type_error: "Invalid Email" }) and calls validatedFields.error.flatten().fieldErrors. That is the Zod 3 API: with Zod 4.6.5 the invalid_type_error option is silently ignored (the message stays "Invalid input: expected string, received number"), the replacement is the error option, and .flatten() still works but the documented function is z.flattenError(error), used above.

useActionState, useFormStatus and Pending UI

useActionState (React 19; the name before was useFormState) connects a form to the state that an action returns. It takes the action and an initial state and returns the current state, a function to pass to the form’s action and a pending boolean. React calls the action with the previous state first and the FormData second, which is why createReview above has two parameters.

// app/[locale]/books/[id]/review-form.tsx
"use client";

import { useActionState } from "react";
import { createReview, type ReviewFormState } from "@/lib/actions/reviews";
import { SubmitButton } from "./submit-button";

const initialState: ReviewFormState = { ok: false };

export function ReviewForm({ bookId }: { bookId: string }) {
  const [state, formAction] = useActionState(createReview, initialState);

  return (
    <form action={formAction}>
      <input type="hidden" name="bookId" value={bookId} />
      <label>
        Rating
        <input name="rating" type="number" defaultValue={state.values?.rating ?? "5"} />
      </label>
      {state.errors?.rating && <p role="alert">{state.errors.rating[0]}</p>}
      <label>
        Review
        <textarea name="text" defaultValue={state.values?.text ?? ""} />
      </label>
      {state.errors?.text && <p role="alert">{state.errors.text[0]}</p>}
      <SubmitButton>Send review</SubmitButton>
      <p aria-live="polite">{state.message}</p>
    </form>
  );
}

useFormStatus (from react-dom) tells a component whether the form that contains it is submitting. It must live in a child of the form, not in the component that renders the <form>, which is why the button is a separate file:

// app/[locale]/books/[id]/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

// useFormStatus reads the status of the parent <form>, so it must live in a child component.
export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} aria-disabled={pending}>
      {pending ? "Sending…" : children}
    </button>
  );
}

A headless Chrome run against the production build, with 1.5 seconds of artificial delay in the action, observed this: submitting rating 9 and text x changed the button to "Sending…" and disabled it, then the two messages "Pick 1 to 5" and "Write at least 3 characters" appeared, the textarea kept the x, and the page did not reload (one navigation entry in the performance timeline). Submitting valid values showed "Thanks for your review!" and the list below updated.

Table 2. Three ways to know that an action is running
Hook Where Use when

useActionState pending

The component that owns the form

The same component also shows the returned state

useFormStatus

Any descendant of the <form>

A reusable submit button or spinner that must not know about the action

useTransition isPending

A component that calls an action from an event handler

There is no form (see the next section)

The optional third argument of useActionState is a permalink. It sets the page URL that a form of a Server Function posts to when it is submitted before the JavaScript has loaded. Passing /en/books/1 made the rendered form carry action="/en/books/1" instead of the empty action=""; after hydration the argument has no effect, and the same form component, action and permalink must be rendered at the destination.

Pitfall: State and Suspense Under Cache Components

Observed on 16.3.8: in the previous model, a JavaScript-less POST of the review form with rating 9 came back as a page that already contained both error messages and the text the user typed. With cacheComponents: true the same request was answered (the mutation ran, updateTag took effect and the list was fresh) without the messages whenever the form sat inside the <Suspense> boundary that the session read forces on it, while a form in the static shell showed its returned state. So for the JavaScript-less path, return a redirect to a URL that carries the outcome (the createReviewAndGo pattern below) rather than relying on returned state, and test the path with curl as above.

Calling Actions from Event Handlers

A Server Function is also an ordinary async function in a Client Component. Call it from an event handler, or from useEffect (the official page shows that case for mutations triggered by mounting or by a changed dependency), wrapped in startTransition so that React treats it as an Action: pending state works, the UI stays interactive, and an error reaches an error boundary. This is a button that deletes the reader’s own review:

// lib/actions/reviews.ts (continued)
// Called from an event handler; takes plain arguments, not FormData.
export async function deleteReview(reviewId: string) {
  const user = await getUser();
  if (!user) throw new Error("Unauthorized");

  const review = await findReview(reviewId);
  if (!review || review.userId !== user.id) throw new Error("Forbidden"); // ownership, not just login

  await removeReview(review.id);
  updateTag(`reviews:${review.bookId}`);
  revalidatePath("/[locale]/books/[id]", "page");
}

The client side is the next section, together with useOptimistic, because the two belong together.

Things to know about this style:

  • Sequential dispatch. The Next.js client sends actions one at a time: an action that is triggered while another is in flight waits for it. A Chrome script clicked Delete on two reviews with a 1.5 second delay on the server: the second POST started at 1541 ms, exactly when the first one finished, and ended at 3048 ms. Do not use Promise.all to parallelise actions, do parallel work inside one action, and use a Route Handler for reads. The official page says this is a property of the client dispatcher and may change.

  • Errors. Return expected failures (invalid input, "you do not own this") as values, as createReview does. A thrown error is for the unexpected: in production the client receives only an opaque digest, never the message, and React shows the nearest error boundary unless the caller catches the rejected promise. The direct POST tests below show the digest.

  • Return values are public. They are serialised to the browser; shape them for the UI ({ id, name }), not raw database rows.

Optimistic Updates with useOptimistic

useOptimistic returns a copy of some state that you may change immediately inside a transition; when the transition ends, React throws the optimistic copy away and shows the real state again. It gives instant feedback without pretending that the server has finished. Here the review list removes a row at once while deleteReview runs, and shows an error if the server refuses:

// app/[locale]/books/[id]/review-list.tsx
"use client";

import { startTransition, useOptimistic, useState } from "react";
import { deleteReview } from "@/lib/actions/reviews";

type Item = { id: string; userId: string; rating: number; text: string };

export function ReviewList({ reviews, userId }: { reviews: Item[]; userId: string | null }) {
  // The optimistic list starts from the server data and snaps back to it when the transition ends.
  const [visible, removeLocally] = useOptimistic(reviews, (current, id: string) =>
    current.filter((r) => r.id !== id),
  );
  const [error, setError] = useState<string | null>(null);

  function onDelete(id: string) {
    setError(null);
    startTransition(async () => {
      removeLocally(id);
      try {
        await deleteReview(id);
      } catch {
        setError("Could not delete the review.");
      }
    });
  }

  return (
    <>
      <ul>
        {visible.map((r) => (
          <li key={r.id}>
            {r.rating}/5 {r.text}
            {r.userId === userId && (
              <button type="button" onClick={() => onDelete(r.id)}>
                Delete
              </button>
            )}
          </li>
        ))}
      </ul>
      {error && <p role="alert">{error}</p>}
    </>
  );
}

With a 1.5 second delay on the server, the row disappeared 33 ms after the click and, after the server finished, the re-rendered list matched. In a second run the session cookie was removed just before the click: the row vanished optimistically, the action threw "Unauthorized", the catch block ran, the row came back (the list is again the server data) and "Could not delete the review." appeared. That revert is the property to rely on: an optimistic value is never more than a hint.

The pattern for adding an item is the mirror image: keep only the pending items in useOptimistic([]), render them after the persisted list with a "pending" style, and let the server render replace them. The Next.js interactive-apps guide builds that comment list step by step.

After the Mutation: Revalidate, Refresh or Redirect

An action that changes data has to tell Next.js what is now out of date. Four functions from next/cache do it, and they differ in what they invalidate and in whether the action’s own response waits for fresh data.

Table 3. Which function to call after a mutation
Function What it does Where it can be called

updateTag(tag)

Expires the tag immediately: the next read, including the re-render that ships with the action response, waits for fresh data. Read-your-own-writes. New in Next.js 16

Server Actions only

revalidateTag(tag, profile)

Marks the tag stale, with stale-while-revalidate semantics; 'max' is the recommended profile. The single-argument form is deprecated (it behaves like { expire: 0 })

Server Functions and Route Handlers

revalidatePath(path, type?)

Invalidates a route (a literal path, or a pattern such as "/[locale]/books/[id]" with "page" or "layout")

Server Functions and Route Handlers

refresh()

Refetches the current route’s RSC payload without touching cached data. New in Next.js 16

Server Actions only

None of them throws, so an action can call them and still return a value. Setting or deleting a cookie in an action also re-renders the current page, and redirect navigates to the destination in the same response.

The cache they act on is whatever the page reads through. The review list reads through a helper tagged per book, and the helper is the only part that differs between the two models:

// lib/reviews-cache.ts: Cache Components model (cacheComponents: true)
import { cacheLife, cacheTag } from "next/cache";
import { listReviews } from "@/lib/reviews";

export async function getReviews(bookId: string) {
  "use cache";
  cacheLife("hours");
  cacheTag(`reviews:${bookId}`);
  return { reviews: await listReviews(bookId), renderedAt: new Date().toISOString() };
}
// lib/reviews-cache.ts: previous model (default)
import { unstable_cache } from "next/cache";
import { listReviews } from "@/lib/reviews";

export async function getReviews(bookId: string) {
  return unstable_cache(
    async () => ({ reviews: await listReviews(bookId), renderedAt: new Date().toISOString() }),
    ["reviews", bookId],
    { tags: [`reviews:${bookId}`], revalidate: 3600 },
  )();
}

A server component calls getReviews in both cases. With a fetch to an external API the same tag is attached with fetch(url, { next: { tags: […​] } }). The caching page of this section covers 'use cache', cacheLife and the other layers in full; Partial Prerendering and Cache Components and Incremental Static Regeneration explain the two models.

To see the differences instead of trusting a table, a scratch page listed a book’s reviews through getReviews and had five actions, each adding one review and then calling one function. After each no-JavaScript POST the page was fetched three times, one second apart (the output is identical in both models):

Table 4. What a reader sees after each call (scratch page, production build)
Action ends with First GET Second GET Third GET

nothing

old list

old list

old list

refresh()

old list

old list

old list

revalidatePath(…​)

new list

new list

new list

revalidateTag(tag, "max")

old list (stale)

new list

new list

updateTag(tag)

new list

new list

new list

refresh() does not invalidate cached data (the official page says so), so it is for state that lives outside the cache, such as a cookie or an uncached read. revalidateTag with 'max' serves the stale value once while a regeneration runs, so it suits content where a short delay is fine, and a reader who has just submitted a review wants updateTag.

Redirecting After a Mutation

redirect is how an action ends a flow ("review saved, show the book"). It works by throwing a framework exception, with these consequences:

  • Code after redirect(…​) never runs, so call updateTag/revalidatePath before it.

  • Call it outside try/catch, or the catch swallows the redirect.

  • In a Server Action it performs a client-side navigation when JavaScript is available (type push by default in actions, replace elsewhere); for a progressive-enhancement form post it answers 303, otherwise 307. Both were observed above (303 for the review form). permanentRedirect is the 308 variant for permanent moves.

  • Failure paths can redirect too, which is the robust way to carry an error to a JavaScript-less user, as in ?error=invalid.

  • Never build a redirect from an unchecked argument. Arguments bound with .bind are sent by the client and an action can be called directly with any values, so a locale of /evil.example would turn /${locale}/sign-in into the protocol-relative URL //evil.example/sign-in, an open redirect. The action therefore checks locale against the supported locales and bookId against its format and the database before using either.

// lib/actions/reviews.ts (continued)
// Used from a plain form (bound arguments) and followed by a redirect.
export async function createReviewAndGo(locale: string, bookId: string, formData: FormData) {
  // Bound arguments come from the client like any other argument: validate them before
  // they reach a URL or a write (isLocale is imported at the top of the file).
  if (!isLocale(locale)) notFound();
  if (!/^\d{1,9}$/.test(bookId) || !(await getBook(bookId))) notFound();

  const user = await getUser();
  if (!user) redirect(`/${locale}/sign-in`);

  const parsed = ReviewSchema.safeParse({ bookId, rating: formData.get("rating"), text: formData.get("text") });
  if (!parsed.success) redirect(`/${locale}/books/${bookId}/review?error=invalid`);

  await insertReview({ ...parsed.data, userId: user.id });
  updateTag(`reviews:${bookId}`);
  redirect(`/${locale}/books/${bookId}`); // throws: nothing after this line runs
}

The redirects option of next.config and Proxy are the other places a redirect can be declared; they are covered in Redirects, Rewrites and Proxy.

next/form

next/form exports a <Form> component for forms that do not mutate anything, most often a search box. With a string action it behaves like a GET form: the fields become search params, the destination is prefetched when the form is visible, and the submission is a client-side navigation that keeps the layouts and their state. With a function action it is the React form described above.

// app/[locale]/page.tsx (excerpt)
import Form from "next/form";

export default async function Page({ params, searchParams }: PageProps<"/[locale]">) {
  const { locale } = await params;
  return (
    <main>
      {/* string action: a GET form that navigates on the client, but also works without JavaScript */}
      <Form action={`/${locale}`}>
        <input name="q" aria-label="Search" />
        <button>Search</button>
      </Form>
      {/* results read searchParams inside a Suspense boundary */}
    </main>
  );
}

Typing dune and pressing the button in headless Chrome moved to /en?q=dune and rendered "Dune — Frank Herbert". A marker set on window before the click was still there afterwards, which proves that the page was not reloaded. Without JavaScript the same markup is a normal GET form and the URL is the same. An empty string action (action="") navigates to the current route with the new search params.

Caveats from the reference: replace, scroll and prefetch apply to string actions only; method, encType and target are not supported (use <form> for those); a key on a string action is not supported; formAction on a button navigates on the client but does not prefetch, and needs the basePath written out; onSubmit with preventDefault cancels the navigation; and <input type="file"> submits the file name, not the file. Use a function action for uploads.

Source: Form Component.

Security: Every Action Is a Public Endpoint

Next.js adds protections around actions, and none of them replaces checks inside the action. The official guides state it plainly: render-time gating, a form shown only on an authenticated page, is not a security boundary, because a request can be sent without the UI.

Authorise Every Action

Each action starts by establishing who is calling and ends its checks by establishing what that caller may touch. The session is read on the server, from the cookie, never from an argument. The getUser function used above is the Data Access Layer’s single place for that (the sections on authentication and data security build it out):

// lib/dal.ts
import "server-only";
import { cache } from "react";
import { auth } from "@/auth";

// Data Access Layer: the single place that reads the session.
// `cache` memoises the call for the duration of one request.
export const getUser = cache(async () => {
  const session = await auth();
  return session?.user ?? null;
});

deleteReview shows the second half: it takes only the reference (an ID) from the client and re-reads the row, then compares its owner with the session. The official page warns about the opposite design, an action that receives the whole item from the client: a well-formed object can still name a row that the caller does not own. The guide’s wording is that schema validation checks only the shape of the input.

A raw call to the production build, which is what a script (or an attacker) does, shows all of this. The action ID is read from the build manifest; the review ID belongs to alice:

DEL=$(python3 -c "import json;m=json.load(open('.next/server/server-reference-manifest.json'));print([k for k,v in m['node'].items() if v.get('exportedName')=='deleteReview'][0])")
curl -s -X POST http://localhost:3602/en/books/2 \
  -H "Next-Action: $DEL" -H "Content-Type: text/plain;charset=UTF-8" \
  -H "Origin: http://localhost:3602" -b jar-bob.txt \
  -d '["<alice review id>"]'
Table 5. Raw calls to deleteReview on a production build
Caller Result Server log

No cookie

500, body 1:E{"digest":"…​"}

Error: Unauthorized

bob, not the owner

500, only a digest

Error: Forbidden

alice, the owner

200, x-action-revalidated: 1, a streamed RSC payload; the review is gone

none

Origin: https://evil.example.com

500

x-forwarded-host header with value localhost:3602 does not match origin header with value evil.example.com …​ Aborting the action. and Invalid Server Actions request.

The Unauthorized and Forbidden messages exist only in the server log; the client sees an opaque digest. With the experimental authInterrupts flag, an action can instead call unauthorized() or forbidden() from next/navigation and Next.js renders the matching unauthorized.tsx or forbidden.tsx segment (the authentication section covers it).

Rate limiting and extra checks for destructive operations (re-authentication, elevated session) are the application’s job; the framework has none.

Action IDs and Dead Code Elimination

Action IDs are encrypted, non-deterministic identifiers created at build time. The documentation says they are cached for at most 14 days and regenerated when a new build starts or the build cache is invalidated, and the IDs of two builds in this experiment indeed differed. If an exported action is never referenced by the application, the compiler removes it from the client bundle and creates no endpoint for it (Next.js 15 introduced this; the build manifest still lists the actions that are used). This reduces accidental exposure; it is not an authorisation mechanism, because the ID of a used action is in every page that uses it.

Closure Encryption

An inline action can read variables of the component that defines it. For that to work after the page has been sent, the captured values travel to the browser and back. Next.js encrypts them. The page above captured snapshot = "snapshot-of-1"; the string appeared in the HTML 0 times, only the opaque $ACTION_0:2 value did, and a no-JS submission logged captured=snapshot-of-1 on the server, so the value round-tripped intact. Two properties matter:

  • A new private key is generated for each action on every build, so an action can only be invoked for the build that rendered it.

  • The documentation recommends not relying on encryption alone to keep sensitive values off the client.

Two limits of the mechanism, both visible in the experiment: only closure variables of inline actions are encrypted. Arguments added with bind to a module-level action (["en","1"] above) and hidden inputs are plain text. And the value is encrypted, not authenticated against you: it proves the server created it, not that the reader is allowed to trigger the action.

Multiple instances. Each build gets its own key, so when several servers run the same build (containers behind a load balancer, rolling deployments) a request that reaches an instance with another key fails with "Failed to find Server Action". Set the same key everywhere with NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, a base64 value whose decoded length is a valid AES key size (16, 24 or 32 bytes; Next.js generates 32 by default):

openssl rand -base64 32                       # generate once, keep it in your secret store
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="$ENCRYPTION_KEY_FROM_SECRET_STORE" npx next build

The key is embedded in the build output and used at run time, so it is a build-time secret, never a NEXT_PUBLIC_ variable, and never committed. New deployments typically generate new action IDs even for unchanged source, so a browser that still runs the previous build can call an ID that no longer exists; the answer is rolling deployments, a stable key and a UI that turns the error into a retry.

allowedOrigins and bodySizeLimit

Both options live under experimental.serverActions in 16.3.8 (the build prints "Experiments (use with caution): serverActions" although the feature has been stable since Next.js 14):

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    serverActions: {
      allowedOrigins: ["bookshelf.example.com", "*.bookshelf.example.com"],
      bodySizeLimit: "2mb",
    },
  },
};

export default nextConfig;

Source: serverActions.

CSRF. Actions are POST-only, which together with default SameSite cookies defeats most cross-site request forgery. As an extra layer Next.js compares the Origin header with the Host (or X-Forwarded-Host) and aborts the action when they differ. allowedOrigins lists additional safe hosts, matched against the host part of the Origin (hostname plus port when present). stands for exactly one label and * for one or more, and only at the start of the pattern. The check runs in production as well as in development. Behind a reverse proxy no entry is needed if the proxy forwards the public host in x-forwarded-host; if it forwards its own, add the host that the browser shows. Observed with the configuration above:

Table 6. Origin header against allowedOrigins
Origin sent Result

http://localhost:3602 (the server’s own host)

Passed the check (the action ran)

https://bookshelf.example.com

Passed (exact entry)

https://eu.bookshelf.example.com

Passed (*. matches one label)

https://a.b.bookshelf.example.com

Invalid Server Actions request (two labels, *. does not match)

https://evil.example.com

Invalid Server Actions request

No Origin header at all

Passed (the documentation says such a request is let through with a warning)

Body size. Action requests are capped at 1 MB by default. The option takes a number of bytes or a string such as '500kb' or '3mb', and the limit applies to the raw request body including the bytes that multipart/form-data adds for boundaries and part headers, so leave some room (10 to 20 KB). With bodySizeLimit: "2mb", a 1.5 MB body reached the action and a 3 MB body was rejected with "Body exceeded 2mb limit." (status code 413 in the server error, a 500 response to the client). Raising the limit raises the cost of every anonymous request, so raise it only for the actions that need it and keep uploads of large files for a Route Handler or a direct-to-storage upload.

Never Mutate While Rendering

A GET that changes state (logging out when ?logout=1 is present, for instance) is exposed to CSRF and to prefetching. Next.js refuses to set cookies or revalidate during rendering for this reason. Put mutations in actions, which are POST, and trigger them from a form.

The Pages Router Equivalent

The Pages Router has no Server Actions. A mutation there is an API Route (pages/api/…​) plus a client fetch, with the method check, the session lookup, the validation and the status codes written by hand. The review endpoint of the running example looks like this; it was built in the same app (the App Router and the Pages Router can coexist) and answered 401 without a cookie, 400 with a JSON body of field errors for an invalid payload, 201 for a valid one and 405 with an Allow: POST header for GET:

// pages/api/reviews.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { getToken } from "@auth/core/jwt";

const Body = z.object({ bookId: z.string(), rating: z.number().int().min(1).max(5), text: z.string().trim().min(3).max(500) });

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "POST") return res.status(405).setHeader("Allow", "POST").json({ error: "Method not allowed" });
  // Auth.js v5 JWT cookie; `salt` is the cookie name
  const token = await getToken({ req: { headers: new Headers(req.headers as Record<string, string>) }, secret: process.env.AUTH_SECRET!, salt: "authjs.session-token" });
  if (!token?.sub) return res.status(401).json({ error: "Unauthorized" });
  const parsed = Body.safeParse(req.body);
  if (!parsed.success) return res.status(400).json({ errors: z.flattenError(parsed.error).fieldErrors });
  return res.status(201).json({ ok: true });
}
Table 7. The same mutation in the two routers
Concern App Router Server Action Pages Router API Route

Endpoint

Implicit: the page’s own URL with a Next-Action header

Explicit file under pages/api

Method check

Only POST is accepted by the framework

Written by hand

Body parsing, 1 MB limit

Decoded for you; serverActions.bodySizeLimit

bodyParser and sizeLimit route config

CSRF

Origin against Host, allowedOrigins

Nothing built in

Works without JavaScript

Yes, as a form POST

Only if you add a <form method="post"> and redirect yourself

Re-render after the mutation

Same response (updateTag, revalidatePath, redirect)

Client calls router.replace/refresh, or res.revalidate for ISR

Types between client and server

Imported function, end to end

Hand-written, or a shared schema

A caution for Pages Router projects: in this scratch app the Auth.js v5 beta (next-auth 5.0.0-beta.32) auth() helper failed to load from a Pages API Route in the production server (Failed to load external module next-auth), so the example verifies the session cookie with getToken from @auth/core/jwt. Check the current Auth.js documentation for the supported pairing before copying it.

Pitfalls

Table 8. Server Action pitfalls and what you see
Pitfall Symptom and remedy

Trusting the UI as the authorisation

Anybody can POST the action. Authenticate and authorise inside every action, through the Data Access Layer

Accepting a whole object from the client

A caller can name rows they do not own. Take an ID, re-read the row, compare the owner

Exporting a constant or a helper from a 'use server' file

Build error "Only async functions are allowed to be exported in a "use server" file". Move it to another module

Calling redirect inside try/catch

The catch swallows it and nothing redirects. Call it outside, or rethrow

Code after redirect

Never runs. Revalidate first

Expecting returned state in a JavaScript-less post under Cache Components

Errors missing from the HTML when the form is inside a <Suspense>. Redirect to a URL that carries the outcome

Using a hidden field or bind for secrets

Plain text in the HTML. Re-read it on the server from the session

Spreading Object.fromEntries(formData) into a write

Includes $ACTION_ keys after hydration. Parse with a schema that lists the fields

Reading data through an action

Reads are serialised by the client dispatcher. Use a Server Component or a Route Handler

Promise.all over actions

They still run one after the other (measured: 1541 ms for the second to start). Batch inside one action

Zod 3 examples with Zod 4

invalid_type_error ignored, .flatten() deprecated. Use error and z.flattenError

revalidateTag(tag) with one argument

Deprecated; use updateTag(tag) in an action, or revalidateTag(tag, "max")

Several servers, one build, no shared key

"Failed to find Server Action" intermittently. Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY on build

Calling refresh() or updateTag() from a Route Handler

Not allowed: both are Server Actions only (the refresh reference shows the error). Use revalidateTag there

Mismatched Origin behind a proxy

Invalid Server Actions request. Forward x-forwarded-host or add the public host to allowedOrigins

Reading searchParams, cookies() or params without generateStaticParams outside <Suspense> under Cache Components

Build error blocking-prerender-dynamic (hit by the review page while writing this example). Read it in a child under <Suspense>, as the review page does

Development helps: since Next.js 16.2 next dev prints one line per Server Function execution with the function name, its arguments, the time and the file, for example createReview({"ok":false}, {}) in 4ms lib/actions/reviews.ts and deleteReview("2686c3f6-…​") in 5ms. Note that the form data of a hydrated submission is logged as {}.

Testing Actions

Because an action is an HTTP endpoint, three levels of test are cheap. A unit test imports the action and calls it with a FormData after mocking the Data Access Layer. An HTTP test does what the curl calls on this page do: sign in, read the hidden fields or the build manifest, and post. A browser test drives the form and checks the optimistic and pending states, as the headless Chrome runs did. The section on testing builds the Vitest and Playwright set-up; the rule that matters here is that the HTTP test must include the negative cases (no session, wrong owner, wrong origin).

What Changed Recently

Table 9. Server Actions across Next.js and React versions
Version Change

Next.js 13.4 (2023)

Server Actions appear as an experimental feature behind experimental.serverActions: true

Next.js 14 (2023-10-26)

Server Actions are stable and enabled by default; the flag is no longer needed (the docs keep an "Enabling Server Actions (v13)" note). The options still live under experimental.serverActions

React 19 (December 2024)

useActionState (renamed from useFormState, imported from react), useFormStatus with more keys than pending, useOptimistic, <form action> as stable features; "Server Actions" become a use of "Server Functions"

Next.js 15 (2024-10-21)

Unguessable action endpoints and removal of unused actions; params, searchParams and cookies() become async, which affects the actions that read them

Next.js 16 (2025-10-21)

updateTag() and refresh() added; revalidateTag takes a cache-life profile as a second argument and the single-argument form is deprecated; middleware becomes proxy; sync request APIs are removed

Next.js 16.2 (2026-03-18)

Server Function logging in the dev terminal

Next.js 16.3 (2026-08-03)

Experimental experimental.useOffline: a Server Action interrupted by a connectivity drop stays pending and completes when the network returns. next/root-params is not yet supported in Server Actions

Where the Books Are Outdated

Neither of the two books this section consulted describes Server Actions: a search of the text of Real-World Next.js (Packt, 2022) and Practical Next.js for E-Commerce (Apress, 2023) finds no mention of them, of 'use server' or of useActionState, useFormStatus or useOptimistic. Their mutations go through Pages Router API routes called with fetch, or through an external service, and their data functions are getServerSideProps and getStaticProps. What to use today:

  • Mutations: an App Router Server Action (or a Route Handler for a public API) instead of pages/api plus fetch; forms with <form action> instead of onSubmit plus preventDefault.

  • Pending, error and optimistic UI: useActionState, useFormStatus, useOptimistic instead of hand-written isSubmitting state.

  • Refreshing data: updateTag, revalidatePath and revalidateTag(tag, profile) instead of router.replace(router.asPath) or res.revalidate.

  • Authorisation: check the session on the server in each action. The books' habit of protecting a page with a client hook (useAuth, useSession) does not protect the endpoint. The authentication and data-security sections of this section cover the corrections of their errata (client-only protection, hard-coded secrets, cookie flags, prices taken from the client).

The requester’s books are listed in the section bibliography; no text, listing or figure of them is reproduced here.