Next.js Server Actions skill for Codex

Treat every action as a public POST endpoint. Codex writes every server action in a fixed order: authenticate, validate with a schema, authorize against the session's tenant, mutate, invalidate, then return or redirect. It wires results into useActionState, keeps forms working without JavaScript, sets body limits and allowed origins deliberately, and proves the action rejects forged payloads from another user.

  • Frontend
  • $nextjs-server-actions
  • 188 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- nextjs-server-actions

Installs to ~/.agents/skills/nextjs-server-actions/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $nextjs-server-actions, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes secure Next.js Server Actions ("use server" functions) with schema validation, per-action authentication and authorization, typed results for useActionState, cache invalidation with updateTag or revalidatePath, redirects, and progressive enhancement. Use when adding a form or mutation in the App Router, reviewing actions for security holes, when an action returns stale UI, when a redirect inside try/catch fails, or when deciding between a server action and a route handler.

Use it when

  • Adding a mutation or form submit to a Next.js App Router app
  • Security review of existing "use server" modules
  • UI shows stale data after an action completes
  • Choosing between a server action and a route handler

Use something else when

How to check the result

A UI test submits valid and invalid input and sees fresh data or field errors, the form works with JavaScript disabled, and calling the action with another user's resource ID is rejected.

The mistake it prevents

Relying on the page or middleware to check permissions, forgetting that the action is callable directly with any arguments.

~/.agents/skills/nextjs-server-actions/SKILL.md

--- name: nextjs-server-actions description: Writes secure Next.js Server Actions ("use server" functions) with schema validation, per-action authentication and authorization, typed results for… license: MIT ---

Done well means: each action treats its input as untrusted, authenticates and authorizes inside the action itself, returns a typed result the form can render, invalidates exactly the cache entries its write affects, and still works when JavaScript has not loaded.

Threat model in three lines

  • Every exported "use server" function becomes a POST endpoint that any client can call with any arguments, whether or not your UI renders it.
  • Checks in a page, layout or proxy.ts/middleware.ts do not protect the action. The action must check the session and permissions itself.
  • Arguments are deserialized from the request: types in the signature are not enforced at runtime. Validate everything.

Workflow

  1. Read AGENTS.md, the Next.js version, and existing actions (grep -rln '"use server"' app src lib). Match the project’s file layout and result shape if one exists.
  2. Decide action vs route handler with the table below.
  3. Put actions in a dedicated module (app/(dashboard)/projects/actions.ts) with "use server" at the top. Import shared data access from a server-only module.
  4. Write the action in the fixed order: authenticate, validate, authorize, mutate, invalidate, return or redirect.
  5. Wire the form with useActionState and a useFormStatus submit button.
  6. Verify with the checklist, including a direct call with a forged payload.
NeedUse
Form or button in your own React UI mutates dataserver action
Third party, webhook, mobile app or cron calls youroute handler
Read data for renderingserver component fetch, not an action
File download or streaming responseroute handler
Large uploads (beyond the body limit)presigned upload to storage, action stores the key

Action template

// app/projects/actions.ts
"use server";

import { z } from "zod";
import { redirect } from "next/navigation";
import { updateTag } from "next/cache";
import { getSession } from "@/lib/auth";        // server-only module
import { db } from "@/lib/db";

const RenameProject = z.object({
  projectId: z.string().uuid(),
  name: z.string().trim().min(1, "Name is required").max(80),
});

export type ActionResult =
  | { ok: true; message?: string }
  | { ok: false; message?: string; fieldErrors?: Record<string, string[]> };

export async function renameProject(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const session = await getSession();
  if (!session) return { ok: false, message: "Sign in again to continue." };

  const parsed = RenameProject.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { ok: false, fieldErrors: z.flattenError(parsed.error).fieldErrors };
  }

  const project = await db.project.findUnique({ where: { id: parsed.data.projectId } });
  if (!project || project.orgId !== session.orgId) {
    return { ok: false, message: "Project not found." }; // do not reveal existence
  }

  try {
    await db.project.update({ where: { id: project.id }, data: { name: parsed.data.name } });
  } catch (err) {
    console.error("renameProject failed", { projectId: project.id, err });
    return { ok: false, message: "Could not save. Try again." };
  }

  updateTag(`project:${project.id}`);
  updateTag(`org:${session.orgId}:projects`);
  return { ok: true, message: "Renamed." };
}

