Overview
Server Actions are async functions that run on the server and are callable from the client through an RPC reference the framework injects at build time. They are the default tool for mutations in Next.js 15: form submissions, optimistic updates, button-triggered state changes. They replace most of what pages/api/ did, and they integrate with the cache so a write can invalidate the right pages without an extra round trip.
Mark actions with "use server"
A server action is a function whose body begins with "use server", or a module whose first line is "use server". The directive promotes every exported function in the module to an action.
// app/notes/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function createNote(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
if (!title) throw new Error("title required");
await db.note.create({ data: { title } });
revalidatePath("/notes");
}A file in a client component ("use client") can still import an action; the framework swaps the function body for an RPC stub at build time. The implementation never ships to the browser.
Pass FormData, not ad-hoc objects
A <form action={serverAction}> calls the action with a FormData instance and works without JavaScript. The browser submits the form; the framework reads the body server-side; the action runs. This is the progressive-enhancement path that ordinary <button onClick> cannot give you.
// app/notes/new/page.tsx
import { createNote } from "../actions";
export default function NewNote() {
return (
<form action={createNote}>
<input name="title" required />
<button type="submit">Save</button>
</form>
);
}For non-form invocations (a button that toggles a flag, an optimistic UI update), call the action like a normal function from a client component. See react-forms for the broader form pattern.
Validate every input on the server
A server action runs with the user’s privileges, but the framework does no type checking on the incoming FormData. Validate every field. Use Zod or another schema library so the validation is one statement, not a wall of String(...). See react-forms for the FormData and Zod pattern at the form layer.
"use server";
import { z } from "zod";
const NoteInput = z.object({
title: z.string().min(1).max(200),
body: z.string().max(10000).optional(),
});
export async function createNote(formData: FormData) {
const parsed = NoteInput.safeParse({
title: formData.get("title"),
body: formData.get("body"),
});
if (!parsed.success) return { error: parsed.error.flatten() };
await db.note.create({ data: parsed.data });
revalidatePath("/notes");
}Treat the action as a public endpoint. Anyone who can render the page can call the action with arbitrary input; sanitize and authorize accordingly.
Authorize before you write
A server action has access to cookies and headers via next/headers. Read the session at the top of the action and short-circuit on failure.
"use server";
import { cookies } from "next/headers";
export async function deleteNote(id: string) {
const session = await getSession(await cookies());
if (!session) throw new Error("unauthorized");
await db.note.delete({ where: { id, ownerId: session.userId } });
revalidatePath("/notes");
}The action runs server-side, but the call originates from the client. Never trust a hidden form field for the user id or the resource owner.
Revalidate with revalidatePath or revalidateTag
A mutation that changes cached data must invalidate the cache. The wrong choice is to drop caching; the right choice is to tag what you cache and call the tag on write.
import { revalidatePath, revalidateTag } from "next/cache";
revalidatePath("/notes"); // one route
revalidatePath("/notes/[id]", "page"); // a dynamic segment
revalidateTag("notes"); // every cache entry with this tagTag fetches and unstable_cache calls so the action can invalidate them in one call. The full cache model lives in nextjs-caching.
Return values, not redirects, for inline UI
A server action’s return value is available to a client component via useActionState. Use it for inline errors and success states.
"use client";
import { useActionState } from "react";
import { createNote } from "./actions";
export function NoteForm() {
const [state, action, pending] = useActionState(createNote, null);
return (
<form action={action}>
<input name="title" />
<button disabled={pending}>Save</button>
{state?.error && <p>{state.error.formErrors.join(", ")}</p>}
</form>
);
}Call redirect("/notes") only when the next paint should be a different route. A redirect after a mutation throws a special control-flow error; do not catch it.
Pick actions over route handlers for mutations
A route.ts handler is for HTTP endpoints with custom status codes, public APIs, webhooks, or non-React clients. For a mutation a React component triggers, server actions win on type safety, integration with the cache, progressive enhancement, and brevity. See nextjs-route-handlers for the cases where a handler is the right tool.