Actions are server mutations invocable by form/startTransition — not a replacement for GET. App Router architecture: nextjs-architecture. Security (authz, CSRF, IDOR): nextjs-server-actions-security.
When to use
| Use Server Action | Use Route Handler |
|---|---|
| Mutation of the app itself (form, button) | Public API, mobile, third parties |
Progressive enhancement with <form> |
Webhooks, cron, direct upload |
| Co-located with the UI that mutates | Stable REST/JSON contract for external clients |
Do not do read fetches in an action. Prefer Server Components / loaders.
Organization
features/<domínio>/
actions.ts # "use server" — mutações do domínio
schemas.ts # Zod (ou equivalente)
components/ # forms client ou server
"use server"in the top of the file of importable mutations, or inline in Server Components.- One action = one business intention (
createOrder,archivePost) — not a "controller that does everything". - Extract I/O (DB, mail) into services; the action orchestrates validate → authz → mutate → revalidate.
Non-negotiable rules
- Input validated with schema on the server (FormData or object). Client types are not a guarantee.
- Authn/authz inside the action (see security skill).
- Serializable return:
{ ok: true, data }/{ ok: false, code, message }— orredirect/notFound. - After write:
revalidatePath/revalidateTag/updateTagwhat became stale. - No secrets, Prisma client or Node modules in the Client Components bundle — just import the action.
Forms and progressive enhancement
// Server Component — funciona sem JS
import { createTodo } from "./actions";
export function CreateTodoForm() {
return (
<form action={createTodo}>
<input name="title" required maxLength={200} />
<button type="submit">Add</button>
</form>
);
}
"use client";
import { useActionState } from "react";
import { createTodo } from "./actions";
export function CreateTodoForm() {
const [state, formAction, pending] = useActionState(createTodo, null);
return (
<form action={formAction}>
<input name="title" required />
<button disabled={pending}>Add</button>
{state && !state.ok ? <p>{state.message}</p> : null}
</form>
);
}
useFormStatuson the child button for pending without lifting state.useActionStatefor validation/domain errors back to the form.- Hidden fields only for non-sensitive data already known to the server (IDs that the action revalidates).
Idiomatic action
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
const Schema = z.object({
title: z.string().trim().min(1).max(200),
});
export async function createTodo(prev: unknown, formData: FormData) {
const parsed = Schema.safeParse({ title: formData.get("title") });
if (!parsed.success) {
return { ok: false as const, code: "validation", message: "Invalid title" };
}
// authz + persist...
revalidatePath("/todos");
redirect("/todos");
}
- Signature
(prevState, formData)when used withuseActionState; otherwise(formData)or explicit args. redirect()throws — do not mix withreturnafterwards.- Extra args:
.bind(null, id)on the server; the id is still hostile input — revalidate ownership.
Cache and navigation
revalidateTag("todos")when the fetch used the same tag.revalidatePathfor the visible route; avoid global revalidate without reason.redirectafter create/delete that changes the URL; otherwise return data and let the form/router update.- Do not call
cookies()/headers()without need — that opts the route into dynamic.
Errors
- Expected (validation, 409, no permission): object
{ ok: false, code, message }safe for UI. - Unexpected: log + error boundary /
digest; do not leak stack to the client. - Do not use
try/catchto swallow failure ofredirectwithout rethrowing.
Anti-patterns
- Action that only does
fetchto its own Route Handler - Validation only on the client (
zodin the form without parsing in the action) - Return full entities from the DB (hash, tokens, PII)
"use server"in a file mixed with client helpers- Mutation without revalidate (stale UI)
- Read/
searchParamsvia action
Flow when implementing
- Input schema + result type.
- Action: parse → session → policy → persist → revalidate/redirect.
- Form server or
useActionStateon the island. - Test happy path + validation + unauthenticated (security skill).
Completion criteria
"use server"isolated and importable without leaking server-only- Schema at the action boundary
- Correct revalidate/redirect after the write
- Serializable result useful for the form
- No reads disguised as actions
- Authz covered (
nextjs-server-actions-security)