Notes:

  • z.flattenError is the Zod 4 API; on Zod 3 use parsed.error.flatten().fieldErrors. Check the installed version. Valibot or another Standard Schema library works the same.
  • Look up the record and check ownership with the session’s tenant, never with an ID taken from the form alone (that is an IDOR).
  • Return expected failures as data. Throw only for bugs; thrown errors reach the nearest error.tsx and lose the user’s form input.

Wiring the form

"use client";
import { useActionState } from "react";
import { renameProject, type ActionResult } from "./actions";

export function RenameForm({ projectId, name }: { projectId: string; name: string }) {
  const [state, action, pending] = useActionState<ActionResult, FormData>(renameProject, { ok: true });
  return (
    <form action={action}>
      <input type="hidden" name="projectId" value={projectId} />
      <input name="name" defaultValue={name} aria-describedby="name-error" />
      {state.ok === false && state.fieldErrors?.name && <p id="name-error">{state.fieldErrors.name[0]}</p>}
      <button disabled={pending}>Save</button>
      {state.message && <p aria-live="polite">{state.message}</p>}
    </form>
  );
}
  • Hidden inputs are user-controlled. They are fine for identifiers as long as the action re-checks authorization.
  • To bind a server-known value without a hidden input, use renameProject.bind(null, projectId); bound arguments are still sent by the client and must still be validated.
  • A <form action={serverAction}> rendered by a Server Component works before hydration (progressive enhancement). Keep that path working for critical forms.

Invalidation and navigation

  • updateTag(tag): inside actions, for read-your-own-writes. Pairs with cacheTag on the read side.
  • revalidatePath("/projects"): when data is not tagged. Prefer tags for anything shared by several routes.
  • revalidateTag(tag, "max"): when slight staleness is fine (other users’ views).
  • redirect("/projects") throws a control-flow error. Call it after the try/catch, never inside it, or the catch swallows the redirect.
  • refresh() from next/cache (Next 16) re-renders the current route’s uncached data after an action without touching tags.

Configuration and security settings

  • Body size: actions accept 1 MB by default. Raise only where needed with experimental: { serverActions: { bodySizeLimit: "4mb" } } in next.config (check the installed version’s docs for where the key lives); large files belong in direct-to-storage uploads.
  • Origin check: Next compares Origin and Host headers to block cross-site action calls. Behind a proxy that rewrites hosts, add the public domains to serverActions.allowedOrigins instead of disabling checks.
  • Action IDs are encrypted and rotate per build. Multi-instance or multi-region deployments must share NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (a base64 AES key) or closures and bound args fail to decrypt on another instance.
  • Next drops the IDs of actions no client code references, but any action imported anywhere stays callable even when the button using it is hidden by a feature flag or role check. Hiding UI is not access control.
  • Rate-limit expensive or abuse-prone actions (sign-up, invite, send email) by user and IP inside the action.

Gotchas

  • Inline "use server" functions inside a Server Component capture closure variables; those are encrypted but still round-trip through the client. Never capture secrets.
  • "use server" files may only export async functions. Exporting a constant or a Zod schema from the same file is a build error; put schemas in a separate module.
  • Actions run sequentially per client: a slow action blocks the next one from the same tab. Long work belongs in a background job; the action enqueues and returns.
  • Calling actions for data fetching in client components creates POST waterfalls and bypasses caching. Fetch in server components or use a route handler with a query library.
  • console.log in actions logs on the server, not in the browser console.

Verification

  • Type-check and lint pass; the build shows no “use server” export errors.
  • UI test: valid submit shows success and updated data without a manual refresh; invalid input shows field errors and keeps the typed value.
  • With JavaScript disabled, the form still submits and redirects.
  • Security check: call the action as another user’s session or with a foreign projectId and confirm it is rejected. In tests, call the exported function directly with a forged FormData.
  • Data that the action changes is fresh on the next navigation in a production build (next build && next start).

Output

For each action: name, who may call it (auth rule), validated fields, tags or paths it invalidates, and how failure is shown to the user. Flag any action that was missing an authorization check before the change.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Next.js Server Actions"
  short_description: "Treat every action as a public POST endpoint"
  default_prompt: "$nextjs-server-actions Add a rename-project server action with validation and field errors"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc