How it works
The action lifecycle
When you call a safe action, it goes through a well-defined pipeline on the server before returning a result to the client:
- Client Call: the action function is called from a Client Component (directly, via
useAction, or via a form) - Middleware: the
use()middleware stack runs in order, each layer can short-circuit or extend the context - Validate Input: the raw client input is parsed and validated against the input schema via Standard Schema
- Validated Middleware: if validation succeeded, the
useValidated()middleware stack runs with typedparsedInputand accumulatedctx. Skipped entirely if validation fails. - Server Code: your async function runs with the validated
parsedInputand accumulatedctx - Validate Output: if an
outputSchemais defined, the return value is validated before being sent to the client
If any step fails, the pipeline stops and returns an appropriate error in the action result.
Server vs client
next-safe-action has three entry points, each designed for a specific environment:
| Entry point | Environment | Purpose |
|---|---|---|
next-safe-action | Server only | Define actions with createSafeActionClient, middleware, validation |
next-safe-action/hooks | Client only | useAction, useOptimisticAction, and useStateAction hooks |
next-safe-action/stateful-hooks | Client only | Re-exports useStateAction from next-safe-action/hooks (backward compatibility) |
Server code (your action function) never runs on the client. Next.js ensures this by replacing the server function with a network call when the "use server" directive is present.
The middleware pipeline
Middleware functions wrap your server code like layers of an onion. Each middleware can run code before calling next(), and after the inner layers complete:
const actionClient = createSafeActionClient()
// Layer 1: Logging
.use(async ({ next }) => {
const start = Date.now();
const result = await next();
console.log(`Action took ${Date.now() - start}ms`);
return result;
})
// Layer 2: Auth
.use(async ({ next }) => {
const session = await getSession();
if (!session) throw new Error("Unauthorized");
return next({ ctx: { user: session.user } });
});Key concepts:
next()calls the next layer (or the server code if there are no more middleware)ctx(context) accumulates through the chain, each middleware can add to it vianext({ ctx: { ... } })- Short-circuiting: throwing an error in middleware stops the pipeline and returns a server error
- The returned result flows outward through each middleware, so logging middleware can measure total time
Learn more in the Middleware guide.
Most middleware should use use(), which covers authentication, logging, rate limiting, and context enrichment. If you specifically need middleware that runs after input validation with access to typed parsedInput, see validated middleware (the useValidated() method).
How actions are defined
Every action starts from a safe action client, a builder object that accumulates configuration:
const myAction = actionClient // 1. Start from client
.use(authMiddleware) // 2. Add middleware (optional)
.metadata({ role: "admin" }) // 3. Set metadata (optional)
.inputSchema(z.object({ id: z.string() })) // 4. Define input
.useValidated(logParsedInput) // 5. Validated middleware (optional)
.outputSchema(z.object({ ok: z.boolean() })) // 6. Define output (optional)
.action(async ({ parsedInput, ctx }) => { // 7. Server code
return { ok: true };
});Each method returns a new immutable client instance, the original is never modified. This lets you build a hierarchy of increasingly specialized clients. See Action Client for more.