next-safe-action
Concepts

Action Client

The safe action client is the starting point for defining all your actions. You create one with createSafeActionClient(), then use its chainable methods to add middleware, input schemas, and server code.

Creating a client

src/lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";

export const actionClient = createSafeActionClient();

This creates a client with sensible defaults. You can customize its behavior by passing options, see the API reference for the full list.

Common options

src/lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";

export const actionClient = createSafeActionClient({
	// Customize how server errors are handled and what's sent to the client
	handleServerError(e) {
		console.error("Action error:", e.message);
		return "Something went wrong";
	},
	// Define a metadata schema (makes metadata type-safe)
	defineMetadataSchema() {
		return z.object({
			actionName: z.string(),
		});
	},
	// Change the default validation error shape
	defaultValidationErrorsShape: "flattened",
});

handleServerError controls what the client sees when an error is thrown in your server code. By default, it logs the error and returns a generic "Something went wrong" message, preventing sensitive error details from leaking to the client.

The immutability pattern

Every chainable method returns a new client instance, the original is never modified. This is the key design principle that enables client hierarchies:

// This doesn't modify actionClient — it returns a new instance
const authClient = actionClient.use(authMiddleware);

// actionClient still has no middleware
// authClient has authMiddleware

This means you can safely build on top of any client without worrying about side effects.

Client hierarchy

In real applications, you typically create a tree of clients with increasingly specific middleware:

Client hierarchy: actionClient → authClient / publicClient, authClient → adminClient
src/lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";

// Base client — error handling, logging, metadata
export const actionClient = createSafeActionClient({
	handleServerError(e) {
		console.error("Action error:", e.message);
		return e.message;
	},
});

// Authenticated client — requires valid session
export const authClient = actionClient.use(async ({ next }) => {
	const session = await getSession();
	if (!session) throw new Error("Unauthorized");
	return next({ ctx: { user: session.user } });
});

// Admin client — requires admin role
export const adminClient = authClient.use(async ({ next, ctx }) => {
	// ctx.user is available here (typed!) from the auth middleware
	if (ctx.user.role !== "admin") throw new Error("Forbidden");
	return next({ ctx: { isAdmin: true } });
});

Then use the appropriate client for each action:

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

// Public action — no auth needed
export const getPublicData = actionClient
	.action(async () => { /* ... */ });

// Authenticated action — user must be logged in
export const updateProfile = authClient
	.inputSchema(profileSchema)
	.action(async ({ parsedInput, ctx }) => {
		// ctx.user is typed and available
	});

// Admin action — user must be admin
export const deleteUser = adminClient
	.inputSchema(z.object({ userId: z.string() }))
	.action(async ({ parsedInput, ctx }) => {
		// ctx.user and ctx.isAdmin are both available
	});

Chainable methods

The client provides these methods, each returning a new instance:

MethodPurpose
.use(middleware)Add a middleware function
.metadata(data)Set action metadata (requires defineMetadataSchema in client options)
.inputSchema(schema)Define input validation schema
.outputSchema(schema)Define output validation schema
.bindArgsSchemas(schemas)Define schemas for bound arguments
.action(serverCode)Define the action (returns a callable function)
.stateAction(serverCode)Define a stateful action (for React's useActionState)

See the API reference for detailed signatures and parameters.

What's next?

On this page