next-safe-action
Guides

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:

Middleware execution flow: Logging MW calls next() → Auth MW calls next() → Server Code returns → results flow back

Building middleware step by step

Add logging

The simplest middleware: log when actions start and finish:

src/lib/safe-action.ts
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:

src/lib/safe-action.ts
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:

src/app/actions.ts
"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:

PropertyTypeDescription
clientInputunknownThe raw, unvalidated input from the client
bindArgsClientInputsunknown[]Raw bound argument values
ctxCtxAccumulated context from previous middleware
metadataMDAction 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()

NeedMethod
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():

src/app/actions.ts
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:

Execution pipeline
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 code

Both 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:

PropertyTypeDescription
parsedInputParsedInputThe validated and transformed input (schema output type)
clientInputClientInputThe raw input from the client (schema input type)
bindArgsParsedInputstupleValidated bind argument values
bindArgsClientInputstupleRaw bind argument values
ctxCtxAccumulated context from previous middleware
metadataMDAction 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:

src/app/actions.ts
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:

  1. useValidated() requires a prior schema: you must call inputSchema() or bindArgsSchemas() before useValidated(). Otherwise TypeScript will error.
  2. No schemas after useValidated(): once you call useValidated(), you cannot call inputSchema() or bindArgsSchemas(). This prevents the schema from changing after validated middleware has been typed against it.
  3. No use() after useValidated(): once you call useValidated(), you cannot call use(). All pre-validation middleware must be added before any useValidated() 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:

src/app/actions.ts
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
		},
	});

What's next?

On this page