next-safe-action

Quick start

Requirements

  • Next.js >= 14 (App Router)
  • React >= 18.2.0
  • TypeScript >= 5
  • A Standard Schema validation library (e.g., Zod, Valibot)

Install

Install next-safe-action and a validation library:

npm install next-safe-action zod

Then, create a safe action client. This is the entry point for defining all your actions:

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

export const actionClient = createSafeActionClient();

This creates a base client with default settings. You can extend it later with middleware, error handling, and metadata as your app grows.

Define an action

Create a Server Action with input validation. The schema guarantees that parsedInput is fully typed and validated before your server code runs:

src/app/login-action.ts
"use server";

import { z } from "zod";
import { actionClient } from "@/lib/safe-action";

const loginSchema = z.object({
	username: z.string().min(3).max(10),
	password: z.string().min(8).max(100),
});

export const loginUser = actionClient
	.inputSchema(loginSchema)
	.action(async ({ parsedInput: { username, password } }) => {
		// `username` is string (3-10 chars), `password` is string (8-100 chars)
		// Both are validated before this code runs
		const user = await verifyCredentials(username, password);
		return { id: user.id, name: user.name };
	});

Note the "use server" directive at the top of the file. This is required by Next.js for all Server Actions.

The action() method returns a callable function. The parsedInput object is fully typed based on your schema, so you get autocomplete and compile-time errors if you access a field that doesn't exist.

Execute from a client component

The simplest approach. Call the action directly and handle the result:

src/app/login.tsx
"use client";

import { loginUser } from "./login-action";

export default function Login() {
	const handleLogin = async () => {
		const result = await loginUser({
			username: "johndoe",
			password: "12345678",
		});

		// The result is a discriminated union, so checking one
		// field narrows the others to `undefined`.
		if (result.data) {
			console.log("Welcome,", result.data.name);
		} else if (result.validationErrors) {
			console.log("Validation failed:", result.validationErrors);
		} else if (result.serverError) {
			console.log("Server error:", result.serverError);
		}
	};

	return <button onClick={handleLogin}>Log in</button>;
}

What's next?

On this page