Server Actions
Form actions, useActionState, optimistic, revalidation.
For most of the JavaScript era, "mutate something on the server" meant: write an API route, define a fetch on the client, JSON-encode the body, hope the types match, and remember to revalidate. Server Actions collapse all of that into a single function with a directive. You write the function on the server. You pass it to a form. The browser calls it like a normal RPC. TypeScript follows you the whole way. Even better: it still works with JavaScript disabled.
The "use server" directive
Mark a function (or an entire file) with "use server" and it becomes invokable from the client. The directive is the contract: this code only ever runs on the server, and Next will set up the plumbing to let the browser call it.
"use server";
import { db } from "@/server/db";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title")?.toString() ?? "";
await db.post.create({ data: { title } });
revalidatePath("/posts");
}Now any component (server or client) can import createPost and either pass it as a form action or call it directly. The compiler replaces the import on the client with a thin reference that POSTs to the server.
Form actions: the progressive-enhancement path
The simplest way to use an action is as a form's action prop. This works even without JavaScript: the form submits a normal POST, Next runs the action, the page re-renders. With JS, the form submits without a full reload.
import { createPost } from "../actions";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">Create</button>
</form>
);
}useActionState: capture results and errors
When you want feedback (validation errors, success message), use the useActionState hook on the client. The action receives the previous state plus the form data and returns a new state.
"use server";
import { z } from "zod";
import { db } from "@/server/db";
import { revalidatePath } from "next/cache";
const Schema = z.object({
title: z.string().min(3, "Title must be at least 3 characters").max(120),
body: z.string().min(10),
});
type State =
| { ok: false; errors: Record<string, string> }
| { ok: true; id: string };
export async function createPost(_prev: State, formData: FormData): Promise<State> {
const parsed = Schema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
});
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
errors[issue.path.join(".")] = issue.message;
}
return { ok: false, errors };
}
const post = await db.post.create({ data: parsed.data });
revalidatePath("/posts");
return { ok: true, id: post.id };
}"use client";
import { useActionState } from "react";
import { createPost } from "../actions";
const initial = { ok: false, errors: {} } as const;
export function NewPostForm() {
const [state, formAction, isPending] = useActionState(createPost, initial);
return (
<form action={formAction}>
<label>
Title
<input name="title" />
</label>
{!state.ok && state.errors.title && <p>{state.errors.title}</p>}
<label>
Body
<textarea name="body" />
</label>
{!state.ok && state.errors.body && <p>{state.errors.body}</p>}
<button disabled={isPending}>{isPending ? "Posting..." : "Post"}</button>
</form>
);
}useFormStatus: know if the parent form is pending
Sometimes a child component (a submit button, an icon) needs to know "is the form I'm inside of currently submitting?" useFormStatus answers that without prop drilling.
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save"}
</button>
);
}useOptimistic: instant UI for slow networks
When a mutation will probably succeed, you can paint the result immediately and let the action confirm in the background. useOptimistic gives you a temporary state that merges with the real list once the server responds.
"use client";
import { useOptimistic } from "react";
import { addTodo } from "./actions";
type Todo = { id: string; text: string; pending?: boolean };
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimistic, addOptimistic] = useOptimistic(
todos,
(current, newText: string) => [
...current,
{ id: "temp-" + crypto.randomUUID(), text: newText, pending: true },
],
);
async function action(formData: FormData) {
const text = formData.get("text")?.toString() ?? "";
addOptimistic(text); // paint instantly
await addTodo(text); // server confirms
}
return (
<>
<form action={action}>
<input name="text" />
<button>Add</button>
</form>
<ul>
{optimistic.map((t) => (
<li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.text}</li>
))}
</ul>
</>
);
}revalidatePath and revalidateTag
After a mutation, you usually want some cached page or fragment to refresh. Two helpers:
revalidatePath("/posts")marks that route's cache stale; the next request rebuilds it.revalidateTag("posts")invalidates every cached fetch you tagged with"posts"(more on this in the caching lesson).
Progressive enhancement, for real
Disable JavaScript in your browser, submit the form, and it still works. The browser falls back to a vanilla POST, Next runs the action, returns HTML, and the page reloads. With JS, you get the nicer no-reload experience plus all the hooks. Same code path, same handler. That's the win.
actions.ts next to the page that uses them. For shared actions, put them in app/_actions/ or src/server/actions/. They're just functions, so import paths work like normal.Quiz
What does "use server" do at the top of a file?
Recap
- "use server" marks a function as a Server Action that the browser can invoke as an RPC.
- Forms get progressive enhancement via
action={myAction}; with JS it's a no-reload submit, without JS it's a vanilla POST. useActionStategives you state + pending + the form action;useFormStatustells children about the parent form's status;useOptimisticpaints predictions.- Validate inside the action with Zod or similar; the server is the trust boundary.
- Use
revalidatePath/revalidateTagafter a mutation to refresh cached data.