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
npm install next-safe-action valibot

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 };
	});
src/app/login-action.ts
"use server";

import * as v from "valibot";
import { actionClient } from "@/lib/safe-action";

const loginSchema = v.object({
	username: v.pipe(v.string(), v.minLength(3), v.maxLength(10)),
	password: v.pipe(v.string(), v.minLength(8), v.maxLength(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",
		});

		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>;
}

For richer UI, track execution status, handle callbacks, and more:

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

import { useAction } from "next-safe-action/hooks";
import { loginUser } from "./login-action";

export default function Login() {
	const { execute, result, isExecuting } = useAction(loginUser, {
		onSuccess: ({ data }) => {
			console.log("Welcome,", data.name);
		},
		onError: ({ error }) => {
			console.log("Something went wrong:", error);
		},
	});

	return (
		<div>
			<button
				onClick={() => execute({ username: "johndoe", password: "12345678" })}
				disabled={isExecuting}
			>
				{isExecuting ? "Logging in..." : "Log in"}
			</button>
			{result.validationErrors && (
				<p>Validation errors: {JSON.stringify(result.validationErrors)}</p>
			)}
		</div>
	);
}

The useAction hook gives you reactive status, result, and input values, plus lifecycle callbacks (onSuccess, onError, onSettled, and more). See the Hooks guide for the full API.

What's next?

On this page