
How to Use Server Actions to Handle Mutations in Next.js?
For years, changing data in a Next.js app meant writing the same plumbing over and over: an API route that parses a JSON body, a fetch call from a client component, a loading flag in useState, error handling on both sides, and a manual refetch so the page shows the new data. Every form in the app needed that full round trip, and every one of them was a place for bugs to hide.
Server Actions remove most of that plumbing. You write an async function on the server, mark it with "use server", and pass it straight to a form's action prop. Next.js handles the network request, React handles the pending state, and a single call to revalidatePath or updateTag refreshes the page in the same response. Forms even work before JavaScript loads.
This article covers Server Actions in the Next.js 16 App Router from start to finish: what they are, how to define them inline or in a separate file, how to call them from forms, buttons, and event handlers, how to validate input and return errors with useActionState, how to show pending and optimistic states, how to refresh cached data and redirect after a mutation, and how to secure every action properly.
What Server Actions Are
A Server Function is an asynchronous function that runs on the server but can be called from the client through a network request. When a Server Function is used for a mutation, typically through a form action or inside startTransition, it is called a Server Action. The two terms are often used interchangeably, but Server Action is the specific name for the mutation use case.
Under the hood, the "use server" directive tells the compiler to replace the function in client bundles with a reference: an encrypted action ID plus a small dispatcher. When the client calls it, the dispatcher sends a POST request to the current page, Next.js looks up the real function by ID, runs it, and streams back the result.
| Task | API route approach | Server Action approach |
|---|---|---|
| Define the endpoint | app/api/posts/route.ts with a POST handler | An async function with "use server" |
| Send data | fetch with a JSON body from a Client Component | <form action={createPost}> |
| Parse input | await request.json() | FormData passed automatically |
| Pending state | Manual useState flag | useActionState or useFormStatus |
| Refresh the page after saving | Manual refetch or router.refresh() | revalidatePath or updateTag in the action |
| Works without JavaScript | No | Yes, for forms rendered by Server Components |
Server Actions do not replace every API route. If an external service, a mobile app, or a webhook needs to call your backend, a Route Handler with a stable URL is still the right tool. For background on that side, see what are API routes in Next.js. Server Actions are for mutations triggered by your own UI.
Defining Server Actions
There are two ways to mark a function as a Server Action.
Inline in a Server Component
Put "use server" at the top of an async function body inside a Server Component. This is convenient for one-off actions that belong to a single page:
// app/newsletter/page.tsx
import { redirect } from "next/navigation";
export default function NewsletterPage() {
async function subscribe(formData: FormData) {
"use server";
const email = String(formData.get("email") ?? "").trim();
if (!email.includes("@")) return;
// Save the email to your database or mailing list provider here.
console.log("New subscriber:", email);
redirect("/newsletter/thanks");
}
return (
<form action={subscribe}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<button type="submit">Subscribe</button>
</form>
);
}
Because this page is a Server Component, the form submits as a normal HTML form even if JavaScript has not loaded or is disabled. Once the page hydrates, React takes over and submits without a full page reload.
In a separate actions file
For anything you want to reuse, or call from a Client Component, put "use server" at the top of a file. Every exported async function in that file becomes a Server Action:
// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
const body = String(formData.get("body") ?? "").trim();
if (!title) return;
await db.post.create({ title, body });
revalidatePath("/posts");
}
export async function deletePost(id: string) {
await db.post.delete(id);
revalidatePath("/posts");
}
You cannot define a Server Action inside a Client Component, but you can import one from a "use server" file into any Client Component.
For the examples in this article, @/lib/db is a tiny in-memory store so you can run the code without setting up a database. Swap it for Prisma, Drizzle, or your database client of choice:
// lib/db.ts
import "server-only";
export type Post = { id: string; title: string; body: string; createdAt: Date };
const posts: Post[] = [];
export const db = {
post: {
async findMany() {
return [...posts].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
);
},
async create(data: { title: string; body: string }) {
const post = { id: crypto.randomUUID(), createdAt: new Date(), ...data };
posts.push(post);
return post;
},
async delete(id: string) {
const index = posts.findIndex((p) => p.id === id);
if (index !== -1) posts.splice(index, 1);
},
},
};
The server-only import (install it with npm install server-only) makes the build fail if this module is ever imported into a Client Component, which keeps database code out of the browser bundle.
Calling Server Actions
From a form
Pass the action to the form's action prop. React calls it with the form's FormData:
// app/posts/page.tsx
import { db } from "@/lib/db";
import { createPost, deletePost } from "./actions";
export default async function PostsPage() {
const posts = await db.post.findMany();
return (
<main>
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="body" placeholder="Write something..." />
<button type="submit">Publish</button>
</form>
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title}
<form action={deletePost.bind(null, post.id)}>
<button type="submit">Delete</button>
</form>
</li>
))}
</ul>
</main>
);
}
Note the delete button. deletePost expects an id, not FormData, so the example uses bind to pre-fill the first argument. React appends the FormData as the last argument, which deletePost simply ignores. Bound arguments are encoded and sent with the request, and bind works in both Server and Client Components, which makes it a better choice than a hidden input when you want to pass an ID.
From a button with formAction
A single form can call different actions from different buttons using formAction:
// app/editor/editor-form.tsx
import { publishPost, saveDraft } from "./actions";
export function EditorForm() {
return (
<form action={publishPost}>
<input name="title" required />
<textarea name="body" />
<button type="submit">Publish</button>
<button type="submit" formAction={saveDraft}>
Save draft
</button>
</form>
);
}
From an event handler
Server Actions are just async functions on the client side, so you can await them inside onClick or any other handler in a Client Component. Wrap the call in startTransition so React treats it as an action and tracks the pending state:
// app/posts/like-button.tsx
"use client";
import { useState, useTransition } from "react";
import { likePost } from "./actions";
export function LikeButton({
postId,
initialLikes,
}: {
postId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() =>
startTransition(async () => {
const updated = await likePost(postId);
setLikes(updated);
})
}
>
{likes} likes
</button>
);
}
The matching action returns the new count, and that value is serialized back to the client. This excerpt assumes your database client has a likes counter; with Prisma it would be an update that uses { increment: 1 }:
// app/posts/actions.ts (excerpt)
export async function likePost(postId: string): Promise<number> {
const likes = await db.like.increment(postId);
return likes;
}
One behavior to know: Next.js dispatches Server Actions one at a time per client. If a user triggers three actions quickly, they run in sequence, not in parallel. Do not use Promise.all to parallelize actions from the browser. If you need parallel work, do it inside a single action on the server.
Validating Input and Returning Errors
Anything that arrives in a Server Action is untrusted, including FormData, bound arguments, and headers. HTML attributes like required are a convenience for users, not a security boundary, because anyone can send a POST request directly.
A schema library such as Zod keeps validation readable. Install it with npm install zod. To show errors in the form, the action needs to return them, and the form needs useActionState. When you use useActionState, the action signature changes: it receives the previous state as the first argument and the FormData second.
// app/signup/actions.ts
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
const SignupSchema = z.object({
name: z.string().trim().min(2, "Name must be at least 2 characters."),
email: z.string().trim().email("Enter a valid email address."),
password: z.string().min(8, "Password must be at least 8 characters."),
});
export type SignupState = {
errors?: { name?: string[]; email?: string[]; password?: string[] };
message?: string;
};
export async function signup(
_prev: SignupState,
formData: FormData,
): Promise<SignupState> {
const result = SignupSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
});
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
message: "Please fix the highlighted fields.",
};
}
try {
// await createUser(result.data);
} catch {
return { message: "Something went wrong. Please try again." };
}
redirect("/welcome");
}
The form becomes a Client Component so it can read the state:
// app/signup/signup-form.tsx
"use client";
import { useActionState } from "react";
import { signup, type SignupState } from "./actions";
const initialState: SignupState = {};
export function SignupForm() {
const [state, formAction, pending] = useActionState(signup, initialState);
return (
<form action={formAction} noValidate>
<label htmlFor="name">Name</label>
<input id="name" name="name" aria-describedby="name-error" />
<p id="name-error">{state.errors?.name?.[0]}</p>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
aria-describedby="email-error"
/>
<p id="email-error">{state.errors?.email?.[0]}</p>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
aria-describedby="password-error"
/>
<p id="password-error">{state.errors?.password?.[0]}</p>
<p aria-live="polite">{state.message}</p>
<button type="submit" disabled={pending}>
{pending ? "Creating account..." : "Sign up"}
</button>
</form>
);
}
A few details worth copying from this pattern:
- Return errors, do not throw them. Thrown errors go to the nearest
error.tsxboundary, which replaces the form. Expected validation failures should come back as state. - Keep the return value small. Whatever you return is serialized and sent to the browser. Return messages and field errors, not raw database rows.
- Call
redirectoutsidetry/catch.redirectworks by throwing a special control-flow exception. If you call it inside atryblock, yourcatchwill swallow it.
For more general form patterns, including uncontrolled inputs and client-side validation, see how to handle forms in a Next.js application.
Showing Pending and Optimistic UI
useFormStatus for submit buttons
useActionState gives you a pending flag for the whole form. If you want a reusable submit button that works inside any form, use useFormStatus from react-dom. It reads the status of the nearest parent form, so it must be rendered inside the form, not next to it:
// components/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-disabled={pending}>
{pending ? "Saving..." : children}
</button>
);
}
You can drop <SubmitButton> into a form rendered by a Server Component and still get a live pending state.
useOptimistic for instant feedback
Some interactions should feel instant, like adding a comment or toggling a to-do. useOptimistic shows the expected result immediately and rolls back automatically if the action fails or the server returns different data:
// app/posts/[id]/comments.tsx
"use client";
import { useOptimistic, useRef } from "react";
import { addComment } from "./actions";
type Comment = { id: string; text: string; pending?: boolean };
export function Comments({
postId,
comments,
}: {
postId: string;
comments: Comment[];
}) {
const formRef = useRef<HTMLFormElement>(null);
const [optimisticComments, addOptimistic] = useOptimistic<Comment[], string>(
comments,
(state, text) => [
...state,
{ id: `temp-${Date.now()}`, text, pending: true },
],
);
async function action(formData: FormData) {
const text = String(formData.get("text") ?? "").trim();
if (!text) return;
addOptimistic(text);
formRef.current?.reset();
await addComment(postId, text);
}
return (
<section>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
{c.text}
</li>
))}
</ul>
<form ref={formRef} action={action}>
<input name="text" placeholder="Add a comment" />
<button type="submit">Post</button>
</form>
</section>
);
}
When addComment finishes and revalidates the page, the server sends down the real comments prop, and the optimistic entry is replaced by the saved one.
Refreshing Data and Redirecting After a Mutation
After you change data, the page needs to reflect it. Next.js 16 gives you four tools, all callable from inside a Server Action:
| Function | Import from | What it does | Re-renders in the same response |
|---|---|---|---|
revalidatePath(path) | next/cache | Invalidates cached data and rendered output for a route | Yes |
updateTag(tag) | next/cache | Immediately expires data tagged with tag; next read waits for fresh data | Yes |
revalidateTag(tag, profile) | next/cache | Marks tagged data stale; serves stale while refetching in the background | No |
refresh() | next/cache | Re-renders the current route without touching cached data | Yes |
redirect(url) | next/navigation | Navigates to another route and streams its payload | Navigates instead |
The practical rule: use updateTag or revalidatePath when the user must see their own change immediately, such as after creating a post. Use revalidateTag(tag, "max") when eventual freshness is fine, such as updating a public listing that other people will see later.
// app/posts/actions.ts (excerpt)
"use server";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export async function publishPost(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
const body = String(formData.get("body") ?? "").trim();
if (!title) return;
const post = await db.post.create({ title, body });
updateTag("posts");
redirect(`/posts/${post.id}`);
}
If you are upgrading from Next.js 14 or 15, note two changes. revalidateTag now expects a second argument, a cache life profile such as "max"; the single-argument form is deprecated. And updateTag is new, and it only works inside Server Actions, not Route Handlers. Revalidation is covered in more depth in how to revalidate data on demand with revalidatePath and revalidateTag.
Working with cookies
You can read and write cookies inside an action with the async cookies() function. Setting or deleting a cookie automatically re-renders the current page so the UI reflects the new value:
// app/settings/actions.ts
"use server";
import { cookies } from "next/headers";
export async function setTheme(theme: "light" | "dark") {
const cookieStore = await cookies();
cookieStore.set("theme", theme, { path: "/", maxAge: 60 * 60 * 24 * 365 });
}
In Next.js 15 and later, cookies() and headers() return promises, so always await them.
Securing Server Actions
This is the section most tutorials skip, and it matters most. A Server Action is a public POST endpoint. Hiding a form behind a login page does not protect the action, because anyone who finds the action ID can call it directly.
Next.js provides some protection automatically:
- CSRF protection. Actions only accept
POST, and Next.js compares theOriginheader to theHostheader, rejecting mismatches. - Encrypted, non-guessable action IDs. Unused actions are removed from client bundles entirely.
- Encrypted closures. Variables captured by inline actions are encrypted before they reach the client.
- A 1 MB body size limit by default.
Everything else is your job. Inside every action that touches private data:
- Authenticate. Check the session inside the action itself.
- Authorize. Confirm the user is allowed to change this specific record. Do not trust IDs that come from the client to belong to the caller.
- Validate. Parse every input with a schema.
- Constrain the return value. Return only what the UI needs.
// app/posts/actions.ts (excerpt)
"use server";
import { revalidatePath } from "next/cache";
import { getSession } from "@/lib/session";
import { prisma } from "@/lib/prisma";
export async function deleteOwnPost(postId: string) {
const session = await getSession();
if (!session) throw new Error("Unauthorized");
// Look the post up by id AND owner, so a forged id cannot delete someone else's post.
const post = await prisma.post.findFirst({
where: { id: postId, authorId: session.userId },
select: { id: true },
});
if (!post) throw new Error("Forbidden");
await prisma.post.delete({ where: { id: post.id } });
revalidatePath("/dashboard/posts");
}
Do not rely on proxy.ts (formerly middleware.ts) alone for this. Actions are POST requests to whatever page uses them, so a matcher change or a refactor can quietly remove proxy coverage. The check belongs inside the action. If you are building login flows, see how to implement authentication in a Next.js app.
Configuration for proxies and large uploads
If your app sits behind a reverse proxy or CDN that rewrites the host, or if your actions accept file uploads larger than 1 MB, configure serverActions in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: {
allowedOrigins: ["app.example.com", "*.example.com"],
bodySizeLimit: "5mb",
},
},
};
export default nextConfig;
When self-hosting across several instances, also set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to the same value everywhere, so every instance can decrypt action references created by the others.
Common Problems and Fixes
- "Failed to find Server Action." A browser tab is still running the previous deployment and calling an action ID that no longer exists. Prefer rolling deployments, keep the encryption key stable across instances, and show a "please refresh" message instead of a hard failure.
- The page does not update after saving. The action never invalidated anything. Add
revalidatePath,updateTag, orrefresh()after the mutation. If you usedrevalidateTagwith a profile, the update is intentionally deferred to a later request. - The redirect never happens.
redirectwas called inside atry/catchand thecatchswallowed it. Move it after thetryblock. - "Only plain objects can be passed to Client Components." An action returned a class instance, a
Dateinside a complex object, or a database model with methods. Map the result to a plain object before returning it. useFormStatusalways returnspending: false. The component calling it is not inside the<form>. It reads the status of its parent form only.- Actions block each other. Actions are dispatched sequentially per client by design. Combine related work into one action.
- Body exceeded 1 MB limit. Raise
serverActions.bodySizeLimit, or upload large files directly to object storage with a signed URL.
Server Actions FAQ
No. Both run on the server, but a Server Action has no stable public URL you design yourself. It is called through React's action mechanism with an encrypted ID, and it can return re-rendered UI in the same response. Use Route Handlers when external clients, webhooks, or other services need a fixed endpoint.
Yes, when the form is rendered by a Server Component and its action prop points directly to a Server Action. The form submits as a normal HTML POST. After hydration, React intercepts the submission and avoids a full page reload.
Yes. Define the action in a file that starts with the use server directive, then import it into the Client Component. You can pass it to a form action prop, a button formAction prop, or await it inside an event handler wrapped in startTransition.
They can, but they are not designed for it. Actions run one at a time per client and use POST, which makes them a poor fit for reading data. Fetch data in Server Components and use actions for mutations.
Use updateTag when the user who made the change must see it immediately, such as after creating or editing their own content. Use revalidateTag with a profile such as max when it is fine for the change to appear on a later request.
Yes, the code never reaches the browser. But the action itself is a public endpoint, so always check authentication, authorization, and input validation inside it, and return only the data the UI needs.
Conclusion
Server Actions let you treat a mutation as what it really is: a function call. Define an async function with "use server", pass it to a form or call it in a transition, and Next.js handles the request, the pending state, and the refreshed UI in one round trip. Forms keep working before JavaScript loads, and the amount of client code you ship shrinks.
To use them well, keep actions in dedicated "use server" files, validate every input with a schema, return errors as state through useActionState, add useFormStatus and useOptimistic where feedback matters, and choose updateTag, revalidatePath, or revalidateTag based on how quickly the change needs to appear. Above all, treat every action as a public endpoint and check the session and ownership inside it.
Here are some useful references for going deeper on Server Actions:
- Next.js Docs: Mutating Data — creating and invoking Server Functions in the App Router.
- Next.js Docs: Server Actions and Mutations — sequential dispatch, the single-roundtrip response, security, and deployment.
- React Docs: Server Functions — the React feature Server Actions are built on.
- React Docs: useActionState — form state, returned errors, and pending flags.
- Next.js Docs: Data Security — authentication, authorization, and data access patterns for actions.


