Middleware
Middleware lets you run code before and after your action's server code. Common uses include authentication, logging, rate limiting, and enriching the context with shared data.
How middleware executes
Middleware functions wrap your server code in layers. Each layer calls next() to invoke the next layer, and the result flows back outward:
Building middleware step by step
Add logging
The simplest middleware: log when actions start and finish:
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient().use(async ({ next, metadata }) => {
const start = Date.now();
const result = await next();
console.log(`Action ${JSON.stringify(metadata)} took ${Date.now() - start}ms`);
return result;
});The key pattern: do something before next(), await the result, do something after.
Add authentication
Check the session and pass the user into the context:
export const authClient = actionClient.use(async ({ next }) => {
const session = await getSession();
if (!session?.user) {
throw new Error("Not authenticated");
}
// Pass user data to the next layer via ctx
return next({ ctx: { user: session.user } });
});When you pass { ctx: { user } } to next(), it merges with the existing context. The next middleware (or your server code) can access ctx.user.
Use the context in actions
Now actions defined with authClient have typed access to ctx.user:
"use server";
import { authClient } from "@/lib/safe-action";
import { z } from "zod";
export const updateProfile = authClient
.inputSchema(z.object({ name: z.string().min(2) }))
.action(async ({ parsedInput, ctx }) => {
// ctx.user is typed: TypeScript knows it exists
await db.user.update({
where: { id: ctx.user.id },
data: { name: parsedInput.name },
});
return { success: true };
});Instance-level vs action-level middleware
Middleware added with .use() applies to different scopes depending on where you add it:
Instance-level (shared across actions)
// All actions using authClient will run this middleware
const authClient = actionClient.use(async ({ next }) => {
const session = await getSession();
if (!session) throw new Error("Unauthorized");
return next({ ctx: { user: session.user } });
});Action-level (specific to one action)
// Only this action runs the rate limit middleware
export const sensitiveAction = authClient
.use(async ({ next, ctx }) => {
await checkRateLimit(ctx.user.id);
return next();
})
.inputSchema(schema)
.action(async ({ parsedInput, ctx }) => {
// ...
});use() middleware arguments
Each middleware function receives a single object with these properties:
| Property | Type | Description |
|---|---|---|
clientInput | unknown | The raw, unvalidated input from the client |
bindArgsClientInputs | unknown[] | Raw bound argument values |
ctx | Ctx | Accumulated context from previous middleware |
metadata | MD | Action metadata (if defineMetadataSchema is configured) |
next | (opts?) => Promise<MiddlewareResult> | Call the next middleware or server code |
The next() function accepts an optional { ctx: { ... } } to extend the context for downstream layers.
Common patterns
Combining before/after logic
.use(async ({ next, metadata }) => {
// BEFORE: runs before server code
console.log("Starting:", metadata);
const startTime = performance.now();
const result = await next();
// AFTER: runs after server code (even if it failed)
const duration = performance.now() - startTime;
console.log(`Finished: ${metadata} in ${duration}ms`);
return result;
})Short-circuiting
Throwing an error in middleware stops the entire pipeline:
.use(async ({ next, ctx }) => {
if (ctx.user.isBanned) {
throw new Error("Account suspended");
// Server code never runs
}
return next();
})Passing data through context
Each middleware can add to the context, building up a rich object:
const actionClient = createSafeActionClient()
.use(async ({ next }) => {
return next({ ctx: { requestId: crypto.randomUUID() } });
})
.use(async ({ next }) => {
const session = await getSession();
return next({ ctx: { user: session?.user ?? null } });
});
// In your action: ctx = { requestId: string, user: User | null }Validated middleware
Regular use() middleware runs before input validation and only has access to the raw, unvalidated clientInput. Sometimes you need middleware that runs after validation, with access to the typed, validated parsedInput. That's what useValidated() is for.
Start with use(). Most middleware (authentication, logging, rate limiting, error handling, context enrichment) does not need validated input and should use use(). Reach for useValidated() only when your middleware logic specifically depends on parsedInput, for example to check resource ownership or perform input-dependent authorization.
When to use useValidated() vs use()
| Need | Method |
|---|---|
| Authentication, logging, rate limiting (no input needed) | .use() |
Access to raw clientInput before validation | .use() |
| Authorization based on validated input (e.g., check user owns resource) | .useValidated() |
| Logging or auditing validated/transformed input | .useValidated() |
| Enriching context with data derived from parsed input | .useValidated() |
Basic usage
useValidated() can only be called after inputSchema() or bindArgsSchemas():
export const updatePost = authClient
.inputSchema(z.object({ postId: z.string().uuid() }))
.useValidated(async ({ parsedInput, ctx, next }) => {
// parsedInput is typed: { postId: string }
const post = await db.post.findUnique({ where: { id: parsedInput.postId } });
if (post?.authorId !== ctx.user.id) {
throw new Error("Not your post");
}
return next({ ctx: { post } });
})
.action(async ({ ctx }) => {
// ctx.post is typed and available
return { title: ctx.post.title };
});Execution order
All use() middleware runs before input validation, and all useValidated() middleware runs after. The chain declaration order matches the execution order:
const action = authClient
.use(rateLimit) // 1. Pre-validation middleware
.inputSchema(schema) // 2. Input validation
.useValidated(checkOwnership) // 3. Post-validation middleware
.action(serverCode); // 4. Server code
// Execution order:
// 1. authClient's use() middleware
// 2. rateLimit (use() -- pre-validation)
// 3. Input validation
// 4. checkOwnership (useValidated() -- post-validation)
// 5. Server codeBoth stacks follow the onion model: each middleware can run code before and after calling next(), with unwinding in reverse order.
Validated middleware arguments
Each useValidated() middleware function receives a single object with these properties:
| Property | Type | Description |
|---|---|---|
parsedInput | ParsedInput | The validated and transformed input (schema output type) |
clientInput | ClientInput | The raw input from the client (schema input type) |
bindArgsParsedInputs | tuple | Validated bind argument values |
bindArgsClientInputs | tuple | Raw bind argument values |
ctx | Ctx | Accumulated context from previous middleware |
metadata | MD | Action metadata (if defineMetadataSchema is configured) |
next | (opts?) => Promise<MiddlewareResult> | Call the next middleware or server code |
Schema transforms are visible
If your schema includes transforms, useValidated() middleware sees the transformed output, while clientInput retains the original:
authClient
.inputSchema(z.string().transform((s) => s.toUpperCase()))
.useValidated(async ({ clientInput, parsedInput, next }) => {
console.log(clientInput); // "hello" (original)
console.log(parsedInput); // "HELLO" (transformed)
return next();
})
.action(async ({ parsedInput }) => {
// parsedInput is also "HELLO"
});Chaining restrictions
TypeScript enforces three rules at compile time:
useValidated()requires a prior schema: you must callinputSchema()orbindArgsSchemas()beforeuseValidated(). Otherwise TypeScript will error.- No schemas after
useValidated(): once you calluseValidated(), you cannot callinputSchema()orbindArgsSchemas(). This prevents the schema from changing after validated middleware has been typed against it. - No
use()afteruseValidated(): once you calluseValidated(), you cannot calluse(). All pre-validation middleware must be added before anyuseValidated()call. This ensures the context type always matches what is available at runtime.
// ✅ Correct
client.use(mw).inputSchema(schema).useValidated(fn).action(serverCode);
// ❌ Type error: no schema before useValidated()
client.useValidated(fn).action(serverCode);
// ❌ Type error: inputSchema() after useValidated()
client.inputSchema(schema).useValidated(fn).inputSchema(otherSchema).action(serverCode);
// ❌ Type error: use() after useValidated()
client.inputSchema(schema).useValidated(fn).use(mw).action(serverCode);Context in error callbacks
In onError and onSettled callbacks, context from use() middleware is always available, but context added by useValidated() middleware is optional. This is because if validation fails, useValidated() middleware never runs and its context additions don't exist:
authClient
.inputSchema(schema)
.useValidated(async ({ next }) => {
return next({ ctx: { validated: true } });
})
.action(serverCode, {
onError: async ({ ctx }) => {
ctx.user; // User — always available (from use())
ctx.validated; // boolean | undefined — may not exist
},
});