Server Actions

A Server Action is a public POST endpoint. Anyone who finds its id can call it with any input, including actions that no screen calls anymore. The UI that hides a button is not a check. secureAction puts the checks inside the action.

1. Wrap every action

app/agents/actions.ts
"use server";import { z } from "zod";import { ActionError, secureAction } from "@/lib/secure-action";export const renameAgent = secureAction({  // Required, and it runs on every call.  authorize: async () => {    const user = await currentUser();    if (user && !user.can("agents:write")) throw new ActionError("forbidden");    return user; // null answers "unauthorized"  },  schema: z.object({ id: z.string().uuid(), name: z.string().min(1).max(80) }),  maxInputBytes: 4 * 1024,  rateLimit: (user) => limiter.take(`rename:${user.id}`),  handler: async ({ id, name }, user) => db.agents.rename(user.tenantId, id, name),});
  • The order is fixed: authorize, rate limit, size limit, schema, handler. An anonymous call is never parsed.
  • The handler only sees the schema's output, so extra fields never reach your database.
  • Any schema library works through Standard Schema: zod, valibot, arktype and others.
  • The client gets { ok: true, data } or { ok: false, code }; the error message stays in the server log. Map the code to your own text, or show useToast().failed().

2. Keep secrets on the server

  • The file imports server-only: importing it from a Client Component fails the build.
  • Never capture a secret in a closure inside a component (async function save() { "use server"; use(token) }): Next encrypts closed-over values, but they still travel to the browser and back. Read the secret inside the action.
  • Turn on experimental.taint and call taintSecret(message, lifetimeObject, token) where you load a token: React then refuses to pass it to a Client Component. It returns false when the API is not available.

3. Configure Next

next.config.ts
const config: NextConfig = {  experimental: {    taint: true,    serverActions: {      // Requests whose Origin does not match the Host (or one of these) are rejected by Next.      allowedOrigins: ["app.example.com"],      // Default 1 MB; keep it close to what your largest form needs.      bodySizeLimit: "256kb",    },  },};
  • Next compares the Origin header with the Host on every action call and rejects a mismatch; behind a proxy, list the public origin in allowedOrigins rather than turning the check off.
  • bodySizeLimit is the outer bound for the whole request; maxInputBytes in secureAction is the bound for each action.
  • Delete actions you no longer use: an unused action is still reachable.