Server Actions
Server Actions run only on the server, invoked from the client over a POST. They are the App Router's tool for
mutations — create, update, delete, form submissions.
Reads don't belong here. Fetch data directly in a Server Component (no HTTP round-trip). Reach for a Server Action
only when state changes.
Stack-agnostic: examples use placeholder modules (~/auth, ~/lib/logger, your ORM, your toast library) — swap them.
The one thing standardized is the result contract, because a consistent return shape lets every form, hook, and
error path compose.
Setup: the result contract
Every action returns a typed discriminated union, not a bare object or a thrown error. Do this once per project before
writing actions:
- Check for an existing definition —
grep -rl "ActionResponse" --include="*.ts" lib src app.
- If none, copy the canonical file assets/action-types.ts into the project:
cp <skill-path>/assets/action-types.ts lib/action-types.ts (match the project's layout).
- If one exists, use it as-is — read it for the exact exported names and import path.
export type ActionResponse<T = unknown> = Promise<
| { success: true; data: T; message?: string }
| { success: false; error: string; fieldErrors?: Record<string, string[]> }
>;
// `never` on success because redirect() throws — the function never returns normally.
export type RedirectAction = Promise<never | { success: false; error: string; fieldErrors?: Record<string, string[]> }>;
The success boolean lets TypeScript narrow the result, so the client always knows whether to read data or
error/fieldErrors — a thrown error would only surface as an opaque message a form can't display. Reserve throws (or
notFound() / unauthorized() / forbidden()) for cases the user can't act on. Full usage in
references/types.md.
Workflow
- Clarify the shape — what's mutated? Returns data or redirects? What needs validation? What auth/ownership rules?
Which cache paths become stale?
- Pick the response type:
ActionResponse<T> to return data; RedirectAction to navigate on success;
ActionResponse<T> with a (prevState, formData) signature for useActionState.
- Write the action (anatomy below).
- Write the Zod schema → references/validation.md.
- Wire into React → references/hooks.md /
references/react-hook-form.md.
Anatomy of an action
Fixed sequence so every action reads the same: directive → auth → validate → mutate → revalidate → return. Keep
business logic in the data/service layer; the action only orchestrates.
"use server"; // must be the first statement in the file (or first line inside an inline action)
import { revalidatePath } from "next/cache";
import { auth } from "~/auth";
import { createLogger } from "~/lib/logger";
import type { ActionResponse } from "~/lib/action-types";
import { createThing } from "../db/things"; // data layer → [error, data] tuple
import { thingSchema, type ThingInput } from "../../schemas/thing";
const logger = createLogger({ module: "things-actions" });
export async function createThingAction(input: ThingInput): ActionResponse<Thing> {
const { userId } = await auth();
if (!userId) return { success: false, error: "Authentication required" };
const parsed = thingSchema.safeParse(input); // never trust the client
if (!parsed.success) {
return { success: false, error: "Validation failed", fieldErrors: parsed.error.flatten().fieldErrors };
}
const [error, thing] = await createThing({ ...parsed.data, ownerId: userId });
if (error) {
logger
.withMetadata({ userId, operation: "createThing", errorCode: error.code })
.error("Failed to create thing");
return { success: false, error: "Could not create the item" }; // user-safe message
}
revalidatePath("/things"); // only on success
return { success: true, data: thing, message: "Item created" };
}
A redirect action ends with redirect("/next") instead of returning success (it throws, so nothing after it runs).
Rules that matter on every action
- Validate every input with Zod; return
fieldErrors from error.flatten().
- Data layer returns
[error, data] (not throws) so the action branches on error kind.
- Never leak internal errors — log the real cause (
userId, operation, errorCode); return a short, safe
message.
- Revalidate only on success, only affected paths/tags — never
revalidatePath("/").
- Toast follows navigation — non-redirect: return
message/error, toast client-side; redirect: set a short-lived
cookie before redirect(). Never a toast cookie without a redirect.
- Order: authenticate → authorize → validate → mutate.
Detail in references/patterns.md.
React integration
useActionState (simple native form) · React Hook Form + useTransition (rich/typed/dynamic) · useTransition (single
button: delete/toggle) · useOptimistic (instant feedback). See references/hooks.md and
references/react-hook-form.md.
Reference files
- references/types.md — contract usage, narrowing, guards,
useActionState signature.
- references/examples.md — CRUD, redirect, client toast,
useActionState, file upload.
- references/patterns.md — errors, logging, cache, security, performance, anti-patterns.
- references/validation.md — Zod schemas, cross-field rules, FormData parsing.
- references/hooks.md —
useActionState, useFormStatus, useTransition, useOptimistic.
- references/react-hook-form.md — RHF integration,
useFieldArray, wizards, UI
binding.
For the logger itself (createLogger, required libraries, full config) see the structured-logging skill's
references/setup.md.
Before reporting done
Run the project's type checker (npm run type-check / tsc --noEmit) — a mistyped result contract compiles against the
client incorrectly. If a form was touched, exercise it once.
1---2name: server-actions3description: Create Next.js Server Actions in TypeScript with a typed result contract, Zod validation, auth guards, error handling, and React integration. Use this skill whenever writing or modifying Server Actions in a Next.js App Router project — form submissions, data mutations, CRUD operations, auth-gated actions, multi-step/redirect flows. Trigger even when the user says "create a server action", "add form handling", "handle form submit on the server", "validate form with Zod", "add a server-side mutation", or asks about useActionState / useTransition / useFormStatus / useOptimistic with actions. Stack-agnostic: works with any auth provider, ORM, logger, and toast library. Targets Next.js 15/16+ with the App Router. Not for: fetching/reading data (use a Server Component), building the toast UI or post-redirect toast plumbing, React error boundaries, or route handlers / REST API endpoints.4---56# Server Actions78Server Actions run only on the server, invoked from the client over a POST. They are the App Router's tool for9**mutations** — create, update, delete, form submissions.1011> **Reads don't belong here.** Fetch data directly in a Server Component (no HTTP round-trip). Reach for a Server Action12> only when state changes.1314Stack-agnostic: examples use placeholder modules (`~/auth`, `~/lib/logger`, your ORM, your toast library) — swap them.15The one thing standardized is the **result contract**, because a consistent return shape lets every form, hook, and16error path compose.1718## Setup: the result contract1920Every action returns a typed discriminated union, not a bare object or a thrown error. Do this once per project before21writing actions:22231. **Check for an existing definition** — `grep -rl "ActionResponse" --include="*.ts" lib src app`.242. **If none, copy the canonical file** [assets/action-types.ts](./assets/action-types.ts) into the project:25 `cp <skill-path>/assets/action-types.ts lib/action-types.ts` (match the project's layout).263. **If one exists, use it as-is** — read it for the exact exported names and import path.2728```typescript29export type ActionResponse<T = unknown> = Promise<30 | { success: true; data: T; message?: string }31 | { success: false; error: string; fieldErrors?: Record<string, string[]> }32>;33// `never` on success because redirect() throws — the function never returns normally.34export type RedirectAction = Promise<never | { success: false; error: string; fieldErrors?: Record<string, string[]> }>;35```3637The `success` boolean lets TypeScript narrow the result, so the client always knows whether to read `data` or38`error`/`fieldErrors` — a thrown error would only surface as an opaque message a form can't display. Reserve throws (or39`notFound()` / `unauthorized()` / `forbidden()`) for cases the user can't act on. Full usage in40[references/types.md](./references/types.md).4142## Workflow43441. **Clarify the shape** — what's mutated? Returns data or redirects? What needs validation? What auth/ownership rules?45 Which cache paths become stale?462. **Pick the response type:** `ActionResponse<T>` to return data; `RedirectAction` to navigate on success;47 `ActionResponse<T>` with a `(prevState, formData)` signature for `useActionState`.483. **Write the action** (anatomy below).494. **Write the Zod schema** → [references/validation.md](./references/validation.md).505. **Wire into React** → [references/hooks.md](./references/hooks.md) /51 [references/react-hook-form.md](./references/react-hook-form.md).5253## Anatomy of an action5455Fixed sequence so every action reads the same: **directive → auth → validate → mutate → revalidate → return**. Keep56business logic in the data/service layer; the action only orchestrates.5758```typescript59"use server"; // must be the first statement in the file (or first line inside an inline action)6061import { revalidatePath } from "next/cache";62import { auth } from "~/auth";63import { createLogger } from "~/lib/logger";64import type { ActionResponse } from "~/lib/action-types";65import { createThing } from "../db/things"; // data layer → [error, data] tuple66import { thingSchema, type ThingInput } from "../../schemas/thing";6768const logger = createLogger({ module: "things-actions" });6970export async function createThingAction(input: ThingInput): ActionResponse<Thing> {71 const { userId } = await auth();72 if (!userId) return { success: false, error: "Authentication required" };7374 const parsed = thingSchema.safeParse(input); // never trust the client75 if (!parsed.success) {76 return { success: false, error: "Validation failed", fieldErrors: parsed.error.flatten().fieldErrors };77 }7879 const [error, thing] = await createThing({ ...parsed.data, ownerId: userId });80 if (error) {81 logger82 .withMetadata({ userId, operation: "createThing", errorCode: error.code })83 .error("Failed to create thing");84 return { success: false, error: "Could not create the item" }; // user-safe message85 }8687 revalidatePath("/things"); // only on success88 return { success: true, data: thing, message: "Item created" };89}90```9192A redirect action ends with `redirect("/next")` instead of returning success (it throws, so nothing after it runs).9394## Rules that matter on every action9596- **Validate every input with Zod**; return `fieldErrors` from `error.flatten()`.97- **Data layer returns `[error, data]`** (not throws) so the action branches on error kind.98- **Never leak internal errors** — log the real cause (`userId`, `operation`, `errorCode`); return a short, safe99 message.100- **Revalidate only on success**, only affected paths/tags — never `revalidatePath("/")`.101- **Toast follows navigation** — non-redirect: return `message`/`error`, toast client-side; redirect: set a short-lived102 cookie _before_ `redirect()`. Never a toast cookie without a redirect.103- **Order: authenticate → authorize → validate → mutate.**104105Detail in [references/patterns.md](./references/patterns.md).106107## React integration108109`useActionState` (simple native form) · React Hook Form + `useTransition` (rich/typed/dynamic) · `useTransition` (single110button: delete/toggle) · `useOptimistic` (instant feedback). See [references/hooks.md](./references/hooks.md) and111[references/react-hook-form.md](./references/react-hook-form.md).112113## Reference files114115- [references/types.md](./references/types.md) — contract usage, narrowing, guards, `useActionState` signature.116- [references/examples.md](./references/examples.md) — CRUD, redirect, client toast, `useActionState`, file upload.117- [references/patterns.md](./references/patterns.md) — errors, logging, cache, security, performance, anti-patterns.118- [references/validation.md](./references/validation.md) — Zod schemas, cross-field rules, FormData parsing.119- [references/hooks.md](./references/hooks.md) — `useActionState`, `useFormStatus`, `useTransition`, `useOptimistic`.120- [references/react-hook-form.md](./references/react-hook-form.md) — RHF integration, `useFieldArray`, wizards, UI121 binding.122123For the logger itself (`createLogger`, required libraries, full config) see the `structured-logging` skill's124[references/setup.md](../structured-logging/references/setup.md).125126## Before reporting done127128Run the project's type checker (`npm run type-check` / `tsc --noEmit`) — a mistyped result contract compiles against the129client incorrectly. If a form was touched, exercise it once.