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 aPOSTrequest to the page that rendered the reference, with the action ID in aNext-Actionheader, and onlyPOSTcan 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,useOptimisticand 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 beasync, and a'use server'file may export only async functions. Addingexport 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, soexport type ReviewFormStatenext 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.tsstarts withimport "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.
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=""pBrNO4iLxhrM...Fh8iXh9X88=""/>
<input type="hidden" name="$ACTION_0:1" value="["$@2"]"/>
<input type="hidden" name="$ACTION_0:0" value="{"id":"60d4294657dd23ee63efbd373fb76882a3c67b9d7b","bound":"$@1"}"/>
<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:
| Where the form lives | Before JavaScript has loaded, or with JavaScript off | After hydration |
|---|---|---|
Server Component |
Submitted as a normal multipart |
The browser does not reload: React sends the call with |
Client Component |
Next.js queues the submission and prioritises hydrating the form, so the click is not lost |
Same as above |
Client Component with |
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!" };
}
Source: Forms: Form validation, Zod.
Points that are easy to miss:
-
Shape is not permission. The schema proves that
bookIdis a string; it does not prove that the book exists (hencegetBook) 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)rejects9. 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 theFormData; a debugging log showed$ACTION_REF_2,$ACTION_2:0,$ACTION_2:1,$ACTION_KEY,bookId,rating,textfor a hydrated submission and onlybookId,rating,textfor 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 callsvalidatedFields.error.flatten().fieldErrors. That is the Zod 3 API: with Zod 4.6.5 theinvalid_type_erroroption is silently ignored (the message stays "Invalid input: expected string, received number"), the replacement is theerroroption, and.flatten()still works but the documented function isz.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>
);
}
Source: Forms: Validation errors, Forms: Pending states, React: useActionState, React: useFormStatus.
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.
| Hook | Where | Use when |
|---|---|---|
|
The component that owns the form |
The same component also shows the returned state |
|
Any descendant of the |
A reusable submit button or spinner that must not know about the action |
|
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
POSTstarted at 1541 ms, exactly when the first one finished, and ended at 3048 ms. Do not usePromise.allto 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
createReviewdoes. A thrown error is for the unexpected: in production the client receives only an opaquedigest, never the message, and React shows the nearest error boundary unless the caller catches the rejected promise. The directPOSTtests 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.
| Function | What it does | Where it can be called |
|---|---|---|
|
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 |
|
Marks the tag stale, with stale-while-revalidate semantics; |
Server Functions and Route Handlers |
|
Invalidates a route (a literal path, or a pattern such as |
Server Functions and Route Handlers |
|
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.
Source: updateTag, revalidateTag, revalidatePath, refresh, Server Actions: Choosing a cache update, How revalidation works.
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):
| Action ends with | First GET | Second GET | Third GET |
|---|---|---|---|
nothing |
old list |
old list |
old list |
|
old list |
old list |
old list |
|
new list |
new list |
new list |
|
old list (stale) |
new list |
new list |
|
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 callupdateTag/revalidatePathbefore it. -
Call it outside
try/catch, or thecatchswallows the redirect. -
In a Server Action it performs a client-side navigation when JavaScript is available (type
pushby default in actions,replaceelsewhere); for a progressive-enhancement form post it answers303, otherwise307. Both were observed above (303for the review form).permanentRedirectis the308variant 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
.bindare sent by the client and an action can be called directly with any values, so alocaleof/evil.examplewould turn/${locale}/sign-ininto the protocol-relative URL//evil.example/sign-in, an open redirect. The action therefore checkslocaleagainst the supported locales andbookIdagainst 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>"]'
| Caller | Result | Server log |
|---|---|---|
No cookie |
|
|
bob, not the owner |
|
|
alice, the owner |
|
none |
|
|
|
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.
Source: Server Actions: Security, forbidden, unauthorized, authInterrupts.
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:
Origin sent |
Result |
|---|---|
|
Passed the check (the action ran) |
Passed (exact entry) |
|
Passed ( |
|
|
|
|
|
No |
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.
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 });
}
Source: Pages Router: API Routes.
| Concern | App Router Server Action | Pages Router API Route |
|---|---|---|
Endpoint |
Implicit: the page’s own URL with a |
Explicit file under |
Method check |
Only |
Written by hand |
Body parsing, 1 MB limit |
Decoded for you; |
|
CSRF |
|
Nothing built in |
Works without JavaScript |
Yes, as a form |
Only if you add a |
Re-render after the mutation |
Same response ( |
Client calls |
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
| Pitfall | Symptom and remedy |
|---|---|
Trusting the UI as the authorisation |
Anybody can |
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 |
Build error "Only async functions are allowed to be exported in a "use server" file". Move it to another module |
Calling |
The catch swallows it and nothing redirects. Call it outside, or rethrow |
Code after |
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 |
Using a hidden field or |
Plain text in the HTML. Re-read it on the server from the session |
Spreading |
Includes |
Reading data through an action |
Reads are serialised by the client dispatcher. Use a Server Component or a Route Handler |
|
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 |
|
|
Deprecated; use |
Several servers, one build, no shared key |
"Failed to find Server Action" intermittently. Set |
Calling |
Not allowed: both are Server Actions only (the |
Mismatched |
|
Reading |
Build error |
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
| Version | Change |
|---|---|
Next.js 13.4 (2023) |
Server Actions appear as an experimental feature behind |
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 |
React 19 (December 2024) |
|
Next.js 15 (2024-10-21) |
Unguessable action endpoints and removal of unused actions; |
Next.js 16 (2025-10-21) |
|
Next.js 16.2 (2026-03-18) |
Server Function logging in the dev terminal |
Next.js 16.3 (2026-08-03) |
Experimental |
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/apiplusfetch; forms with<form action>instead ofonSubmitpluspreventDefault. -
Pending, error and optimistic UI:
useActionState,useFormStatus,useOptimisticinstead of hand-writtenisSubmittingstate. -
Refreshing data:
updateTag,revalidatePathandrevalidateTag(tag, profile)instead ofrouter.replace(router.asPath)orres.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.