# AI skills (/docs/ai-skills)
next-safe-action provides a set of [skills](https://skills.sh), structured knowledge files optimized for AI coding agents. When installed, these skills give your AI agent deep context about next-safe-action's API, patterns, and anti-patterns, enabling it to write better code with the library.
## Installation [#installation]
Skills are installed via the `skills` CLI. Run from your project root:
```bash
npx skills add next-safe-action/skills
```
This downloads the skills into your project where AI agents can discover and use them automatically.
## Available Skills [#available-skills]
| Skill | Description |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `safe-action-client` | Client creation, action definition, Standard Schema validation (Zod, Yup, Valibot), server error handling |
| `safe-action-middleware` | Authentication, authorization, logging, rate limiting, context extension, `createMiddleware()`, `useValidated()` post-validation middleware |
| `safe-action-hooks` | `useAction`, `useOptimisticAction`, status lifecycle, callbacks, `execute` vs `executeAsync` |
| `safe-action-forms` | React Hook Form adapter, native form submission, file uploads, bind arguments with forms |
| `safe-action-validation-errors` | `returnValidationErrors`, formatted vs flattened shapes, custom error shapes, `throwValidationErrors` |
| `safe-action-advanced` | Bind arguments, metadata schemas, framework errors (redirect/notFound/forbidden/unauthorized), type utilities |
| `safe-action-testing` | Vitest patterns for testing actions, middleware, hooks, validation errors, and framework errors |
| `safe-action-better-auth` | Better Auth adapter: `betterAuth()` middleware, typed session context, custom `authorize` callbacks, `unauthorized()` handling |
| `safe-action-tanstack-query` | TanStack Query adapter: `mutationOptions()`, `useMutation`, `ActionMutationError` handling, optimistic updates, query invalidation |
## How Skills Work [#how-skills-work]
Skills are markdown files that AI agents load when they detect a relevant task. Each skill contains:
* **Correct API usage** with complete, copy-paste-ready code examples
* **Anti-patterns** showing common mistakes and how to fix them
* **Type information** so the agent generates properly typed code
* **Sub-topic files** for deep dives into specific features
Unlike documentation (designed for humans to browse), skills are structured for AI agents to quickly find the right pattern and generate accurate code.
## Supported Agents [#supported-agents]
Skills work with any AI coding agent that supports the [skills.sh](https://skills.sh) format, including:
* [Claude Code](https://claude.ai/claude-code)
* [Cursor](https://cursor.com)
* [OpenAI Codex](https://openai.com/codex)
* Other agents that support the skills format
Visit [skills.sh](https://skills.sh) to learn more about the skills ecosystem.
# Contributing (/docs/contributing)
If you want to contribute to next-safe-action, please check out the [contributing guide](https://github.com/next-safe-action/next-safe-action/blob/main/CONTRIBUTING.md).
If you found bugs or just want to ask a question, feel free to open an issue or a discussion by following the [issue templates](https://github.com/next-safe-action/next-safe-action/issues/new/choose).
### Donations [#donations]
If you find this project useful, please consider making a [donation](https://github.com/sponsors/TheEdoRan). This is absolutely not required, but is very much appreciated, since it will help to cover the time and resources required to maintain this project. Thank you!
## Sponsors [#sponsors]
A big shout-out to all our [sponsors](https://github.com/sponsors/TheEdoRan)! You're the driving force behind this library's growth, and we're truly grateful for your support. ❤️
# Introduction (/docs/introduction)
**next-safe-action** is a library that takes the hassle out of Server Actions in Next.js. It gives you end-to-end type safety from input validation to the action result, with a chainable API for middleware, metadata, and schema validation, all powered by [Standard Schema](https://github.com/standard-schema/standard-schema).
## Features [#features]
Full TypeScript inference from input schema to action result, no manual type annotations needed.
Built-in input and output validation via Standard Schema (Zod, Valibot, ArkType, and more).
Chainable middleware with type-safe context accumulation for authentication, logging, and more.
Purpose-built hooks for executing actions with status tracking, callbacks, and optimistic updates.
## Why next-safe-action? [#why-next-safe-action]
Server Actions are powerful, but using them directly means writing a lot of boilerplate for validation, error handling, and type safety. Here's what changes with next-safe-action.
**Without next-safe-action**, raw Server Actions have no built-in validation or typed results:
```ts title="app/actions.ts"
"use server";
export async function updateUser(formData: FormData) {
// ❌ No type safety: raw FormData
const name = formData.get("name");
// ❌ Manual validation
if (typeof name !== "string" || name.length < 3) {
return { error: "Invalid name" };
}
// ❌ Untyped result: caller doesn't know the shape
try {
await db.user.update({ name });
return { success: true };
} catch (e) {
return { error: "Something went wrong" };
}
}
```
**With next-safe-action**, validation, type safety, and structured results out of the box:
```ts title="app/actions.ts"
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
export const updateUser = actionClient
.inputSchema(z.object({ name: z.string().min(3) }))
.action(async ({ parsedInput: { name } }) => {
// ✅ `name` is typed as `string`, validated, min 3 chars
await db.user.update({ name });
// ✅ Return type is inferred and available to the caller
return { success: true };
});
```
The result is always a structured object with `data`, `validationErrors`, and `serverError`, making it impossible to forget error handling on the client side.
## Next steps [#next-steps]
Get up and running in 5 minutes with a complete working example.
Understand the action lifecycle, middleware pipeline, and architecture.
# Quick start (/docs/quick-start)
* Next.js >= 14 (App Router)
* React >= 18.2.0
* TypeScript >= 5
* A [Standard Schema](https://github.com/standard-schema/standard-schema) validation library (e.g., Zod, Valibot)
### Install [#install]
Install next-safe-action and a validation library:
npm
pnpm
yarn
bun
```bash
npm install next-safe-action zod
```
```bash
pnpm add next-safe-action zod
```
```bash
yarn add next-safe-action zod
```
```bash
bun add next-safe-action zod
```
npm
pnpm
yarn
bun
```bash
npm install next-safe-action valibot
```
```bash
pnpm add next-safe-action valibot
```
```bash
yarn add next-safe-action valibot
```
```bash
bun add next-safe-action valibot
```
Then, create a safe action client. This is the entry point for defining all your actions:
```ts title="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](/docs/guides/middleware), [error handling](/docs/concepts/error-handling), and [metadata](/docs/advanced/metadata) as your app grows.
### Define an action [#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:
```ts title="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 };
});
```
```ts title="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 [#execute-from-a-client-component]
The simplest approach. Call the action directly and handle the result:
```tsx title="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 ;
}
```
For richer UI, track execution status, handle callbacks, and more:
```tsx title="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 (
);
}
```
The `useAction` hook gives you reactive `status`, `result`, and `input` values, plus lifecycle callbacks (`onSuccess`, `onError`, `onSettled`, and more). See the [Hooks guide](/docs/guides/hooks) for the full API.
## What's next? [#whats-next]
Understand the full action lifecycle and middleware pipeline.
Learn the full structure of the result object and how to handle every case.
Add authentication, logging, and context to your actions.
Deep dive into useAction with status tracking, callbacks, and async execution.
# Troubleshooting (/docs/troubleshooting)
Common issues and their solutions. Click on an issue to expand the answer.
If you use next-safe-action in a monorepo, you may see this error:
```
Type error: The inferred type of 'action' cannot be named without a reference to '...'.
This is likely not portable. A type annotation is necessary.
```
**Fix:** Set `baseUrl` in your `tsconfig.json`:
```json title="tsconfig.json"
{
"compilerOptions": {
"baseUrl": "."
}
}
```
Find more information in [this GitHub issue](https://github.com/next-safe-action/next-safe-action/issues/64).
This usually means one of:
1. **You're on v7.1.3 or earlier**, these versions used TypeSchema, which had inference issues with TypeScript >= 5.5. Upgrade to v7.2.0+ where Standard Schema replaced TypeSchema.
2. **Your schema library doesn't implement Standard Schema v1**, make sure you're using a compatible version of Zod (3.23+), Valibot (1.0+), or ArkType (2.0+). See [Standard Schema](/docs/integrations/standard-schema) for details.
3. **Missing `"strict": true`** in your `tsconfig.json`, next-safe-action relies on strict mode for correct inference.
If you're on v7.1.3 or earlier, this was a known issue with TypeSchema's dynamic imports. **Upgrade to v7.2.0+** where Standard Schema replaced TypeSchema. Standard Schema works with all runtimes.
On v7.2.0+, Edge Runtime is fully supported.
`redirect()` works inside actions, but it behaves differently than you might expect:
* `redirect()` throws a special error internally. next-safe-action catches it and re-throws it so Next.js can handle the navigation.
* **`onSuccess` does NOT fire** when `redirect()` is called. Use `onNavigation` instead.
* When calling actions directly (not via hooks), code after `redirect()` never executes.
See [Framework Errors](/docs/advanced/framework-errors) for the full guide on `redirect()`, `notFound()`, `forbidden()`, and `unauthorized()`.
If you're contributing to the docs and see component resolution errors, make sure your `app/docs/[...slug]/page.tsx` uses `useMDXComponents({})` from `@/mdx-components` instead of `defaultMdxComponents`:
```tsx
import { useMDXComponents } from "@/mdx-components";
// In your page component:
```
The explicit `components` prop takes precedence over the `useMDXComponents` fallback in `mdx-components.tsx`.
`handleServerError` always receives an `Error` object. If your action code throws a non-Error value (like a string), it will be wrapped in a generic `Error`.
To use custom error classes:
```ts
class DatabaseError extends Error {
constructor(message: string) {
super(message);
this.name = "DatabaseError";
}
}
const actionClient = createSafeActionClient({
handleServerError: (error) => {
if (error instanceof DatabaseError) {
return "Database error occurred";
}
return DEFAULT_SERVER_ERROR_MESSAGE;
},
});
```
See [Error Handling](/docs/concepts/error-handling) for the full error taxonomy and [Error Classes](/docs/api/error-classes) for all exported error types.
Hook callbacks fire based on status transitions, not on every render. Common issues:
1. **`onSuccess` fires on mount**, this can happen if `result` already has data from a previous execution. Call `reset()` when appropriate.
2. **`onSettled` fires twice**, this shouldn't happen. Check that you're not rendering the component that calls `useAction` twice (e.g., in React Strict Mode, callbacks may appear to fire twice in development).
3. **`onSuccess` doesn't fire after redirect**, this is by design. When `redirect()` is called, `onNavigation` fires instead. See [Framework Errors](/docs/advanced/framework-errors).
See [Hooks](/docs/guides/hooks) for callback lifecycle details and [Hooks API](/docs/api/hooks-api) for the full reference.
When using form actions (stateless `
## Still stuck? [#still-stuck]
If your issue isn't listed here:
* Search [existing GitHub issues](https://github.com/next-safe-action/next-safe-action/issues)
* Open a [new issue](https://github.com/next-safe-action/next-safe-action/issues/new/choose) with a minimal reproduction
* Start a [GitHub discussion](https://github.com/next-safe-action/next-safe-action/discussions) for questions
# Bind arguments (/docs/advanced/bind-arguments)
Bind arguments let you pass **extra validated arguments** to an action that aren't part of the main input. This is useful when you need to combine server-side values (like an ID from a Server Component) with client-side input (like form data).
## How it works [#how-it-works]
### Define bind args schemas [#define-bind-args-schemas]
Use `.bindArgsSchemas()` to define schemas for the extra arguments. The schemas are passed as a **named tuple** so TypeScript can track each argument's type:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
export const updateItem = actionClient
.bindArgsSchemas<[itemId: z.ZodString]>([z.string().uuid()])
.inputSchema(z.object({ name: z.string().min(1) }))
.action(async ({ parsedInput, bindArgsParsedInputs: [itemId] }) => {
// itemId is typed as string (validated UUID)
// parsedInput.name is typed as string
await db.item.update({ where: { id: itemId }, data: { name: parsedInput.name } });
return { success: true };
});
```
### Bind and call from the client [#bind-and-call-from-the-client]
Use JavaScript's `.bind()` to attach the extra arguments:
```tsx title="src/app/items/[id]/page.tsx"
import { updateItem } from "./actions";
import { ItemForm } from "./item-form";
// Server Component: has access to the item ID
export default async function ItemPage({ params }: { params: { id: string } }) {
// Bind the item ID to the action
const updateThisItem = updateItem.bind(null, params.id);
return ;
}
```
```tsx title="src/app/items/[id]/item-form.tsx"
"use client";
import { useAction } from "next-safe-action/hooks";
export function ItemForm({ action }: { action: typeof updateItem }) {
const { execute, isExecuting } = useAction(action);
return (
);
}
```
**Why use bind args instead of including the ID in the input?** Bind args are set on the server (in a Server Component) and can't be tampered with by the client. This is a security pattern, the client only sends the input, not the ID.
## Named tuple types [#named-tuple-types]
The generic parameter `<[itemId: z.ZodString]>` creates a **named tuple type**. This gives you readable names when destructuring `bindArgsParsedInputs`:
```ts
// Without named tuple: positional, harder to read
.bindArgsSchemas([z.string(), z.number()])
.action(async ({ bindArgsParsedInputs: [arg0, arg1] }) => { ... })
// With named tuple: self-documenting
.bindArgsSchemas<[itemId: z.ZodString, version: z.ZodNumber]>([z.string(), z.number()])
.action(async ({ bindArgsParsedInputs: [itemId, version] }) => { ... })
```
## Progressive enhancement [#progressive-enhancement]
Bind args work with form actions too. The bound values are sent as hidden fields in the `FormData`:
```tsx
// Server Component
const deleteItem = deleteItemAction.bind(null, item.id);
// Client form: works without JavaScript
```
## See also [#see-also]
* [Action result](/docs/concepts/action-result): how bind args validation errors appear in the result
* [`.bindArgsSchemas()` method](/docs/api/safe-action-client#bindargsschemas): API reference
* [Form actions](/docs/guides/form-actions): using bind args with progressive enhancement
# Custom validation errors (/docs/advanced/custom-validation-errors)
next-safe-action gives you control over how validation errors are shaped, formatted, and returned to the client.
## Error shapes [#error-shapes]
There are two built-in shapes:
The formatted shape mirrors the schema structure with `_errors` arrays at each level:
```ts
// Result of failed validation:
{
validationErrors: {
name: { _errors: ["String must contain at least 2 character(s)"] },
address: {
street: { _errors: ["Required"] },
_errors: [],
},
}
}
```
This is the default because it preserves the nesting of your schema, making it easy to display errors next to the corresponding form fields.
The flattened shape separates form-level errors from field-level errors:
```ts
// Result of failed validation:
{
validationErrors: {
formErrors: ["Passwords don't match"],
fieldErrors: {
name: ["String must contain at least 2 character(s)"],
email: ["Invalid email"],
},
}
}
```
This is simpler to work with when you just need a list of errors per field.
### Setting the default shape [#setting-the-default-shape]
Set the shape for all actions when creating the client:
```ts
const actionClient = createSafeActionClient({
defaultValidationErrorsShape: "flattened",
});
```
### Overriding per action [#overriding-per-action]
Override the shape for a specific action by passing a function to `inputSchema()`:
```ts
export const myAction = actionClient
.inputSchema(schema, {
handleValidationErrorsShape: (ve) => flattenValidationErrors(ve),
})
.action(async ({ parsedInput }) => { /* ... */ });
```
## Manually returning validation errors [#manually-returning-validation-errors]
Use `returnValidationErrors()` to return validation errors from your server code, for example when checking business logic that can't be expressed in a schema:
```ts
import { returnValidationErrors } from "next-safe-action";
export const signUp = actionClient
.inputSchema(z.object({
email: z.string().email(),
username: z.string().min(3),
}))
.action(async ({ parsedInput }) => {
// Check business logic
const emailExists = await db.user.findByEmail(parsedInput.email);
if (emailExists) {
return returnValidationErrors(schema, {
email: { _errors: ["Email already registered"] },
});
}
const usernameExists = await db.user.findByUsername(parsedInput.username);
if (usernameExists) {
return returnValidationErrors(schema, {
username: { _errors: ["Username taken"] },
});
}
// Create user...
});
```
`returnValidationErrors` actually **throws** internally, it never returns. This ensures the remaining server code doesn't execute. The error is caught by the action builder and returned as `validationErrors` in the result.
### Form-level errors [#form-level-errors]
Use `_errors` at the root level for errors that don't belong to a specific field:
```ts
return returnValidationErrors(schema, {
_errors: ["Invalid credentials"],
});
```
## Throwing validation errors [#throwing-validation-errors]
If you prefer throwing validation errors instead of returning them in the result, enable `throwValidationErrors`:
```ts
// Per action
export const myAction = actionClient
.action(async ({ parsedInput }, { throwValidationErrors }) => {
// This throws instead of returning in the result
throwValidationErrors(schema, {
email: { _errors: ["Already registered"] },
});
});
// Globally (all actions throw on validation failure)
const actionClient = createSafeActionClient({
throwValidationErrors: true,
});
```
## Utility functions [#utility-functions]
| Function | Description |
| ---------------------------------------- | -------------------------------------------------- |
| `flattenValidationErrors(ve)` | Convert formatted errors to flattened shape |
| `formatValidationErrors(ve)` | Identity function (returns formatted errors as-is) |
| `returnValidationErrors(schema, errors)` | Throw validation errors from server code |
See the [Validation Utilities API reference](/docs/api/validation-utilities) for full signatures.
## See also [#see-also]
* [Error handling](/docs/concepts/error-handling): the complete error taxonomy
* [Input validation](/docs/concepts/input-validation): how validation errors are generated
* [Error classes](/docs/api/error-classes): `ActionValidationError` and related classes
# Extend schemas (/docs/advanced/extend-schemas)
The `.inputSchema()` method can accept an **async function** instead of a direct schema. This function receives the previous schema (if any) as an argument, letting you build on top of it. This is useful for creating action templates that share a base schema.
## Basic usage [#basic-usage]
Pass an async function to `inputSchema()` that returns a new schema:
```ts title="src/lib/safe-action.ts"
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
// Template: all CRUD actions need an ID
const crudClient = actionClient
.inputSchema(z.object({ id: z.string().uuid() }));
// Extend: update action adds name and email
export const updateUser = crudClient
.inputSchema(async (prevSchema) => {
// prevSchema is z.object({ id: z.string().uuid() })
return prevSchema.extend({
name: z.string().min(2),
email: z.string().email(),
});
})
.action(async ({ parsedInput }) => {
// parsedInput is typed as { id: string, name: string, email: string }
});
```
## Multiple extensions [#multiple-extensions]
You can chain multiple `inputSchema()` calls, each building on the last:
```ts
const baseAction = actionClient
.inputSchema(z.object({ orgId: z.string() }));
const teamAction = baseAction
.inputSchema(async (prevSchema) => {
return prevSchema.extend({ teamId: z.string() });
});
const memberAction = teamAction
.inputSchema(async (prevSchema) => {
return prevSchema.extend({ memberId: z.string() });
})
.action(async ({ parsedInput }) => {
// parsedInput: { orgId: string, teamId: string, memberId: string }
});
```
The async function signature is key, as it lets you `await` other operations before returning the schema. This is useful for [i18n](/docs/advanced/i18n) where you need to load translations before building the schema.
## Use cases [#use-cases]
* **CRUD templates**: Define a base schema with an ID field, then extend for create/update
* **Multi-tenant apps**: Start with `orgId`, extend with resource-specific fields
* **i18n validation**: Await translations before defining error messages in schemas
## See also [#see-also]
* [Input validation](/docs/concepts/input-validation): foundational guide to schema validation
* [i18n](/docs/advanced/i18n): combine async schemas with translation loading
* [`.inputSchema()` method](/docs/api/safe-action-client#inputschema): API reference for the async factory signature
# File uploads (/docs/advanced/file-uploads)
You can upload files through safe actions using `FormData` input. This guide shows how to validate and process file uploads.
* Node.js >= 20 (for native `File` support)
* Next.js default body size limit is **1 MB**. For larger files, configure `bodyParser` in your route or use a separate upload endpoint.
### Install zod-form-data [#install-zod-form-data]
npm
pnpm
yarn
bun
```bash
npm install zod-form-data
```
```bash
pnpm add zod-form-data
```
```bash
yarn add zod-form-data
```
```bash
bun add zod-form-data
```
### Define the upload action [#define-the-upload-action]
Use `zod-form-data` to validate the `FormData` input, including file fields:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { zfd } from "zod-form-data";
import { actionClient } from "@/lib/safe-action";
const uploadSchema = zfd.formData({
name: zfd.text(z.string().min(1)),
file: zfd.file(z.instanceof(File).refine(
(f) => f.size < 1_000_000,
"File must be under 1MB"
)),
});
export const uploadFile = actionClient
.inputSchema(uploadSchema)
.action(async ({ parsedInput: { name, file } }) => {
// `file` is a validated File object
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Save to disk, S3, etc.
await saveFile(name, buffer);
return { fileName: file.name, size: file.size };
});
```
### Create the upload form [#create-the-upload-form]
```tsx title="src/app/upload.tsx"
"use client";
import { useAction } from "next-safe-action/hooks";
import { useRef } from "react";
import { uploadFile } from "./actions";
export default function UploadForm() {
const formRef = useRef(null);
const { execute, result, isExecuting } = useAction(uploadFile, {
onSuccess: ({ data }) => {
alert(`Uploaded ${data.fileName} (${data.size} bytes)`);
formRef.current?.reset();
},
});
return (
);
}
```
## Multiple files [#multiple-files]
For multiple file uploads, use an array schema:
```ts
const schema = zfd.formData({
files: zfd.repeatableOfType(
zfd.file(z.instanceof(File).refine((f) => f.size < 5_000_000, "Max 5MB per file"))
),
});
```
Remember the 1MB default body size limit in Next.js. For larger uploads, consider using presigned URLs (S3, Cloudflare R2) instead of sending files through Server Actions.
## See also [#see-also]
* [Form actions](/docs/guides/form-actions): using FormData with server actions
* [Hooks](/docs/guides/hooks): the `useAction` hook used in the upload form example
* [Input validation](/docs/concepts/input-validation): how `zod-form-data` integrates with Standard Schema
# Framework navigation/errors (/docs/advanced/framework-errors)
Next.js provides navigation functions like `redirect()`, `notFound()`, `forbidden()`, and `unauthorized()`. These work inside safe actions, and next-safe-action detects and handles them automatically.
## How framework errors work [#how-framework-errors-work]
When you call a navigation function inside an action, Next.js throws a special error internally. next-safe-action:
1. **Catches** the error and identifies it as a framework error
2. **Re-throws** it so Next.js can handle the actual navigation
3. **Notifies** hooks via `onNavigation` callback and `hasNavigated` status
```ts title="src/app/actions.ts"
"use server";
import { redirect } from "next/navigation";
import { actionClient } from "@/lib/safe-action";
export const loginAction = actionClient
.inputSchema(loginSchema)
.action(async ({ parsedInput }) => {
const user = await authenticate(parsedInput);
if (!user) {
// Return validation error, NOT a framework error
return returnValidationErrors(loginSchema, {
_errors: ["Invalid credentials"],
});
}
// This is a framework error: triggers navigation
redirect("/dashboard");
});
```
## Navigation kinds [#navigation-kinds]
Each navigation function maps to a `navigationKind`:
| Function | `navigationKind` | HTTP Status | Description |
| ---------------- | ---------------- | ----------- | -------------------------- |
| `redirect(url)` | `"redirect"` | 303/307/308 | Navigate to another page |
| `notFound()` | `"notFound"` | 404 | Show the not-found page |
| `forbidden()` | `"forbidden"` | 403 | Show the forbidden page |
| `unauthorized()` | `"unauthorized"` | 401 | Show the unauthorized page |
## Handling in hooks [#handling-in-hooks]
By default, `useAction` catches navigation errors and sets the status to `"hasNavigated"`, firing the `onNavigation` callback. The component stays mounted:
```tsx
const { execute, hasNavigated } = useAction(loginAction, {
onNavigation: ({ navigationKind }) => {
if (navigationKind === "redirect") {
// The user was redirected
} else if (navigationKind === "notFound") {
// The resource wasn't found
}
},
onSuccess: ({ data }) => {
// This does NOT fire when redirect() is called
// redirect is a navigation, not a success
},
});
```
### `throwOnNavigation: true` [#throwonnavigation-true]
Set `throwOnNavigation` to `true` to propagate navigation errors to the nearest error boundary. In Next.js, this shows the appropriate error page:
* `notFound()` shows the not-found page
* `forbidden()` shows the forbidden page
* `unauthorized()` shows the unauthorized page
* `redirect()` performs the redirect (via HTTP headers)
```tsx
// Navigation errors propagate to Next.js error boundaries
const { execute } = useAction(loginAction, {
throwOnNavigation: true,
});
```
When `throwOnNavigation` is `true`, `onNavigation` and `onSettled` callbacks are **not available**. TypeScript will prevent you from passing them. This is because they cannot execute: see [why callbacks can't fire](#why-callbacks-cant-fire-with-throwonnavigation-true) below.
When `redirect()` is called in an action, `onSuccess` does **not** fire regardless of `throwOnNavigation`. If your action redirects after success, put cleanup logic in `onNavigation` or `onSettled` (with default `throwOnNavigation`), or use server-side action callbacks.
### Why callbacks can't fire with `throwOnNavigation: true` [#why-callbacks-cant-fire-with-throwonnavigation-true]
When `throwOnNavigation` is `true`, the navigation error is thrown during React's **render phase** to reach the error boundary. This is a fundamental constraint of React's rendering model, not a library limitation:
1. **React's render must be pure.** The render phase is where React calls your component function. It must be synchronous and side-effect free.
2. **Effects only run after commit.** `useEffect` and `useLayoutEffect` callbacks are scheduled during render but only execute during the **commit phase**, after React has successfully rendered the component.
3. **A render-phase throw prevents commit.** When a component throws during render, React never reaches the commit phase for that component. All scheduled effects are discarded.
4. **The error boundary catches the throw.** React propagates the error to the nearest error boundary, which unmounts the throwing component and renders its fallback.
Since `onNavigation` and `onSettled` are fired via `useEffect`, they can never execute when the render throws. There is no React API that allows committed side effects from a component whose render fails.
**For side effects that must run on navigation** (analytics, logging, cleanup), use server-side action callbacks instead. These run on the server before the error reaches the client, and are guaranteed to complete:
```ts
export const myAction = actionClient
.inputSchema(schema)
.action(
async ({ parsedInput }) => {
notFound();
},
{
onNavigation: async ({ navigationKind }) => {
// Guaranteed to run on the server
await analytics.track("navigation", { navigationKind });
},
}
);
```
## Handling in action callbacks [#handling-in-action-callbacks]
Framework navigation/errors also work with server-side action callbacks:
```ts
export const myAction = actionClient
.inputSchema(schema)
.action(
async ({ parsedInput }) => {
redirect("/somewhere");
},
{
onNavigation: ({ navigationKind }) => {
// Runs on the server after navigation is triggered
console.log("Action navigated:", navigationKind);
},
}
);
```
## Direct execution [#direct-execution]
When calling actions directly (not via hooks), framework errors cause the navigation to happen on the server. The function call never returns a result:
```tsx
const result = await loginAction(input);
// If redirect() was called, this line never executes
// The browser navigates to the redirect URL instead
```
## See also [#see-also]
* [Error handling](/docs/concepts/error-handling): where framework errors fit in the error taxonomy
* [Hooks](/docs/guides/hooks): `onNavigation` callback and `hasNavigated` status
* [ActionCallbacks](/docs/api/safe-action-client#actioncallbacks-action-callbacks): server-side `onNavigation` callback
# Internationalization (i18n) (/docs/advanced/i18n)
next-safe-action supports async schema functions, which lets you load translations before building your validation schema. This means validation error messages can be in the user's language.
## How it works [#how-it-works]
Instead of passing a schema directly to `inputSchema()`, pass an **async function** that returns a schema. This function runs on the server before validation, giving you a chance to load translations:
### Set up the async schema [#set-up-the-async-schema]
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
import { getTranslations } from "@/lib/i18n";
export const createUser = actionClient
.inputSchema(async () => {
// Load translations for the current locale
const t = await getTranslations("validation");
return z.object({
name: z.string().min(2, t("name.tooShort")),
email: z.string().email(t("email.invalid")),
age: z.number().min(18, t("age.tooYoung")),
});
})
.action(async ({ parsedInput }) => {
// parsedInput is typed based on the returned schema
await db.user.create({ data: parsedInput });
});
```
### Use with your i18n library [#use-with-your-i18n-library]
This works with any i18n solution, including `next-intl`, `i18next`, `react-i18next`, custom solutions, etc. The only requirement is that the translation function is available server-side:
```ts
// next-intl example
import { getTranslations } from "next-intl/server";
.inputSchema(async () => {
const t = await getTranslations("validation");
return z.object({
name: z.string().min(2, t("name.min")),
});
})
```
The async function runs **every time** the action is called, so it always uses the current locale. This is important because the locale might change between calls.
## Combining with schema extension [#combining-with-schema-extension]
You can combine i18n with [schema extension](/docs/advanced/extend-schemas) to build on a base schema:
```ts
const baseClient = actionClient
.inputSchema(z.object({ orgId: z.string() }));
export const createTeam = baseClient
.inputSchema(async (prevSchema) => {
const t = await getTranslations("validation");
return prevSchema.extend({
teamName: z.string().min(3, t("teamName.tooShort")),
});
})
.action(async ({ parsedInput }) => {
// parsedInput: { orgId: string, teamName: string }
});
```
## See also [#see-also]
* [Extend schemas](/docs/advanced/extend-schemas): the async schema factory pattern used by i18n
* [Input validation](/docs/concepts/input-validation): foundational guide to schema validation
* [Standard Schema](/docs/integrations/standard-schema): validation libraries compatible with i18n
# Metadata (/docs/advanced/metadata)
Metadata lets you attach **type-safe data** to each action that's accessible in middleware. This is useful for logging, permission checks, rate limiting, and any cross-cutting concern that needs to know *which* action is running.
## Setup [#setup]
### Define a metadata schema [#define-a-metadata-schema]
Tell the client what shape metadata should have:
```ts title="src/lib/safe-action.ts"
import { z } from "zod";
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient({
defineMetadataSchema() {
return z.object({
actionName: z.string(),
requiredRole: z.enum(["user", "admin"]).optional(),
});
},
});
```
### Set metadata on each action [#set-metadata-on-each-action]
Once a metadata schema is defined, `.metadata()` must be called before `.action()`:
```ts title="src/app/actions.ts"
"use server";
export const deleteUser = actionClient
.metadata({ actionName: "deleteUser", requiredRole: "admin" })
.inputSchema(z.object({ userId: z.string() }))
.action(async ({ parsedInput }) => {
await db.user.delete({ where: { id: parsedInput.userId } });
});
```
TypeScript enforces the metadata shape. If you forget a required field or use the wrong type, you get a compile error.
### Access metadata in middleware [#access-metadata-in-middleware]
Metadata is available in every middleware function:
```ts title="src/lib/safe-action.ts"
export const actionClient = createSafeActionClient({
defineMetadataSchema() {
return z.object({
actionName: z.string(),
requiredRole: z.enum(["user", "admin"]).optional(),
});
},
}).use(async ({ next, metadata }) => {
// Log which action is running
console.log(`Running action: ${metadata.actionName}`);
return next();
}).use(async ({ next, metadata, ctx }) => {
// Check permissions based on metadata
if (metadata.requiredRole && ctx.user.role !== metadata.requiredRole) {
throw new Error(`Requires ${metadata.requiredRole} role`);
}
return next();
});
```
## Use cases [#use-cases]
| Use case | Metadata fields |
| ------------- | ------------------------------------- |
| Logging | `actionName`, `category` |
| Permissions | `requiredRole`, `requiredPermissions` |
| Rate limiting | `rateLimit`, `rateLimitWindow` |
| Feature flags | `feature`, `experimentGroup` |
| Analytics | `trackingEvent`, `source` |
Metadata is validated at runtime using the schema you define. If invalid metadata is passed, the action fails with a metadata validation error before any middleware runs.
## See also [#see-also]
* [Middleware](/docs/guides/middleware): where metadata is most commonly used
* [`defineMetadataSchema`](/docs/api/create-safe-action-client#parameters): client-level metadata schema configuration
* [`.metadata()` method](/docs/api/safe-action-client#metadata): API reference
* [`ActionMetadataValidationError`](/docs/api/error-classes#actionmetadatavalidationerror): error thrown on invalid metadata
# Output validation (/docs/advanced/output-validation)
By default, the return type of your server code is inferred by TypeScript. But if you want **runtime validation** of the output, ensuring your server code returns exactly the expected shape, use `outputSchema()`.
## Why validate output? [#why-validate-output]
* **Contract enforcement**: Guarantee the shape of data sent to the client, even as server code evolves
* **Type safety from schema**: The `data` type in the result comes from the output schema, not the function return type
* **Defense in depth**: Catch bugs where server code accidentally returns extra or missing fields
## Usage [#usage]
### Define an output schema [#define-an-output-schema]
Chain `.outputSchema()` before `.action()`:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
const outputSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
export const getUser = actionClient
.inputSchema(z.object({ userId: z.string() }))
.outputSchema(outputSchema)
.action(async ({ parsedInput }) => {
const user = await db.user.findUnique({ where: { id: parsedInput.userId } });
// TypeScript ensures this matches outputSchema
return { id: user.id, name: user.name, email: user.email };
});
```
### Handle output validation errors [#handle-output-validation-errors]
If your server code returns data that doesn't match the output schema, the action fails with a server error (not a validation error, since output issues are server-side bugs):
```ts
// If the server code returns { id: 123, name: "Alice" } (missing email),
// the action returns:
// { serverError: "Something went wrong" }
```
The actual `ActionOutputDataValidationError` is caught by `handleServerError`, so you can log it:
```ts
const actionClient = createSafeActionClient({
handleServerError(e) {
if (e.constructor.name === "ActionOutputDataValidationError") {
console.error("Output validation failed:", e.message);
}
return "Something went wrong";
},
});
```
Schema **transforms and defaults are applied**: just like input validation, the returned `data` (and the `data` received by server-side `onSuccess`/`onSettled` callbacks) is the parsed output of the schema, not the raw return value. A Zod `.transform()` or `.default()` in the output schema takes effect on the client.
Output validation is optional and most useful for API-like actions where the contract between server and client must be strict. For internal UI actions, TypeScript inference is usually sufficient.
## See also [#see-also]
* [Input validation](/docs/concepts/input-validation): validate what goes *in* to the action
* [`.outputSchema()` method](/docs/api/safe-action-client#outputschema): API reference
* [`ActionOutputDataValidationError`](/docs/api/error-classes#actionoutputdatavalidationerror): error class thrown on output mismatch
# Standalone middleware (/docs/advanced/standalone-middleware)
The `createMiddleware()` helper lets you define middleware functions **outside** of a client, with proper type constraints. This is useful for sharing middleware across multiple clients or publishing middleware as a package.
## Why standalone middleware? [#why-standalone-middleware]
When you define middleware inline with `.use()`, the types are inferred from the client chain. But if you want to define middleware in a separate file, or share it across clients, you need a way to declare the type requirements upfront. That's what `createMiddleware()` does.
## Usage [#usage]
```ts title="src/lib/middleware/logging.ts"
import { createMiddleware } from "next-safe-action";
export const loggingMiddleware = createMiddleware().define(async ({ next, metadata }) => {
const start = Date.now();
const result = await next();
console.log(`Action took ${Date.now() - start}ms`, metadata);
return result;
});
```
Then use it with any client:
```ts title="src/lib/safe-action.ts"
import { createSafeActionClient } from "next-safe-action";
import { loggingMiddleware } from "./middleware/logging";
export const actionClient = createSafeActionClient().use(loggingMiddleware);
```
## Type constraints [#type-constraints]
If your middleware needs specific properties on the context, server error, or metadata, declare them as a generic:
```ts title="src/lib/middleware/auth-guard.ts"
import { createMiddleware } from "next-safe-action";
// This middleware requires ctx to have a `user` property
export const adminGuard = createMiddleware<{
ctx: { user: { role: string } };
}>().define(async ({ next, ctx }) => {
if (ctx.user.role !== "admin") {
throw new Error("Admin access required");
}
return next();
});
```
Now TypeScript will error if you try to use `adminGuard` on a client that doesn't have `user` in its context:
```ts
// ✅ Works: authClient has ctx.user from auth middleware
const adminClient = authClient.use(adminGuard);
// ❌ Type error: actionClient doesn't have ctx.user
const broken = actionClient.use(adminGuard);
```
## Available constraints [#available-constraints]
The generic parameter accepts these optional properties:
| Property | Type | Description |
| ------------- | ----------------- | ----------------------------- |
| `ctx` | `object` | Required shape of the context |
| `metadata` | `Schema` | Required shape of metadata |
| `serverError` | `string \| Error` | Required server error type |
```ts
createMiddleware<{
ctx: { user: { id: string; role: string } };
metadata: { actionName: string };
}>().define(async ({ next, ctx, metadata }) => {
// ctx.user and metadata.actionName are typed
return next();
});
```
The constraint defines the **minimum** required shape. The actual context/metadata can have additional properties, the middleware just needs these to be present.
## Standalone validated middleware [#standalone-validated-middleware]
The `createValidatedMiddleware()` helper works like `createMiddleware()`, but for middleware that runs **after** input validation via `useValidated()`. It lets you declare type constraints for `parsedInput`, `clientInput`, and bind args in addition to `ctx` and `metadata`.
### Why standalone validated middleware? [#why-standalone-validated-middleware]
Just as `createMiddleware()` lets you share pre-validation middleware across clients, `createValidatedMiddleware()` lets you share post-validation middleware. This is especially useful for authorization patterns that depend on input shape.
### Usage [#usage-1]
```ts title="src/lib/middleware/check-ownership.ts"
import { createValidatedMiddleware } from "next-safe-action";
export const checkOwnership = createValidatedMiddleware<{
ctx: { user: { id: string } };
parsedInput: { resourceId: string };
}>().define(async ({ parsedInput, ctx, next }) => {
const resource = await db.resource.findUnique({
where: { id: parsedInput.resourceId },
});
if (resource?.ownerId !== ctx.user.id) {
throw new Error("Not authorized");
}
return next({ ctx: { resource } });
});
```
Then use it with any client that has the required context and schema:
```ts title="src/app/actions.ts"
import { checkOwnership } from "@/lib/middleware/check-ownership";
export const updateResource = authClient
.inputSchema(z.object({ resourceId: z.string(), title: z.string() }))
.useValidated(checkOwnership)
.action(async ({ ctx }) => {
// ctx.resource is typed and available
});
```
### Available constraints [#available-constraints-1]
The generic parameter accepts all properties from `createMiddleware()` plus:
| Property | Type | Description |
| ---------------------- | -------------------- | ------------------------------------- |
| `ctx` | `object` | Required shape of the context |
| `metadata` | `Schema` | Required shape of metadata |
| `serverError` | `string \| Error` | Required server error type |
| `parsedInput` | `unknown` | Required shape of validated input |
| `clientInput` | `unknown` | Required shape of raw client input |
| `bindArgsParsedInputs` | `readonly unknown[]` | Required shape of validated bind args |
| `bindArgsClientInputs` | `readonly unknown[]` | Required shape of raw bind args |
The constraint defines the **minimum** required shape. The actual input can have additional properties: the middleware just needs these to be present.
## See also [#see-also]
* [Middleware guide](/docs/guides/middleware): foundational middleware concepts and patterns
* [`createMiddleware()` API](/docs/api/create-middleware): full API reference
* [`createValidatedMiddleware()` API](/docs/api/create-validated-middleware): full API reference for validated standalone middleware
* [Validated middleware guide](/docs/guides/middleware#validated-middleware): when and how to use `useValidated()`
* [Metadata](/docs/advanced/metadata): use metadata constraints in standalone middleware
# Action client (/docs/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 [#creating-a-client]
```ts title="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](/docs/api/create-safe-action-client) for the full list.
### Common options [#common-options]
```ts title="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 [#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:
```ts
// 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 [#client-hierarchy]
In real applications, you typically create a tree of clients with increasingly specific middleware:
```ts title="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:
```ts title="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 [#chainable-methods]
The client provides these methods, each returning a new instance:
| Method | Purpose |
| --------------------------- | --------------------------------------------------------------------------------------- |
| `.use(middleware)` | Add a pre-validation middleware function |
| `.metadata(data)` | Set action metadata (requires `defineMetadataSchema` in client options) |
| `.inputSchema(schema)` | Define input validation schema |
| `.useValidated(middleware)` | Add a post-validation middleware function (requires `inputSchema` or `bindArgsSchemas`) |
| `.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`) |
`.useValidated()` is only available after `.inputSchema()` or `.bindArgsSchemas()`. It adds middleware that runs after input validation with access to typed `parsedInput`. See the [middleware guide](/docs/guides/middleware#validated-middleware) for details.
See the [API reference](/docs/api/safe-action-client) for detailed signatures and parameters.
## What's next? [#whats-next]
Learn how Standard Schema validation works with your actions.
Deep dive into writing and composing middleware functions.
# Action result (/docs/concepts/action-result)
Every safe action returns a **result object**, a structured response that tells you exactly what happened. It always has the same shape, making error handling predictable and type-safe.
## Result structure [#result-structure]
The result is a **discriminated union**. At most one of `data`, `validationErrors`, or `serverError` is populated, and TypeScript enforces this at the type level — checking one field narrows the others to `undefined`:
```ts
type SafeActionResult =
| { data?: undefined; serverError?: undefined; validationErrors?: undefined } // idle / framework navigation
| { data: Data; serverError?: undefined; validationErrors?: undefined } // success
| { data?: undefined; serverError: ServerError; validationErrors?: undefined } // server error
| { data?: undefined; serverError?: undefined; validationErrors: ShapedErrors }; // validation failure
```
The four branches correspond to these outcomes:
| Outcome | `data` | `validationErrors` | `serverError` |
| --------------------------- | ------- | ------------------ | ------------- |
| Success | Present | - | - |
| Validation failure | - | Present | - |
| Server error | - | - | Present |
| Idle / framework navigation | - | - | - |
Framework navigation/errors (like `redirect()` or `notFound()`) are a special case. They don't populate any of the three fields. On the server the error is re-thrown so Next.js can handle the navigation; on the client, the hook exposes the idle branch `{}` together with a `"hasNavigated"` status. See [Error Handling](/docs/concepts/error-handling) for details.
## `data` [#data]
When your server code completes successfully, `data` contains whatever you returned:
```ts
export const greetUser = actionClient
.inputSchema(z.object({ name: z.string() }))
.action(async ({ parsedInput }) => {
return { greeting: `Hello, ${parsedInput.name}!` };
});
// On the client:
const result = await greetUser({ name: "Alice" });
// result.data → { greeting: "Hello, Alice!" }
// Type: { greeting: string } | undefined
```
The `data` type is **inferred** from your server code's return type. If you define an `outputSchema`, the type comes from the schema instead.
If your server code doesn't return anything, `result.data` is typed as exactly `undefined` rather than `void | undefined`. The runtime never emits a separate `{ data: undefined }` for void-returning actions (it returns `{}`), and the types mirror that: the idle and void-success branches collapse into a single shape. Discriminate success for these actions by checking the absence of errors instead of truthy `data`.
## `validationErrors` [#validationerrors]
When the input doesn't match the schema, `validationErrors` contains the validation failures. Your server code **never runs** in this case:
```ts
export const createUser = actionClient
.inputSchema(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.action(async ({ parsedInput }) => {
// This code doesn't run if validation fails
});
// On the client (with invalid input):
const result = await createUser({ name: "", email: "bad" });
// result.validationErrors → {
// name: { _errors: ["String must contain at least 2 character(s)"] },
// email: { _errors: ["Invalid email"] }
// }
```
The shape of `validationErrors` depends on the configured error shape (formatted or flattened). See [Input Validation](/docs/concepts/input-validation) for details.
You can also **manually return** validation errors from your server code using `returnValidationErrors()`:
```ts
import { returnValidationErrors } from "next-safe-action";
export const createUser = actionClient
.inputSchema(z.object({ email: z.string().email() }))
.action(async ({ parsedInput }) => {
const exists = await db.user.findByEmail(parsedInput.email);
if (exists) {
// Return a validation error for the email field
return returnValidationErrors(inputSchema, {
email: { _errors: ["Email already taken"] },
});
}
return { id: "123" };
});
```
## `serverError` [#servererror]
When an unexpected error is thrown in your server code or middleware, `serverError` contains the error message:
```ts
export const riskyAction = actionClient
.action(async () => {
throw new Error("Database connection failed");
});
// On the client:
const result = await riskyAction();
// result.serverError → "Something went wrong" (default message)
```
By default, the actual error message is **not** sent to the client, only the generic `"Something went wrong"` string. This prevents sensitive information from leaking. Customize this with `handleServerError` in [client options](/docs/api/create-safe-action-client).
For **expected** business errors ("out of stock", "not found"), you can populate `serverError` with a typed value of your choice using [`returnServerError()`](/docs/concepts/error-handling#expected-server-errors-with-returnservererror), which bypasses `handleServerError` entirely.
## Reading the result [#reading-the-result]
A typical pattern for handling all cases:
```ts
const result = await myAction(input);
if (result.data) {
// TypeScript knows serverError and validationErrors are undefined here
console.log(result.data);
} else if (result.validationErrors) {
// TypeScript knows data and serverError are undefined here
console.log(result.validationErrors);
} else if (result.serverError) {
// TypeScript knows data and validationErrors are undefined here
console.log(result.serverError);
}
```
Destructuring works the same way — checking any one of the three variables narrows the other two:
```ts
const { data, serverError, validationErrors } = await myAction(input);
if (data) {
// serverError and validationErrors are `undefined`
}
if (serverError) {
// data and validationErrors are `undefined`
}
```
### In hooks [#in-hooks]
With the `useAction` hook (and `useOptimisticAction`, `useStateAction`), the return object is itself a **discriminated union keyed on `status`** and shorthand booleans. Checking any discriminant narrows `result` to the matching branch:
```ts
const action = useAction(myAction);
// Status-based narrowing
if (action.hasSucceeded) {
action.result.data; // Data (guaranteed)
action.result.serverError; // undefined
action.result.validationErrors; // undefined
}
if (action.hasErrored) {
action.result.data; // undefined
// Further narrow between error kinds:
if (action.result.serverError) { /* ... */ }
if (action.result.validationErrors) { /* ... */ }
}
```
Lifecycle callbacks also benefit from narrowing:
```ts
const { execute } = useAction(myAction, {
onSuccess: ({ data }) => { /* data is guaranteed here */ },
onError: ({ error }) => {
if (error.validationErrors) { /* ... */ }
if (error.serverError) { /* ... */ }
},
});
```
See the [Type narrowing](/docs/guides/hooks#type-narrowing) section for more patterns.
**Precedence for edge cases.** In rare situations where the runtime could otherwise produce multiple populated fields (e.g. invalid bind args combined with invalid main input, or middleware that throws after `await next()` has already received a validation-error result), next-safe-action applies a fixed precedence: `validationErrors` > `serverError` > `data`. The higher-priority state fully describes the outcome; lower-priority state is discarded. This keeps the discriminated union honest at runtime. See [Error precedence](/docs/concepts/error-handling#error-precedence) for the exact behavior, including how `handleServerError` and `throwServerError` interact with this rule.
If your action uses `bindArgsSchemas()`, validation errors for bound arguments appear in `validationErrors` as well. They're keyed by the argument position in the tuple:
```ts
export const updateItem = authClient
.bindArgsSchemas([z.string().uuid()])
.inputSchema(z.object({ name: z.string() }))
.action(async ({ parsedInput, bindArgsParsedInputs: [itemId] }) => {
// ...
});
// Bound with an invalid UUID:
const boundAction = updateItem.bind(null, "not-a-uuid");
const result = await boundAction({ name: "test" });
// result.validationErrors includes bind args errors
```
See [Bind Arguments](/docs/advanced/bind-arguments) for more details.
## What's next? [#whats-next]
Learn the full error taxonomy: validation, server, and framework errors.
Use useAction for reactive result handling with callbacks and status tracking.
# Error handling (/docs/concepts/error-handling)
next-safe-action has three categories of errors, each handled differently:
## Validation errors [#validation-errors]
Validation errors occur when the client sends data that doesn't match the input schema. They're **returned** in the result object (not thrown) and are always safe to show to the user:
```ts
const result = await createUser({ name: "", email: "bad" });
result.validationErrors;
// → { name: { _errors: ["Too short"] }, email: { _errors: ["Invalid email"] } }
```
Validation errors are produced in two ways:
1. **Automatically**: when Standard Schema validation fails on input or bind args
2. **Manually**: when you call `returnValidationErrors()` in your server code (e.g., "email already taken")
```ts
import { returnValidationErrors } from "next-safe-action";
export const signUp = actionClient
.inputSchema(schema)
.action(async ({ parsedInput }) => {
const exists = await db.user.findByEmail(parsedInput.email);
if (exists) {
return returnValidationErrors(schema, {
email: { _errors: ["Already registered"] },
});
}
// ...
});
```
`returnValidationErrors` actually **throws** internally (it never returns), which ensures the remaining server code doesn't execute.
See [Input Validation](/docs/concepts/input-validation) for error shapes and [Custom Validation Errors](/docs/advanced/custom-validation-errors) for advanced formatting.
## Server errors [#server-errors]
Server errors are **unexpected failures**, such as database timeouts, API failures, or bugs in your server code. By default, next-safe-action:
1. Catches the thrown error
2. Passes it to `handleServerError` (which you define in client options)
3. Returns the handler's return value as `result.serverError`
```ts
const actionClient = createSafeActionClient({
handleServerError(e) {
// This runs when any action throws an unexpected error
console.error("Action error:", e.message);
// What you return here becomes result.serverError on the client
// Default: "Something went wrong"
return e.message;
},
});
```
**Security**: The default `handleServerError` returns a generic `"Something went wrong"` message. If you return `e.message`, make sure your errors don't contain sensitive information (stack traces, database queries, etc.).
### Expected server errors with `returnServerError` [#expected-server-errors-with-returnservererror]
Not every server error is unexpected. For known business failures ("out of stock", "not found", "quota exceeded"), you can return a typed server error to the client with `returnServerError()`. The value is set as `result.serverError` as-is, **bypassing `handleServerError`**:
```ts
import { returnServerError } from "next-safe-action";
export const buyProduct = actionClient
.inputSchema(schema)
.action(async ({ parsedInput }) => {
const product = await db.product.find(parsedInput.id);
if (!product.inStock) {
returnServerError({ code: "OUT_OF_STOCK", message: "This product is sold out" });
}
// ...
});
```
A few things to keep in mind:
* Like `returnValidationErrors`, it **throws** internally (it never returns), so the remaining server code doesn't execute. It also works from middleware.
* The value should conform to the client's `ServerError` type, which is inferred from `handleServerError`'s return type. See [Typing the error payload](#typing-the-error-payload) below.
* The value must be **JSON-serializable** (no circular references, BigInts, functions, etc.), since it crosses the server/client boundary. This also makes it work inside `"use cache"` scopes when `cacheComponents` is enabled. Non-serializable payloads fail loudly with a `TypeError` on the server (handled by `handleServerError` like any unexpected error), instead of silently degrading.
#### Typing the error payload [#typing-the-error-payload]
`returnServerError` is generic (`returnServerError(serverError: SE): never`) and `SE` is inferred from the argument, so there's no automatic type-level link between the payload you pass and the client's `ServerError` type. Strictness is opt-in, via three complementary techniques.
**1. Declare the error union on `handleServerError`.** The client's `ServerError` type is inferred from `handleServerError`'s return type, so this is what types `result.serverError` on the client:
```ts
type AppServerError =
| { code: "INTERNAL"; message: string }
| { code: "OUT_OF_STOCK"; message: string }
| { code: "NOT_FOUND"; message: string };
const actionClient = createSafeActionClient({
handleServerError: (e): AppServerError => ({ code: "INTERNAL", message: e.message }),
});
```
On the client, `result.serverError` is now `AppServerError | undefined`, and `if (serverError?.code === "OUT_OF_STOCK")` narrows as a normal discriminated union.
**2. Enforce the payload at the call site.** Either pass the generic explicitly, or use `satisfies`:
```ts
returnServerError({ code: "OUT_OF_STOCK", message: "This product is sold out" });
// or
returnServerError({ code: "OUT_OF_STOCK", message: "This product is sold out" } satisfies AppServerError);
```
Both make typos in `code` or missing fields a compile error.
**3. Recommended: export a typed alias next to your action client.** One line gives every call site full checking with zero annotation noise:
```ts title="lib/safe-action.ts"
export const returnAppError: (e: AppServerError) => never = returnServerError;
```
Without one of these techniques, `returnServerError({ anything: true })` compiles even though `result.serverError` claims to be the client's `ServerError` type. This is an inherent limit of a standalone helper: prefer the typed alias (technique 3) as your app's default pattern.
### Throwing errors instead of returning them [#throwing-errors-instead-of-returning-them]
By default, validation and server errors are **returned** in the result object. You can opt into **throwing** them instead using boolean flags, which is useful when you want errors to propagate to an error boundary or a try/catch block.
**`throwServerError`**: action-level only. When enabled, server errors are re-thrown instead of being returned in `result.serverError`:
```ts
export const myAction = actionClient
.inputSchema(schema)
.action(
async ({ parsedInput }) => {
// ...
},
{
throwServerError: true,
}
);
```
**`throwValidationErrors`**: available at both client and action level. When set at both levels, the action-level setting takes priority:
```ts
// Client-level: applies to all actions created from this client
const actionClient = createSafeActionClient({
throwValidationErrors: true,
});
// Action-level: overrides the client setting for this specific action
export const myAction = actionClient
.inputSchema(schema)
.action(
async ({ parsedInput }) => {
// ...
},
{
throwValidationErrors: true,
}
);
```
These flags control whether the **final** error is returned in the result object or thrown. They don't replace `handleServerError` (which still processes server errors before the throw/return decision) or `returnValidationErrors()` (which is a function for manually producing validation errors in your server code).
## Framework navigation/errors [#framework-navigationerrors]
Framework navigation/errors are **navigation events** triggered by Next.js functions like `redirect()`, `notFound()`, `forbidden()`, and `unauthorized()`. These are special because they're not really "errors", but control flow mechanisms that Next.js uses to trigger navigation.
next-safe-action detects and handles these automatically:
```ts
import { redirect } from "next/navigation";
export const loginAction = actionClient
.inputSchema(loginSchema)
.action(async ({ parsedInput }) => {
const user = await authenticate(parsedInput);
// This triggers a redirect, not a normal return
redirect("/dashboard");
});
```
### What happens with framework errors [#what-happens-with-framework-errors]
1. **On the server**: The error is caught, identified as a framework error, and re-thrown so Next.js can handle the navigation
2. **On the client**: If using `useAction`, the hook detects the navigation and sets `status` to `"hasNavigated"` (instead of `"hasSucceeded"` or `"hasErrored"`)
3. **Callbacks**: The `onNavigation` callback fires (instead of `onSuccess` or `onError`)
### Navigation kinds [#navigation-kinds]
The `navigationKind` property tells you what type of navigation occurred:
| Function | `navigationKind` |
| ---------------- | ---------------- |
| `redirect()` | `"redirect"` |
| `notFound()` | `"notFound"` |
| `forbidden()` | `"forbidden"` |
| `unauthorized()` | `"unauthorized"` |
```ts
const { execute } = useAction(myAction, {
onNavigation: ({ navigationKind }) => {
if (navigationKind === "redirect") {
// Action triggered a redirect
}
},
});
```
See [Framework Errors](/docs/advanced/framework-errors) for advanced configuration.
## Error precedence [#error-precedence]
The action result is a discriminated union: at most one of `data`, `serverError`, and `validationErrors` is populated at a time. In most executions only one field is ever set, but a few compound scenarios can reach the final result-building step with multiple candidates:
* Invalid bind args (wrapped as a server error) combined with invalid main input (validation errors).
* Middleware that calls `await next()`, receives a result that already contains `validationErrors`, then throws afterwards (for example, a post-validation audit/cleanup step that fails).
In these cases next-safe-action applies a fixed precedence when assembling the returned result:
```
validationErrors > serverError > data
```
The winning field fully describes the outcome; the lower-priority field is dropped from the returned object. For the example above where middleware throws after receiving validation errors, the client receives only the validation errors, and the thrown server-side error is not present in the result.
### What still runs for dropped server errors [#what-still-runs-for-dropped-server-errors]
The precedence rule only affects what appears in the **returned result**, not whether `handleServerError` executes:
1. When middleware or server code throws, `handleServerError` is invoked immediately with the thrown error, regardless of any pre-existing `validationErrors`.
2. Only after `handleServerError` has run does the result-building step apply precedence and decide which field to keep.
This means logging, telemetry, Sentry integrations, and any other side effects you configure inside `handleServerError` always fire for thrown errors, even when the eventual result carries `validationErrors` instead of `serverError`. Treat `handleServerError` as the authoritative place for server-side observability; the returned result is the client-facing summary, not a full event log.
### Interaction with `throwServerError` [#interaction-with-throwservererror]
The `throwServerError` action-level flag is only consulted when no `validationErrors` are present. If the compound case produces both, the validation errors take precedence and `throwServerError` does not fire:
```ts
export const myAction = actionClient
.inputSchema(schema)
.use(async ({ next }) => {
const result = await next();
if (result.validationErrors) {
// This throw is caught, passed to `handleServerError`,
// and its return value is assigned to `middlewareResult.serverError`
// but the returned result still surfaces `validationErrors`.
throw new Error("audit cleanup failed");
}
return result;
})
.action(
async () => {
// ...
},
{ throwServerError: true } // NOT re-thrown in the compound case above
);
```
If you need to guarantee that an operational failure propagates even in compound cases, raise it from `handleServerError` yourself (for example by logging and then rethrowing in paths you want to hard-fail), or gate the post-`next()` side effects on `result.validationErrors === undefined` so they don't run when validation has already failed.
## Error handling summary [#error-handling-summary]
| Error type | How it's produced | Where it appears | Safe for users? |
| ------------------- | -------------------------------------------------- | ------------------------- | ------------------------------ |
| Validation | Schema parse failure or `returnValidationErrors()` | `result.validationErrors` | Yes |
| Server (unexpected) | Thrown error in server code/middleware | `result.serverError` | Depends on `handleServerError` |
| Server (expected) | `returnServerError()` | `result.serverError` | Yes (you control the value) |
| Framework | `redirect()`, `notFound()`, etc. | Navigation occurs | N/A (handled by Next.js) |
## What's next? [#whats-next]
Customize error shapes, flatten errors, and manually return validation errors.
Advanced configuration for redirect, notFound, forbidden, and unauthorized.
# How it works (/docs/concepts/how-it-works)
## The action lifecycle [#the-action-lifecycle]
When you call a safe action, it goes through a well-defined pipeline on the server before returning a result to the client:
1. **Client Call**: the action function is called from a Client Component (directly, via `useAction`, or via a form)
2. **Middleware**: the `use()` middleware stack runs in order, each layer can short-circuit or extend the context
3. **Validate Input**: the raw client input is parsed and validated against the input schema via [Standard Schema](https://github.com/standard-schema/standard-schema)
4. **Validated Middleware**: if validation succeeded, the `useValidated()` middleware stack runs with typed `parsedInput` and accumulated `ctx`. Skipped entirely if validation fails.
5. **Server Code**: your async function runs with the validated `parsedInput` and accumulated `ctx`
6. **Validate Output**: if an `outputSchema` is defined, the return value is validated before being sent to the client
If any step fails, the pipeline stops and returns an appropriate error in the [action result](/docs/concepts/action-result).
## Server vs client [#server-vs-client]
next-safe-action has three entry points, each designed for a specific environment:
| Entry point | Environment | Purpose |
| --------------------------------- | ----------- | ---------------------------------------------------------------------------------- |
| `next-safe-action` | Server only | Define actions with `createSafeActionClient`, middleware, validation |
| `next-safe-action/hooks` | Client only | `useAction`, `useOptimisticAction`, and `useStateAction` hooks |
| `next-safe-action/stateful-hooks` | Client only | Re-exports `useStateAction` from `next-safe-action/hooks` (backward compatibility) |
Server code (your action function) **never runs on the client**. Next.js ensures this by replacing the server function with a network call when the `"use server"` directive is present.
## The middleware pipeline [#the-middleware-pipeline]
Middleware functions wrap your server code like layers of an onion. Each middleware can run code **before** calling `next()`, and **after** the inner layers complete:
```ts title="Example: Two middleware layers"
const actionClient = createSafeActionClient()
// Layer 1: Logging
.use(async ({ next }) => {
const start = Date.now();
const result = await next();
console.log(`Action took ${Date.now() - start}ms`);
return result;
})
// Layer 2: Auth
.use(async ({ next }) => {
const session = await getSession();
if (!session) throw new Error("Unauthorized");
return next({ ctx: { user: session.user } });
});
```
Key concepts:
* **`next()`** calls the next layer (or the server code if there are no more middleware)
* **`ctx`** (context) accumulates through the chain, each middleware can add to it via `next({ ctx: { ... } })`
* **Short-circuiting**: throwing an error in middleware stops the pipeline and returns a server error
* The returned result flows **outward** through each middleware, so logging middleware can measure total time
Learn more in the [Middleware guide](/docs/guides/middleware).
Most middleware should use `use()`, which covers authentication, logging, rate limiting, and context enrichment. If you specifically need middleware that runs **after** input validation with access to typed `parsedInput`, see [validated middleware](/docs/guides/middleware#validated-middleware) (the `useValidated()` method).
## How actions are defined [#how-actions-are-defined]
Every action starts from a **safe action client**, a builder object that accumulates configuration:
```ts
const myAction = actionClient // 1. Start from client
.use(authMiddleware) // 2. Add middleware (optional)
.metadata({ role: "admin" }) // 3. Set metadata (optional)
.inputSchema(z.object({ id: z.string() })) // 4. Define input
.useValidated(logParsedInput) // 5. Validated middleware (optional)
.outputSchema(z.object({ ok: z.boolean() })) // 6. Define output (optional)
.action(async ({ parsedInput, ctx }) => { // 7. Server code
return { ok: true };
});
```
Each method returns a **new immutable client instance**, the original is never modified. This lets you build a hierarchy of increasingly specialized clients. See [Action Client](/docs/concepts/action-client) for more.
## What's next? [#whats-next]
Learn how to create and configure the safe action client.
Understand how Standard Schema validation works with your actions.
# Input validation (/docs/concepts/input-validation)
Input validation is the core feature of next-safe-action. Every action can define an input schema that validates and types the data before your server code runs. This uses the [Standard Schema](https://github.com/standard-schema/standard-schema) specification, so you can use **any** compatible validation library.
## Defining an input schema [#defining-an-input-schema]
Use the `.inputSchema()` method to attach a schema to your action:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
export const createUser = actionClient
.inputSchema(z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18).optional(),
}))
.action(async ({ parsedInput }) => {
// parsedInput is typed as { name: string; email: string; age?: number }
return { id: "123", ...parsedInput };
});
```
```ts title="src/app/actions.ts"
"use server";
import * as v from "valibot";
import { actionClient } from "@/lib/safe-action";
export const createUser = actionClient
.inputSchema(v.object({
name: v.pipe(v.string(), v.minLength(2)),
email: v.pipe(v.string(), v.email()),
age: v.optional(v.pipe(v.number(), v.minValue(18))),
}))
.action(async ({ parsedInput }) => {
// parsedInput is typed as { name: string; email: string; age?: number }
return { id: "123", ...parsedInput };
});
```
The `parsedInput` parameter in your server code is **fully typed** based on the schema's output type. TypeScript will error if you access a property that doesn't exist.
## How validation works [#how-validation-works]
When an action is called, the input goes through this flow:
1. **Client sends raw input**: the unvalidated data from the client
2. **Standard Schema parse**: the input is validated against your schema
3. **Success** → your server code receives `parsedInput` (typed and validated)
4. **Failure** → the action returns `validationErrors` immediately (your server code never runs)
The raw `clientInput` is also available in middleware and server code if you need access to the original, unvalidated data.
### After validation: validated middleware [#after-validation-validated-middleware]
If you need to run middleware that has access to the validated, typed `parsedInput` (for example, authorization checks that depend on the input), use `useValidated()` instead of `use()`. Validated middleware runs immediately after successful input validation and before your server code:
```ts title="src/app/actions.ts"
authClient
.inputSchema(z.object({ postId: z.string() }))
.useValidated(async ({ parsedInput, ctx, next }) => {
// parsedInput.postId is typed as string
const post = await db.post.findUnique({ where: { id: parsedInput.postId } });
if (!post || post.authorId !== ctx.user.id) throw new Error("Forbidden");
return next({ ctx: { post } });
})
.action(async ({ ctx }) => {
// ctx.post is typed and available
});
```
If validation fails, validated middleware is completely skipped. See the [middleware guide](/docs/guides/middleware#validated-middleware) for more.
## Validation error shapes [#validation-error-shapes]
When validation fails, the error object structure depends on the configured shape. There are two built-in shapes:
### Formatted (default) [#formatted-default]
The default shape mirrors your schema structure with `_errors` arrays at each level:
```ts
// Schema: z.object({ name: z.string().min(2), email: z.string().email() })
// Input: { name: "", email: "invalid" }
{
validationErrors: {
name: { _errors: ["String must contain at least 2 character(s)"] },
email: { _errors: ["Invalid email"] },
}
}
```
### Flattened [#flattened]
The flattened shape separates form-level errors from field-level errors:
```ts
// Same schema and input as above
{
validationErrors: {
formErrors: [],
fieldErrors: {
name: ["String must contain at least 2 character(s)"],
email: ["Invalid email"],
},
}
}
```
You can set the default shape when creating the client:
```ts
const actionClient = createSafeActionClient({
defaultValidationErrorsShape: "flattened",
});
```
Or override it per-action. See [Custom Validation Errors](/docs/advanced/custom-validation-errors) for details.
## Supported libraries [#supported-libraries]
Any library that implements the [Standard Schema](https://github.com/standard-schema/standard-schema) specification works with next-safe-action. This includes:
| Library | Status |
| ------------------------------- | --------------- |
| [Zod](https://zod.dev/) | Fully supported |
| [Valibot](https://valibot.dev/) | Fully supported |
| [ArkType](https://arktype.io/) | Fully supported |
See the [Standard Schema integration guide](/docs/integrations/standard-schema) for side-by-side comparisons.
## What's next? [#whats-next]
Learn the full structure of the result object returned by actions.
Customize error shapes, format errors, and manually return validation errors.
# createMiddleware() (/docs/api/create-middleware)
`createMiddleware` creates a standalone middleware function that can be shared across multiple action clients. It provides type constraints so TypeScript can verify the middleware is only used with compatible clients.
```ts
import { createMiddleware } from "next-safe-action";
const myMiddleware = createMiddleware().define(middlewareFn);
```
## Signature [#signature]
```ts
function createMiddleware<
BaseData extends {
serverError?: any;
ctx?: object;
metadata?: any;
}
>(): {
define: (middlewareFn: MiddlewareFn) => MiddlewareFn;
};
```
## Generic parameter [#generic-parameter]
`createMiddleware` accepts a single generic parameter `BaseData` that declares the **minimum** requirements for the middleware. The client using this middleware must have at least these properties available.
## `.define()` method [#define-method]
The `define` method accepts a middleware function and returns it with proper typing. The middleware function receives the same options as inline `.use()` middleware:
## Examples [#examples]
### No constraints (universal middleware) [#no-constraints-universal-middleware]
```ts title="src/lib/middleware/logging.ts"
import { createMiddleware } from "next-safe-action";
export const loggingMiddleware = createMiddleware().define(
async ({ next, metadata }) => {
const start = Date.now();
const result = await next();
console.log(`Action took ${Date.now() - start}ms`, metadata);
return result;
}
);
```
### With context constraints [#with-context-constraints]
```ts title="src/lib/middleware/admin-guard.ts"
import { createMiddleware } from "next-safe-action";
// Requires ctx.user with a role property
export const adminGuard = createMiddleware<{
ctx: { user: { id: string; role: string } };
}>().define(async ({ next, ctx }) => {
if (ctx.user.role !== "admin") {
throw new Error("Admin access required");
}
// ctx.user is fully typed here
return next();
});
```
### With metadata constraints [#with-metadata-constraints]
```ts title="src/lib/middleware/rate-limit.ts"
import { createMiddleware } from "next-safe-action";
export const rateLimitMiddleware = createMiddleware<{
metadata: { actionName: string };
}>().define(async ({ next, metadata }) => {
await checkRateLimit(metadata.actionName);
return next();
});
```
### Extending context [#extending-context]
```ts title="src/lib/middleware/with-db.ts"
import { createMiddleware } from "next-safe-action";
export const withDb = createMiddleware().define(async ({ next }) => {
const db = await getDbConnection();
return next({
ctx: { db }, // Adds db to context for downstream middleware/actions
});
});
```
## Usage with clients [#usage-with-clients]
```ts title="src/lib/safe-action.ts"
import { createSafeActionClient } from "next-safe-action";
import { loggingMiddleware } from "./middleware/logging";
import { adminGuard } from "./middleware/admin-guard";
const baseClient = createSafeActionClient().use(loggingMiddleware);
// ✅ Works: adminGuard requires ctx.user, which authMiddleware provides
const adminClient = baseClient
.use(async ({ next }) => {
const user = await getUser();
return next({ ctx: { user } });
})
.use(adminGuard);
// ❌ Type error: adminGuard requires ctx.user, but baseClient doesn't have it
const broken = baseClient.use(adminGuard);
```
## See also [#see-also]
* [Middleware guide](/docs/guides/middleware): foundational middleware concepts
* [Standalone middleware](/docs/advanced/standalone-middleware): guide with more patterns and use cases
* [`.use()` method](/docs/api/safe-action-client#use): how standalone middleware is consumed by clients
* [`createValidatedMiddleware()`](/docs/api/create-validated-middleware): standalone factory for post-validation middleware
# createSafeActionClient() (/docs/api/create-safe-action-client)
`createSafeActionClient` creates a new safe action client instance. This is the entry point for defining type-safe server actions.
```ts
import { createSafeActionClient } from "next-safe-action";
const actionClient = createSafeActionClient(opts);
```
## Parameters [#parameters]
`createSafeActionClient` accepts an optional options object:
## Return value [#return-value]
Returns a [`SafeActionClient`](/docs/api/safe-action-client) instance with all chainable methods available.
## Examples [#examples]
### Minimal client [#minimal-client]
```ts title="src/lib/safe-action.ts"
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient();
```
### Custom error handling [#custom-error-handling]
```ts title="src/lib/safe-action.ts"
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient({
handleServerError: (error, { metadata }) => {
// Log to your error tracking service
reportError(error, { metadata });
// Return a user-friendly message
if (error instanceof DatabaseError) {
return "A database error occurred. Please try again.";
}
return "Something went wrong.";
},
});
```
### With metadata schema [#with-metadata-schema]
```ts title="src/lib/safe-action.ts"
import { createSafeActionClient } from "next-safe-action";
import { z } from "zod";
export const actionClient = createSafeActionClient({
defineMetadataSchema: () =>
z.object({
actionName: z.string(),
}),
});
// All actions using this client must now call .metadata()
export const myAction = actionClient
.metadata({ actionName: "myAction" })
.action(async () => {
// ...
});
```
### Flattened validation errors [#flattened-validation-errors]
```ts title="src/lib/safe-action.ts"
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient({
defaultValidationErrorsShape: "flattened",
});
// All actions using this client return flattened errors:
// { formErrors: string[], fieldErrors: { [field]: string[] } }
```
## `ServerErrorFunctionUtils` [#servererrorfunctionutils]
The second argument passed to `handleServerError`:
## See also [#see-also]
* [Action client](/docs/concepts/action-client): conceptual overview of the client pattern
* [SafeActionClient methods](/docs/api/safe-action-client): all chainable methods available on the returned client
* [Metadata](/docs/advanced/metadata): using `defineMetadataSchema` for type-safe action metadata
* [Error handling](/docs/concepts/error-handling): understanding `handleServerError` in context
# createValidatedMiddleware() (/docs/api/create-validated-middleware)
`createValidatedMiddleware` creates a standalone validated middleware function for use with `.useValidated()`. It works the same way as `createMiddleware()`, but the middleware function also receives typed `parsedInput`, `clientInput`, `bindArgsParsedInputs`, and `bindArgsClientInputs`.
```ts
import { createValidatedMiddleware } from "next-safe-action";
const myMiddleware = createValidatedMiddleware().define(middlewareFn);
```
Most middleware should use [`createMiddleware()`](/docs/api/create-middleware) with `.use()` instead. Only use `createValidatedMiddleware()` when the middleware specifically needs access to the validated `parsedInput`, for example to check resource ownership or log transformed input. See [when to use `useValidated()` vs `use()`](/docs/guides/middleware#when-to-use-usevalidated-vs-use) for guidance.
## Signature [#signature]
```ts
function createValidatedMiddleware<
BaseData extends {
serverError?: any;
ctx?: object;
metadata?: any;
parsedInput?: unknown;
clientInput?: unknown;
bindArgsParsedInputs?: readonly unknown[];
bindArgsClientInputs?: readonly unknown[];
}
>(): {
define: (middlewareFn: ValidatedMiddlewareFn) => ValidatedMiddlewareFn;
};
```
## Generic parameter [#generic-parameter]
`createValidatedMiddleware` accepts a single generic parameter `BaseData` with all the properties from `createMiddleware`, plus input-related constraints:
## `.define()` method [#define-method]
The `define` method accepts a validated middleware function. The function receives all the properties from inline `useValidated()` middleware: `parsedInput`, `clientInput`, `bindArgsParsedInputs`, `bindArgsClientInputs`, `ctx`, `metadata`, and `next`.
## Examples [#examples]
### With parsedInput constraint [#with-parsedinput-constraint]
```ts title="src/lib/middleware/log-user.ts"
import { createValidatedMiddleware } from "next-safe-action";
export const logUserMiddleware = createValidatedMiddleware<{
parsedInput: { userId: string };
}>().define(async ({ parsedInput, next }) => {
console.log("Acting on user:", parsedInput.userId);
return next();
});
```
### With context and parsedInput constraints [#with-context-and-parsedinput-constraints]
```ts title="src/lib/middleware/ownership-check.ts"
import { createValidatedMiddleware } from "next-safe-action";
export const ownershipCheck = createValidatedMiddleware<{
ctx: { user: { id: string } };
parsedInput: { resourceId: string };
}>().define(async ({ parsedInput, ctx, next }) => {
const resource = await db.resource.findUnique({
where: { id: parsedInput.resourceId },
});
if (resource?.ownerId !== ctx.user.id) {
throw new Error("Forbidden");
}
return next({ ctx: { resource } });
});
```
### Usage with clients [#usage-with-clients]
```ts title="src/lib/safe-action.ts"
import { ownershipCheck } from "./middleware/ownership-check";
const ownerAction = authClient
.inputSchema(z.object({ resourceId: z.string() }))
.useValidated(ownershipCheck);
// ✅ Works: ownershipCheck requires ctx.user (from auth) and parsedInput.resourceId
// ❌ Type error if used without auth middleware or without resourceId in schema
```
## See also [#see-also]
* [Validated middleware guide](/docs/guides/middleware#validated-middleware): practical patterns for `useValidated()`
* [Standalone middleware](/docs/advanced/standalone-middleware): guide with reuse patterns
* [`.useValidated()` method](/docs/api/safe-action-client#usevalidated): how standalone validated middleware is consumed
* [`createMiddleware()`](/docs/api/create-middleware): standalone pre-validation middleware factory
# Error classes (/docs/api/error-classes)
next-safe-action exports several error classes that are thrown during action execution. You can use these in `handleServerError` or `try/catch` blocks to identify specific error types.
All error classes are imported from the main entry point:
```ts
import {
ActionValidationError,
ActionBindArgsValidationError,
ActionMetadataValidationError,
ActionOutputDataValidationError,
} from "next-safe-action";
```
## `ActionValidationError` [#actionvalidationerror]
Thrown when input validation fails **and** `throwValidationErrors` is enabled (either at the client level or per action). Contains the shaped validation errors (after `handleValidationErrorsShape` is applied).
```ts
class ActionValidationError extends Error {
validationErrors: ShapedErrors;
}
```
```ts
// Enable throwing per action
export const myAction = actionClient.action(
async ({ parsedInput }, { throwValidationErrors }) => {
throwValidationErrors(schema, {
email: { _errors: ["Already registered"] },
});
}
);
// Or enable globally
const actionClient = createSafeActionClient({
throwValidationErrors: true,
});
// Catch in handleServerError
const actionClient = createSafeActionClient({
throwValidationErrors: true,
handleServerError: (error) => {
if (error instanceof ActionValidationError) {
// error.validationErrors is available
return "Validation failed";
}
return DEFAULT_SERVER_ERROR_MESSAGE;
},
});
```
***
## `ActionBindArgsValidationError` [#actionbindargsvalidationerror]
Thrown internally when bind argument validation fails. This error is caught by the action builder and the validation errors are returned in the result.
```ts
class ActionBindArgsValidationError extends Error {
validationErrors: unknown[];
}
```
This error is handled internally by the action builder. You typically don't need to catch it yourself unless you're building custom error handling in `handleServerError`.
***
## `ActionMetadataValidationError` [#actionmetadatavalidationerror]
Thrown when the metadata provided via `.metadata()` doesn't match the schema defined by `defineMetadataSchema`. This is a developer error, meaning the metadata value doesn't conform to the schema.
```ts
class ActionMetadataValidationError extends Error {
validationErrors: ValidationErrors;
}
```
***
## `ActionOutputDataValidationError` [#actionoutputdatavalidationerror]
Thrown when the action's return value doesn't match the schema defined by `.outputSchema()`. This is a developer error, meaning the server code is returning data that doesn't conform to the output schema.
```ts
class ActionOutputDataValidationError extends Error {
validationErrors: ValidationErrors;
}
```
***
## Error handling patterns [#error-handling-patterns]
### Identifying errors in `handleServerError` [#identifying-errors-in-handleservererror]
```ts
import {
ActionValidationError,
ActionMetadataValidationError,
ActionOutputDataValidationError,
} from "next-safe-action";
const actionClient = createSafeActionClient({
throwValidationErrors: true,
handleServerError: (error) => {
if (error instanceof ActionValidationError) {
return "Please check your input.";
}
if (error instanceof ActionMetadataValidationError) {
console.error("Developer error: invalid metadata", error.validationErrors);
return "Internal error";
}
if (error instanceof ActionOutputDataValidationError) {
console.error("Developer error: invalid output", error.validationErrors);
return "Internal error";
}
return "Something went wrong.";
},
});
```
## See also [#see-also]
* [Error handling](/docs/concepts/error-handling): the complete error taxonomy
* [Custom validation errors](/docs/advanced/custom-validation-errors): customizing error shapes and manual error returns
* [Validation utilities](/docs/api/validation-utilities): `returnValidationErrors` and formatting functions
* [`createSafeActionClient()`](/docs/api/create-safe-action-client): `handleServerError` and `throwValidationErrors` options
# Hooks API (/docs/api/hooks-api)
next-safe-action provides React hooks for executing server actions from client components. All hooks are imported from `next-safe-action/hooks`.
## `useAction` [#useaction]
The primary hook for executing server actions from client components.
```ts
import { useAction } from "next-safe-action/hooks";
const { execute, result, status, ... } = useAction(safeActionFn, callbacks?);
```
### Parameters [#parameters]
`opts.initResult` sets the initial result before the first execution (defaults to `{}`), useful for preloading the hook with data fetched on the server. It is captured once at mount, like React's `useActionState` initial state: later changes to the option are ignored, and `reset()` restores the mount value.
### Return object [#return-object]
The return object is a **discriminated union** keyed on `status` and the shorthand booleans (`hasSucceeded`, `hasErrored`, etc.). Checking any discriminant narrows the `result` type. For example, when `hasSucceeded` is `true`, `result.data` is guaranteed to be `Data` (not `Data | undefined`), and `result.serverError` / `result.validationErrors` are narrowed to `undefined`. See the [Type narrowing](/docs/guides/hooks#type-narrowing) guide for details.
### Example [#example]
```tsx
"use client";
import { useAction } from "next-safe-action/hooks";
import { createUser } from "./actions";
export function CreateUserForm() {
const { execute, result, isPending, hasSucceeded, hasErrored } = useAction(createUser, {
onSuccess: ({ data }) => {
console.log("Created user:", data.id);
},
onError: ({ error }) => {
console.error("Failed:", error.serverError);
},
});
return (
);
}
```
***
## `useOptimisticAction` [#useoptimisticaction]
Execute actions with optimistic UI updates. The state updates immediately on execute and reverts if the action fails.
```ts
import { useOptimisticAction } from "next-safe-action/hooks";
const { execute, optimisticState, ... } = useOptimisticAction(safeActionFn, utils);
```
### Parameters [#parameters-1]
The `utils` object also accepts all [HookBaseOptions](#hookbaseoptions) properties including `throwOnNavigation` and lifecycle callbacks. When `throwOnNavigation` is `true`, `onNavigation` and `onSettled` are not available.
### Return object [#return-object-1]
Same as [`useAction`](#return-object) plus:
### Example [#example-1]
```tsx
"use client";
import { useOptimisticAction } from "next-safe-action/hooks";
import { toggleTodo } from "./actions";
export function TodoItem({ todo }: { todo: Todo }) {
const { execute, optimisticState } = useOptimisticAction(toggleTodo, {
currentState: todo,
updateFn: (state, input) => ({
...state,
completed: !state.completed,
}),
});
return (
);
}
```
***
## `useStateAction` [#usestateaction]
Execute stateful actions (defined with `.stateAction()`) with full lifecycle control and `
);
}
```
***
## `HookBaseOptions` [#hookbaseoptions]
Configuration and lifecycle callbacks for `useAction`, `useOptimisticAction`, and `useStateAction`. The available callbacks depend on the `throwOnNavigation` value:
When `throwOnNavigation` is `true`, TypeScript prevents you from passing `onNavigation` or `onSettled` callbacks. This is because the render-phase throw prevents React from committing effects, so these callbacks can never execute. See [why callbacks can't fire](/docs/advanced/framework-errors#why-callbacks-cant-fire-with-throwonnavigation-true) for details.
***
## `HookCallbacks` [#hookcallbacks]
Lifecycle callbacks shared by all hooks:
***
## `HookActionStatus` [#hookactionstatus]
Union type representing all possible hook states:
```ts
type HookActionStatus =
| "idle" // No action executed yet (or after reset)
| "executing" // Action is running on the server
| "hasSucceeded" // Action completed with data, no errors
| "hasErrored" // Action completed with errors
| "hasNavigated"; // Navigation function was called
```
***
## `HookShorthandStatus` [#hookshorthandstatus]
Object of boolean status flags returned alongside the `status` string:
```ts
type HookShorthandStatus = {
isIdle: boolean;
isExecuting: boolean;
isTransitioning: boolean;
isPending: boolean; // isExecuting || isTransitioning
hasSucceeded: boolean;
hasErrored: boolean;
hasNavigated: boolean;
};
```
## See also [#see-also]
* [Hooks guide](/docs/guides/hooks): practical guide with patterns and examples
* [Optimistic updates](/docs/guides/optimistic-updates): `useOptimisticAction` in depth
* [Framework navigation/errors](/docs/advanced/framework-errors): `onNavigation` and `hasNavigated` behavior
* [Form actions](/docs/guides/form-actions): compare `useAction`, `useStateAction`, and React's `useActionState`
* [Type utilities](/docs/api/type-utilities): `InferUseActionHookReturn` and related types
# SafeActionClient (/docs/api/safe-action-client)
The `SafeActionClient` class is the core of next-safe-action. It provides a chainable, immutable API for building type-safe server actions. Each method returns a **new** client instance, the original is never modified.
```ts
import { createSafeActionClient } from "next-safe-action";
const actionClient = createSafeActionClient();
```
## `.use()` [#use]
Add a middleware function to the action execution chain. Cannot be called after `useValidated()`.
```ts
client.use(middlewareFn)
```
**Parameters:**
The middleware function receives:
**Returns:** A new `SafeActionClient` with `NextCtx` merged into the context type.
```ts
const authClient = actionClient.use(async ({ next }) => {
const session = await getSession();
if (!session) throw new Error("Unauthorized");
return next({ ctx: { user: session.user } });
});
// authClient now has ctx: { user: User }
```
***
## `.useValidated()` [#usevalidated]
Add a validated middleware function that runs **after** input validation. Only available after `inputSchema()` or `bindArgsSchemas()` has been called. Cannot be followed by `inputSchema()`, `bindArgsSchemas()`, or `use()`.
For most middleware, prefer [`.use()`](#use) instead. `.useValidated()` is designed for the specific cases where your middleware logic depends on the validated `parsedInput`, such as resource ownership checks or input-dependent authorization.
```ts
client.useValidated(middlewareFn)
```
**Parameters:**
The validated middleware function receives:
**Returns:** A new `SafeActionClient` with `NextCtx` merged into the context type. After calling `useValidated()`, `inputSchema()`, `bindArgsSchemas()`, and `use()` are no longer callable (TypeScript error).
```ts
const protectedAction = authClient
.inputSchema(z.object({ postId: z.string() }))
.useValidated(async ({ parsedInput, ctx, next }) => {
const post = await db.post.findUnique({ where: { id: parsedInput.postId } });
if (post?.authorId !== ctx.user.id) throw new Error("Forbidden");
return next({ ctx: { post } });
});
// protectedAction now has ctx: { user: User, post: Post }
```
`useValidated()` middleware is completely skipped if input validation fails. It only runs when all schemas pass.
***
## `.metadata()` [#metadata]
Set metadata for the action. Only available when a metadata schema has been defined via `defineMetadataSchema` in [`createSafeActionClient`](/docs/api/create-safe-action-client).
```ts
client.metadata(data)
```
**Parameters:**
**Returns:** A new `SafeActionClient` with metadata provided.
```ts
const myAction = actionClient
.metadata({ actionName: "createUser" })
.action(async ({ parsedInput, metadata }) => {
console.log(metadata.actionName); // "createUser"
});
```
When a metadata schema is defined, you **must** call `.metadata()` before `.action()` or `.stateAction()`. TypeScript will error if you forget.
***
## `.inputSchema()` [#inputschema]
Define the input validation schema. Accepts a Standard Schema validator (Zod, Valibot, ArkType, etc.) or an async factory function that returns one.
```ts
client.inputSchema(schema, utils?)
```
**Parameters:**
**Returns:** A new `SafeActionClient` with typed `parsedInput`.
```ts
// Direct schema
const action = actionClient
.inputSchema(z.object({ name: z.string() }))
.action(async ({ parsedInput }) => {
// parsedInput: { name: string }
});
// Async factory (for i18n or extending previous schemas)
const action = actionClient
.inputSchema(async () => {
const t = await getTranslations();
return z.object({ name: z.string().min(2, t("name.tooShort")) });
})
.action(async ({ parsedInput }) => { /* ... */ });
```
`.schema()` is a deprecated alias for `.inputSchema()`. Use `.inputSchema()` instead.
`.inputSchema()` cannot be called after `.useValidated()`. TypeScript will report an error if you try.
***
## `.outputSchema()` [#outputschema]
Define the output data validation schema. The action's return value is validated against this schema.
```ts
client.outputSchema(schema)
```
**Parameters:**
**Returns:** A new `SafeActionClient` with typed and validated output data.
```ts
const action = actionClient
.outputSchema(z.object({ id: z.string(), created: z.boolean() }))
.action(async () => {
return { id: "123", created: true };
// TypeScript + runtime validation ensures this shape
});
```
***
## `.bindArgsSchemas()` [#bindargsschemas]
Define validation schemas for [bind arguments](/docs/advanced/bind-arguments). Bind args are additional arguments bound to the action function before the main input.
```ts
client.bindArgsSchemas(schemas)
```
**Parameters:**
**Returns:** A new `SafeActionClient` with typed bind argument inputs.
```ts
const action = actionClient
.inputSchema(z.object({ title: z.string() }))
.bindArgsSchemas([z.string().uuid()]) // bind arg: projectId
.action(async ({ parsedInput, bindArgsParsedInputs: [projectId] }) => {
// parsedInput: { title: string }
// projectId: string
});
// In a component:
const boundAction = action.bind(null, projectId);
```
`.bindArgsSchemas()` cannot be called after `.useValidated()`. TypeScript will report an error if you try.
***
## `.action()` [#action]
Define the server-side code for the action. This terminates the builder chain and returns the callable action function.
```ts
client.action(serverCodeFn, utils?)
```
**Parameters:**
The `serverCodeFn` receives a single argument object:
**Returns:** A `SafeActionFn`, a callable async function.
***
## `.stateAction()` [#stateaction]
Define a stateful server action for use with the `useStateAction` hook or React's `useActionState`.
```ts
client.stateAction(serverCodeFn, utils?)
```
Same as `.action()`, but the `serverCodeFn` receives a second argument `utils` with access to `prevResult`:
**Returns:** A `SafeStateActionFn`, a callable async function compatible with `useActionState`.
***
## `ActionCallbacks` (action callbacks) [#actioncallbacks-action-callbacks]
The optional second argument to `.action()` and `.stateAction()`:
In `onError`, `onSettled`, and `onNavigation` callbacks, context added by `useValidated()` middleware is optional in the `ctx` type. Context from `use()` middleware is always present, but `useValidated()` context may not exist if validation failed before validated middleware could run.
```ts
export const myAction = actionClient
.inputSchema(schema)
.action(
async ({ parsedInput }) => {
return await doSomething(parsedInput);
},
{
onSuccess: async ({ data, metadata }) => {
console.log("Success:", data);
},
onError: async ({ error }) => {
await reportError(error.serverError);
},
}
);
```
## Method chaining order [#method-chaining-order]
Methods can be called in any order with a few constraints: `use()` must come before `useValidated()`, schemas must come before `useValidated()`, and the final call must be `.action()` / `.stateAction()`.
Typical order:
```ts
actionClient
.use(middleware) // 1. Add pre-validation middleware (repeatable)
.metadata(data) // 2. Set metadata (if schema defined)
.inputSchema(schema) // 3. Define input validation
.bindArgsSchemas([...]) // 4. Define bind args (optional, must be before useValidated)
.useValidated(middleware) // 5. Add post-validation middleware (repeatable, requires schema)
.outputSchema(schema) // 6. Define output validation (optional)
.action(fn, utils) // 7. Define server code (terminal)
```
## See also [#see-also]
* [Action client](/docs/concepts/action-client): conceptual overview and immutability pattern
* [Middleware guide](/docs/guides/middleware): practical patterns for `.use()` and `.useValidated()`
* [`createSafeActionClient()`](/docs/api/create-safe-action-client): creating the client instance
* [`createMiddleware()`](/docs/api/create-middleware) / [`createValidatedMiddleware()`](/docs/api/create-validated-middleware): standalone middleware factories
* [Types reference](/docs/api/types): all exported TypeScript types
# Type utilities (/docs/api/type-utilities)
next-safe-action exports several utility types for inferring types from action functions, clients, and middleware. These are useful when you need to reference action types in other parts of your code.
All type utilities are imported from the main entry point:
```ts
import type {
InferSafeActionFnInput,
InferSafeActionFnResult,
InferMiddlewareFnNextCtx,
InferCtx,
InferMetadata,
InferServerError,
} from "next-safe-action";
```
## `InferSafeActionFnInput` [#infersafeactionfninput]
Infer the input types (both client-side and parsed) from an action function.
```ts
type InferSafeActionFnInput
```
**Produces:**
```ts
{
clientInput: StandardSchemaV1.InferInput;
bindArgsClientInputs: InferInputArray;
parsedInput: StandardSchemaV1.InferOutput;
bindArgsParsedInputs: InferOutputArray;
}
```
**Works with:** `SafeActionFn`, `SafeStateActionFn`
```ts
import type { InferSafeActionFnInput } from "next-safe-action";
import { myAction } from "./actions";
type MyInput = InferSafeActionFnInput;
// MyInput.clientInput: what you pass to execute()
// MyInput.parsedInput: what the server code receives
// MyInput.bindArgsClientInputs: bound arg types (input side)
// MyInput.bindArgsParsedInputs: bound arg types (parsed side)
```
***
## `InferSafeActionFnResult` [#infersafeactionfnresult]
Infer the result type from an action function.
```ts
type InferSafeActionFnResult
```
**Produces:** `SafeActionResult`
**Works with:** `SafeActionFn`, `SafeStateActionFn`
```ts
import type { InferSafeActionFnResult } from "next-safe-action";
import { myAction } from "./actions";
type MyResult = InferSafeActionFnResult;
// MyResult.data: the successful return type
// MyResult.serverError: the server error type
// MyResult.validationErrors: the validation errors type
```
***
## `InferMiddlewareFnNextCtx` [#infermiddlewarefnnextctx]
Infer the next context type that a middleware function provides via `next({ ctx })`.
```ts
type InferMiddlewareFnNextCtx
```
**Produces:** The `NextCtx` type parameter from `MiddlewareFn`.
**Works with:** `MiddlewareFn`
```ts
import type { InferMiddlewareFnNextCtx } from "next-safe-action";
import { authMiddleware } from "./middleware";
type AuthCtx = InferMiddlewareFnNextCtx;
// { user: { id: string; role: string } }
```
***
## `InferCtx` [#inferctx]
Infer the current context type from a safe action client or middleware function.
```ts
type InferCtx
```
**Produces:** The `Ctx` type, the accumulated context up to that point in the chain.
**Works with:** `SafeActionClient`, `MiddlewareFn`
```ts
import type { InferCtx } from "next-safe-action";
import { authClient } from "./safe-action";
type AuthClientCtx = InferCtx;
// { user: { id: string; role: string } }
```
***
## `InferMetadata` [#infermetadata]
Infer the metadata type from a safe action client or middleware function.
```ts
type InferMetadata
```
**Produces:** The `Metadata` type, inferred from the metadata schema or `undefined` if none.
**Works with:** `SafeActionClient`, `MiddlewareFn`
```ts
import type { InferMetadata } from "next-safe-action";
import { actionClient } from "./safe-action";
type MyMetadata = InferMetadata;
// { actionName: string } (if defineMetadataSchema was set)
// undefined (if no metadata schema)
```
***
## `InferServerError` [#inferservererror]
Infer the server error type from a client, middleware, or action function.
```ts
type InferServerError
```
**Produces:** The `ServerError` type, which defaults to `string` unless customized via `handleServerError`.
**Works with:** `SafeActionClient`, `MiddlewareFn`, `SafeActionFn`, `SafeStateActionFn`
```ts
import type { InferServerError } from "next-safe-action";
import { actionClient } from "./safe-action";
import { myAction } from "./actions";
type ClientError = InferServerError; // string (default)
type ActionError = InferServerError; // string (inherited)
```
***
## Hook inference types [#hook-inference-types]
These are imported from `next-safe-action/hooks`:
```ts
import type {
InferUseActionHookReturn,
InferUseOptimisticActionHookReturn,
InferUseStateActionHookReturn,
} from "next-safe-action/hooks";
```
### `InferUseActionHookReturn` [#inferuseactionhookreturn]
Infer the return type of `useAction` for a given action function. The inferred type is a discriminated union keyed on `status` and shorthand booleans, so checking any discriminant narrows `result` to the matching branch.
```ts
type InferUseActionHookReturn
```
**Works with:** `SafeActionFn`
### `InferUseOptimisticActionHookReturn` [#inferuseoptimisticactionhookreturn]
Infer the return type of `useOptimisticAction` for a given action function and state type. Includes `optimisticState` plus the same discriminated union narrowing as `InferUseActionHookReturn`.
```ts
type InferUseOptimisticActionHookReturn
```
**Works with:** `SafeActionFn`
### `InferUseStateActionHookReturn` [#inferusestateactionhookreturn]
Infer the return type of `useStateAction` for a given stateful action function. Includes `formAction` plus the same discriminated union narrowing as `InferUseActionHookReturn`.
```ts
type InferUseStateActionHookReturn
```
**Works with:** `SafeStateActionFn`
## See also [#see-also]
* [Types reference](/docs/api/types): all exported TypeScript types
* [Hooks API](/docs/api/hooks-api): the hooks these types infer from
* [Action result](/docs/concepts/action-result): understanding `InferSafeActionFnResult`
# Types (/docs/api/types)
This page documents all the TypeScript types exported by next-safe-action. Types are exported from the main `next-safe-action` entry point unless otherwise noted.
## Core types [#core-types]
### `SafeActionResult` [#safeactionresult]
The result object returned by every action execution. Modeled as a **discriminated union** so that at most one of `data`, `serverError`, and `validationErrors` is populated — checking one field narrows the others to `undefined`.
```ts
type SafeActionResult =
| { data?: undefined; serverError?: undefined; validationErrors?: undefined } // idle / framework navigation
| { data: Data; serverError?: undefined; validationErrors?: undefined } // success
| { data?: undefined; serverError: ServerError; validationErrors?: undefined } // server error
| { data?: undefined; serverError?: undefined; validationErrors: ShapedErrors }; // validation failure
```
In compound-error scenarios where the runtime could otherwise produce multiple populated fields (middleware calling `next()` twice, or invalid bind args combined with invalid main input), a fixed precedence is applied: `validationErrors` > `serverError` > `data`. See [Action result](/docs/concepts/action-result#reading-the-result) for details.
***
### `SafeActionFn` [#safeactionfn]
The callable function type returned by `.action()`.
```ts
type SafeActionFn = (
...clientInputs: [...bindArgsInputs: InferInputArray, input: InferInput]
) => Promise>;
```
When bind args are defined, they appear as leading parameters. The main input is always the last parameter.
***
### `SafeStateActionFn` [#safestateactionfn]
The callable function type returned by `.stateAction()`.
```ts
type SafeStateActionFn = (
...clientInputs: [
...bindArgsInputs: InferInputArray,
prevResult: SafeActionResult,
input: InferInput,
]
) => Promise>;
```
Same as `SafeActionFn` but includes `prevResult` as the second-to-last parameter, before the main input.
***
## Middleware types [#middleware-types]
### `MiddlewareFn` [#middlewarefn]
The function type for middleware defined via `.use()` or `createMiddleware().define()`.
```ts
type MiddlewareFn = (opts: {
clientInput: unknown;
bindArgsClientInputs: unknown[];
ctx: Ctx;
metadata: Metadata;
next: (opts?: { ctx?: NC }) => Promise>;
}) => Promise>;
```
***
### `MiddlewareResult` [#middlewareresult]
The result type returned by the `next()` function inside middleware. Carries the same readable fields as `SafeActionResult` plus execution metadata (navigation kind, parsed inputs, context, success flag).
```ts
type MiddlewareResult = {
data?: any;
serverError?: ServerError;
validationErrors?: any;
navigationKind?: NavigationKind;
parsedInput?: unknown;
bindArgsParsedInputs?: unknown[];
ctx?: object;
success: boolean;
};
```
This is intentionally a flat object rather than an intersection with `SafeActionResult`. Because `SafeActionResult` is now a discriminated union, intersecting it would prevent middleware from mutating `data`/`serverError`/`validationErrors` while the chain executes. The set of readable fields is unchanged from previous versions.
***
## Server code types [#server-code-types]
### `ServerCodeFn` [#servercodefn]
The function type for the server code passed to `.action()`.
```ts
type ServerCodeFn = (args: {
parsedInput: InferOutput | undefined;
clientInput: InferInput | undefined;
bindArgsParsedInputs: InferOutputArray;
bindArgsClientInputs: InferInputArray;
ctx: Ctx;
metadata: Metadata;
}) => Promise;
```
***
### `StatefulServerCodeFn` [#statefulservercodefn]
The function type for the server code passed to `.stateAction()`. Same as `ServerCodeFn` but with a second `utils` argument.
```ts
type StatefulServerCodeFn = (
args: {
parsedInput: InferOutput | undefined;
clientInput: InferInput | undefined;
bindArgsParsedInputs: InferOutputArray;
bindArgsClientInputs: InferInputArray;
ctx: Ctx;
metadata: Metadata;
},
utils: {
prevResult: SafeActionResult;
}
) => Promise;
```
***
## Configuration types [#configuration-types]
### `CreateClientOpts` [#createclientopts]
Options passed to [`createSafeActionClient()`](/docs/api/create-safe-action-client).
```ts
type CreateClientOpts = {
defineMetadataSchema?: () => MetadataSchema;
handleServerError?: HandleServerErrorFn;
defaultValidationErrorsShape?: ErrorsFormat;
throwValidationErrors?: boolean;
};
```
***
### `ActionCallbacks` [#actioncallbacks]
Server-side callbacks and options passed as the second argument to `.action()` and `.stateAction()`.
```ts
type ActionCallbacks = {
throwServerError?: boolean;
throwValidationErrors?: boolean | {
overrideErrorMessage: (validationErrors: ShapedErrors) => Promise;
};
onSuccess?: (args: { data?, metadata, ctx?, clientInput, bindArgsClientInputs, parsedInput, bindArgsParsedInputs }) => Promise;
onNavigation?: (args: { metadata, ctx?, clientInput, bindArgsClientInputs, navigationKind }) => Promise;
onError?: (args: { error, metadata, ctx?, clientInput, bindArgsClientInputs }) => Promise;
onSettled?: (args: { result, metadata, ctx?, clientInput, bindArgsClientInputs, navigationKind? }) => Promise;
};
```
***
### `HandleServerErrorFn` [#handleservererrorfn]
The type for the `handleServerError` callback.
```ts
type HandleServerErrorFn = (
error: Error,
utils: ServerErrorFunctionUtils
) => MaybePromise;
```
***
### `ServerErrorFunctionUtils` [#servererrorfunctionutils]
Utils passed to `handleServerError`.
```ts
type ServerErrorFunctionUtils = {
clientInput: unknown;
bindArgsClientInputs: unknown[];
ctx: object;
metadata: InferOutput | undefined;
};
```
***
## Hook result types [#hook-result-types]
These types describe the `result` shape within each branch of the hook return discriminated union. They are exported from `next-safe-action/hooks`.
### `NormalizeActionResult` [#normalizeactionresult]
Collapses the void-success branch of a `SafeActionResult` union. When an action returns nothing (`void`), the `{ data: void }` branch is dropped so that `result.data` is exactly `undefined` rather than `void | undefined`. Applied automatically to hook `result` and `executeAsync` return types.
```ts
type NormalizeActionResult = R extends { data: infer D }
? [D] extends [void]
? Exclude
: R
: R;
```
When `Data` is not `void`, the type passes through unchanged.
***
### `HookIdleResult` [#hookidleresult]
The result shape when no action has completed yet (idle, executing, navigated). All fields are `undefined`.
```ts
type HookIdleResult = {
data?: undefined;
serverError?: undefined;
validationErrors?: undefined;
};
```
***
### `HookSuccessResult` [#hooksuccessresult]
The result shape for the success branch. For void-returning actions, collapses to `HookIdleResult`.
```ts
type HookSuccessResult = [Data] extends [void]
? HookIdleResult
: { data: Data; serverError?: undefined; validationErrors?: undefined };
```
***
### `HookErrorResult` [#hookerrorresult]
The result shape for the error branch. Includes the idle shape for thrown errors (where `result` is `{}` and the error is captured internally).
```ts
type HookErrorResult =
| HookIdleResult
| { data?: undefined; serverError: ServerError; validationErrors?: undefined }
| { data?: undefined; serverError?: undefined; validationErrors: ShapedErrors };
```
***
## Validation error types [#validation-error-types]
### `ValidationErrors` [#validationerrors]
The formatted validation errors type, mirroring the schema structure.
```ts
type ValidationErrors = Schema extends StandardSchemaV1
? InferOutput extends PrimitiveOrArray
? { _errors?: string[] }
: { _errors?: string[] } & SchemaErrors>
: undefined;
```
Each level of the schema can have an `_errors` array. Nested objects create nested error objects.
***
### `FlattenedValidationErrors` [#flattenedvalidationerrors]
The flattened validation errors type, with top-level `formErrors` and `fieldErrors`.
```ts
type FlattenedValidationErrors = {
formErrors: string[];
fieldErrors: {
[K in keyof Omit]?: string[];
};
};
```
***
### `HandleValidationErrorsShapeFn` [#handlevalidationerrorsshapefn]
Function type for custom validation error shaping.
```ts
type HandleValidationErrorsShapeFn = (
validationErrors: ValidationErrors,
utils: {
clientInput: InferInput | undefined;
bindArgsClientInputs: InferInputArray;
metadata: Metadata;
ctx: Ctx;
}
) => Promise;
```
***
## Navigation types [#navigation-types]
### `NavigationKind` [#navigationkind]
Union type representing framework navigation functions.
```ts
type NavigationKind = "redirect" | "notFound" | "forbidden" | "unauthorized" | "other";
```
***
## Enums and constants [#enums-and-constants]
### `ValidationErrorsFormat` [#validationerrorsformat]
The default validation errors shape type.
```ts
type ValidationErrorsFormat = "formatted" | "flattened";
```
***
## Utility types [#utility-types]
### `Prettify` [#prettify]
Makes complex intersection types readable in IDE hover tooltips.
```ts
type Prettify = { [K in keyof T]: T[K] } & {};
```
### `MaybePromise` [#maybepromise]
Accepts a value or a promise of a value.
```ts
type MaybePromise = Promise | T;
```
### `MaybeArray` [#maybearray]
Accepts a value or an array of values.
```ts
type MaybeArray = T | T[];
```
## See also [#see-also]
* [Type utilities](/docs/api/type-utilities): `Infer*` types for extracting types from actions and clients
* [Action result](/docs/concepts/action-result): understanding `SafeActionResult`
* [Hooks API](/docs/api/hooks-api): `HookCallbacks`, `HookActionStatus`, and hook return types
# Validation utilities (/docs/api/validation-utilities)
next-safe-action exports several utility functions and constants for working with validation errors.
All utilities are imported from the main entry point:
```ts
import {
returnValidationErrors,
returnServerError,
flattenValidationErrors,
formatValidationErrors,
DEFAULT_SERVER_ERROR_MESSAGE,
} from "next-safe-action";
```
## `returnValidationErrors()` [#returnvalidationerrors]
Throw validation errors from server action code. This function **never returns**. It throws internally and the error is caught by the action builder and returned as `validationErrors` in the result.
```ts
function returnValidationErrors(
schema: S,
validationErrors: ValidationErrors
): never;
```
**Parameters:**
**Returns:** `never`, this function always throws.
```ts
export const signUp = actionClient
.inputSchema(z.object({
email: z.string().email(),
username: z.string().min(3),
}))
.action(async ({ parsedInput }) => {
const emailExists = await db.user.findByEmail(parsedInput.email);
if (emailExists) {
// Throws internally, code below this line never executes
return returnValidationErrors(signUpSchema, {
email: { _errors: ["Email already registered"] },
});
}
// Form-level errors (not tied to a specific field)
return returnValidationErrors(signUpSchema, {
_errors: ["Registration is currently disabled"],
});
});
```
`returnValidationErrors` accepts both direct schemas and async schema factory functions. When using an async factory (for i18n), pass the factory function itself.
***
## `returnServerError()` [#returnservererror]
Return a typed, expected server error to the client from server action code or middleware. This function **never returns**. It throws internally and the error is caught by the action builder and returned as `serverError` in the result, **bypassing `handleServerError`**.
```ts
function returnServerError(serverError: SE): never;
```
**Parameters:**
**Returns:** `never`, this function always throws.
```ts
export const buyProduct = actionClient
.inputSchema(z.object({ productId: z.string() }))
.action(async ({ parsedInput }) => {
const product = await db.product.find(parsedInput.productId);
if (!product.inStock) {
// Throws internally, code below this line never executes
returnServerError({ code: "OUT_OF_STOCK", message: "This product is sold out" });
}
// ...
});
```
Since `SE` is inferred from the argument, the payload isn't checked against the client's `ServerError` type by default. Enforce it at the call site with an explicit generic or `satisfies`, or export a typed alias next to your action client:
```ts
returnServerError({ code: "OUT_OF_STOCK", message: "This product is sold out" });
// or
returnServerError({ code: "OUT_OF_STOCK", message: "This product is sold out" } satisfies AppServerError);
// or, recommended: a typed alias in lib/safe-action.ts
export const returnAppError: (e: AppServerError) => never = returnServerError;
```
See [Typing the error payload](/docs/concepts/error-handling#typing-the-error-payload) for the full explanation.
The payload is JSON-encoded onto the error `digest`, so it also works when thrown inside a Next.js `"use cache"` scope with `cacheComponents` enabled. See [Error handling](/docs/concepts/error-handling#expected-server-errors-with-returnservererror) for more details.
***
## `flattenValidationErrors()` [#flattenvalidationerrors]
Transform formatted validation errors into a flat structure with `formErrors` and `fieldErrors`. Discards errors for nested fields (only keeps one level deep).
```ts
function flattenValidationErrors>(
validationErrors: VE
): FlattenedValidationErrors;
```
**Parameters:**
**Returns:** `FlattenedValidationErrors`, an object with `formErrors: string[]` and `fieldErrors: { [key]: string[] }`.
```ts
// Input (formatted shape):
{
_errors: ["Passwords don't match"],
name: { _errors: ["Too short"] },
address: { street: { _errors: ["Required"] } },
}
// Output (flattened shape):
{
formErrors: ["Passwords don't match"],
fieldErrors: {
name: ["Too short"],
// address.street is discarded (nested)
},
}
```
### Usage with `handleValidationErrorsShape` [#usage-with-handlevalidationerrorsshape]
```ts
import { flattenValidationErrors } from "next-safe-action";
export const myAction = actionClient
.inputSchema(schema, {
handleValidationErrorsShape: (ve) => flattenValidationErrors(ve),
})
.action(async ({ parsedInput }) => { /* ... */ });
```
***
## `formatValidationErrors()` [#formatvalidationerrors]
Identity function that returns the formatted validation errors as-is. Useful as a named reference when you want to be explicit about using the default shape.
```ts
function formatValidationErrors>(
validationErrors: VE
): VE;
```
***
## `DEFAULT_SERVER_ERROR_MESSAGE` [#default_server_error_message]
The default error message returned to the client when a server error occurs and no custom `handleServerError` is provided.
```ts
const DEFAULT_SERVER_ERROR_MESSAGE: string;
// Value: "Something went wrong while executing the operation."
```
This constant is useful when you want to check if the client received the default error message, or when building custom error handlers that should fall back to the default.
```ts
import { DEFAULT_SERVER_ERROR_MESSAGE } from "next-safe-action";
const actionClient = createSafeActionClient({
handleServerError: (error) => {
if (error instanceof AuthError) {
return "Authentication failed";
}
return DEFAULT_SERVER_ERROR_MESSAGE;
},
});
```
## See also [#see-also]
* [Custom validation errors](/docs/advanced/custom-validation-errors): guide for customizing error shapes
* [Error handling](/docs/concepts/error-handling): the complete error taxonomy
* [Error classes](/docs/api/error-classes): all exported error classes
# Executing actions (/docs/guides/executing-actions)
There are four ways to execute a safe action from a Client Component. Each approach serves different needs:
## Comparison [#comparison]
Given the same action:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
export const greetUser = actionClient
.inputSchema(z.object({ name: z.string() }))
.action(async ({ parsedInput }) => {
return { greeting: `Hello, ${parsedInput.name}!` };
});
```
Here's how each method calls it:
```tsx title="src/app/page.tsx"
"use client";
import { greetUser } from "./actions";
export default function Page() {
const handleClick = async () => {
const result = await greetUser({ name: "Alice" });
if (result.data) {
alert(result.data.greeting);
}
};
return ;
}
```
**Use when**: You need a simple one-off call, or you're calling from a Server Component or event handler where you don't need reactive UI updates.
```tsx title="src/app/page.tsx"
"use client";
import { useAction } from "next-safe-action/hooks";
import { greetUser } from "./actions";
export default function Page() {
const { execute, result, isExecuting } = useAction(greetUser, {
onSuccess: ({ data }) => alert(data.greeting),
});
return (
);
}
```
**Use when**: You need loading states, status tracking, lifecycle callbacks, or any reactive UI behavior. This is the most common approach for interactive Client Components.
```tsx title="src/app/page.tsx"
"use client";
import { useActionState } from "react";
import { greetUser } from "./actions";
export default function Page() {
const [result, dispatch] = useActionState(greetUser, {});
return (
);
}
```
**Use when**: You need progressive enhancement (form works without JavaScript), or you want to use HTML form patterns with `FormData` input. See the [Form Actions guide](/docs/guides/form-actions) for details.
## Which method should I use? [#which-method-should-i-use]
| Need | Method |
| ---------------------------------------------- | -------------------------- |
| Simple call, no loading UI | Direct call |
| Loading states, callbacks, reactive result | `useAction` hook |
| Forms with callbacks, status, and `prevResult` | `useStateAction` hook |
| Optimistic UI updates | `useOptimisticAction` hook |
| Progressive enhancement / no-JS forms | `useActionState` (React) |
| Server Component calling an action | Direct call |
## What's next? [#whats-next]
Deep dive into useAction with status tracking, callbacks, and async execution.
Build forms with progressive enhancement using next-safe-action.
Update the UI instantly while the server processes your action.
# Form actions (/docs/guides/form-actions)
next-safe-action works with HTML forms using `FormData` as input. This enables **progressive enhancement**, meaning forms that work even without JavaScript.
## Stateless vs stateful [#stateless-vs-stateful]
There are two approaches to form actions:
Use `useAction` when you want full control over execution and don't need the form to work without JavaScript:
### Define the action with FormData input [#define-the-action-with-formdata-input]
When a form submits, the browser sends `FormData`. Use a library like `zod-form-data` to validate it:
npm
pnpm
yarn
bun
```bash
npm install zod-form-data
```
```bash
pnpm add zod-form-data
```
```bash
yarn add zod-form-data
```
```bash
bun add zod-form-data
```
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { zfd } from "zod-form-data";
import { actionClient } from "@/lib/safe-action";
const schema = zfd.formData({
email: zfd.text(z.string().email()),
password: zfd.text(z.string().min(8)),
});
export const loginAction = actionClient
.inputSchema(schema)
.action(async ({ parsedInput: { email, password } }) => {
const user = await authenticate(email, password);
return { userId: user.id };
});
```
### Create the form component [#create-the-form-component]
```tsx title="src/app/login.tsx"
"use client";
import { useAction } from "next-safe-action/hooks";
import { useRef } from "react";
import { loginAction } from "./actions";
export default function LoginForm() {
const formRef = useRef(null);
const { execute, result, isExecuting } = useAction(loginAction, {
onSuccess: () => {
formRef.current?.reset();
},
});
return (
);
}
```
Use `useStateAction` when you want `
Use React's `useActionState` directly for **progressive enhancement**, where the form works even without JavaScript. This approach has no lifecycle callbacks or navigation tracking, but forms submit natively before React hydrates:
### Define a state action [#define-a-state-action-1]
Use `.stateAction()` instead of `.action()` for stateful actions:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { zfd } from "zod-form-data";
import { actionClient } from "@/lib/safe-action";
const schema = zfd.formData({
email: zfd.text(z.string().email()),
password: zfd.text(z.string().min(8)),
});
export const loginAction = actionClient
.inputSchema(schema)
.stateAction(async ({ parsedInput: { email, password } }) => {
const user = await authenticate(email, password);
return { userId: user.id };
});
```
### Create the form with useActionState [#create-the-form-with-useactionstate]
```tsx title="src/app/login.tsx"
"use client";
import { useActionState } from "react";
import { loginAction } from "./actions";
export default function LoginForm() {
const [result, dispatch, isPending] = useActionState(loginAction, {});
return (
{result.validationErrors?.email &&
{result.validationErrors.email._errors[0]}
}
{result.validationErrors?.password &&
{result.validationErrors.password._errors[0]}
}
{result.data &&
Logged in as user {result.data.userId}
}
{result.serverError &&
Error: {result.serverError}
}
);
}
```
**Progressive enhancement**: The `action={dispatch}` pattern means the form submits natively, even before React hydrates on the client. The server processes the action and returns updated HTML. Once JavaScript loads, the form becomes interactive with `isPending` states and instant validation.
## When to use each approach [#when-to-use-each-approach]
| Feature | `useAction` | `useStateAction` | `useActionState` (React) |
| ---------------------- | ------------------------------------- | ------------------------------ | ------------------------------- |
| Works without JS | No | No | Yes |
| Previous result access | No | Yes (via `stateAction`) | Yes (via `stateAction`) |
| Form action support | No (`onSubmit` only) | Yes (`formAction`) | Yes (`dispatch`) |
| Loading states | `isExecuting`, `isPending` | `isExecuting`, `isPending` | `isPending` |
| Lifecycle callbacks | Full | Full | None |
| Navigation tracking | `onNavigation`, `hasNavigated` | `onNavigation`, `hasNavigated` | Error boundary only |
| `throwOnNavigation` | Yes | Yes | N/A (always throws) |
| `reset()` | Yes | Yes | No |
| `executeAsync` | Yes | Yes | No |
| Action method | `.action()` | `.stateAction()` | `.stateAction()` |
| Best for | Interactive UI, programmatic triggers | Forms with callbacks and state | Forms that must work without JS |
* **Use `useAction`** when you don't need previous result access, your triggers are programmatic (buttons, events), or you're building interactive UI that doesn't use `
`.
* **Use `useStateAction`** when you need previous result access (`prevResult` in server code), you're building forms with rich callbacks and status tracking, or you want the `
` pattern with full DX.
* **Use `useActionState` directly** when you need no-JS progressive enhancement, or you want the simplest possible form setup and don't need lifecycle callbacks.
## Passing an action directly to the `action` prop [#passing-an-action-directly-to-the-action-prop]
If you try to pass a plain action (created with `.action()`) straight into `
// ^ Type 'SafeActionFn<...>' is not assignable to type 'string | ((formData: FormData) => void | Promise)'.
```
This happens because React validates the `action` prop against an internal, **experimental** interface named `DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS`. Out of the box that interface doesn't know about a `SafeActionFn`, so the action's `Promise>` return type isn't recognized as a valid form action.
If you want to pass an action to the `action` prop without going through a hook (`useStateAction`, `useActionState`) or casting at the call site, you can teach React's types about it by augmenting that interface in your own application code:
```ts title="src/types/react.d.ts"
import type { SafeActionResult } from "next-safe-action";
declare module "react" {
interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS {
nextSafeActionFn: (...args: any[]) => Promise>;
}
}
```
With this declaration in scope, the action passes type checking when used directly:
```tsx title="src/app/login.tsx"
{/* ... */}
```
**This opts you into an unstable React interface.** As the name shouts, `DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS` is an internal, experimental React type with no stability guarantee. Its shape (or name) can change between React versions, which would break this augmentation. next-safe-action does **not** apply this augmentation for you, precisely so you opt in knowingly and keep that risk in your own codebase. If you'd rather not depend on an experimental interface, use `useStateAction` or React's `useActionState` (see the tabs above), which keep the action behind a stable hook API.
## What's next? [#whats-next]
Full useAction guide with callbacks and status lifecycle.
Upload files using FormData with next-safe-action.
# Hooks (/docs/guides/hooks)
The `useAction` hook is the primary way to execute safe actions from Client Components. It provides reactive status tracking, lifecycle callbacks, and loading states.
## Basic usage [#basic-usage]
```tsx
"use client";
import { useAction } from "next-safe-action/hooks";
import { myAction } from "./actions";
export default function MyComponent() {
const { execute, result, status, isExecuting } = useAction(myAction);
return (
{result.data &&
Success: {JSON.stringify(result.data)}
}
{result.serverError &&
Error: {result.serverError}
}
);
}
```
## Return object [#return-object]
`useAction` returns an object with these properties:
| Property | Type | Description |
| ----------------- | ---------------------------- | ---------------------------------------------------------- |
| `execute` | `(input) => void` | Execute the action (fire-and-forget) |
| `executeAsync` | `(input) => Promise` | Execute and await the result |
| `result` | `SafeActionResult` | The latest action result |
| `input` | `Input \| undefined` | The current/last input passed to execute |
| `status` | `HookActionStatus` | Current status string |
| `reset` | `() => void` | Reset to initial state, discarding any in-flight execution |
| `isIdle` | `boolean` | `true` when no action has been executed |
| `isExecuting` | `boolean` | `true` while the action is running |
| `isTransitioning` | `boolean` | `true` during React transition |
| `isPending` | `boolean` | `true` when executing or transitioning |
| `hasSucceeded` | `boolean` | `true` after a successful execution |
| `hasErrored` | `boolean` | `true` after a failed execution |
| `hasNavigated` | `boolean` | `true` after a framework navigation (redirect, etc.) |
## Status lifecycle [#status-lifecycle]
The `status` property transitions through these states:
The boolean shortcuts (`isExecuting`, `hasSucceeded`, etc.) are derived from `status` for convenience.
## Type narrowing [#type-narrowing]
The hook return object is a **discriminated union** keyed on `status` and the shorthand booleans (`hasSucceeded`, `hasErrored`, etc.). Checking any discriminant narrows the `result` type:
```tsx
const action = useAction(myAction);
// Narrowing via status
if (action.status === "hasSucceeded") {
action.result.data; // Data (guaranteed present)
action.result.serverError; // undefined (narrowed away)
action.result.validationErrors; // undefined (narrowed away)
}
// Narrowing via shorthand booleans
if (action.hasErrored) {
action.result.data; // undefined (narrowed away)
// Further narrow between error kinds:
if (action.result.serverError) {
action.result.validationErrors; // undefined
}
}
```
Destructured narrowing works too (TypeScript 4.6+):
```tsx
const { status, result, hasSucceeded } = useAction(myAction);
if (status === "hasSucceeded") {
result.data; // narrowed to Data
}
if (hasSucceeded) {
result.data; // also narrowed to Data
}
```
This applies to all hooks: `useAction`, `useOptimisticAction`, and `useStateAction`. The `result` field itself is also a discriminated union (see [Action result](/docs/concepts/action-result)), so you get two layers of narrowing: status-level and result-level.
## `execute` vs `executeAsync` [#execute-vs-executeasync]
| Method | Returns | Use when |
| --------------------- | ----------------- | ------------------------------------------------------------------------------------------ |
| `execute(input)` | `void` | You want fire-and-forget, handling results via callbacks or the reactive `result` property |
| `executeAsync(input)` | `Promise` | You need to `await` the result inline (e.g., sequential calls, conditional logic) |
```tsx
// Fire-and-forget, result is available reactively
execute({ name: "Alice" });
// Await the result: useful for sequential operations
const result = await executeAsync({ name: "Alice" });
if (result.data) {
await executeAsync({ name: "Bob" });
}
```
`executeAsync` throws the server error if the action fails with a server error, so wrap it in a try/catch if needed. `execute` never throws, errors are always captured in `result`.
## Options and callbacks [#options-and-callbacks]
Pass options and callbacks as the second argument to `useAction`:
```tsx
const { execute } = useAction(myAction, {
onExecute: ({ input }) => {
// Fires immediately when execute() is called
console.log("Starting with input:", input);
},
onSuccess: ({ data, input }) => {
// Fires when the action succeeds
toast.success(`Created: ${data.name}`);
},
onError: ({ error, input }) => {
// Fires when the action fails (validation or server error)
if (error.validationErrors) {
toast.error("Invalid input");
} else if (error.serverError) {
toast.error(error.serverError);
}
},
onNavigation: ({ navigationKind }) => {
// Fires when the action triggers a framework navigation
// (redirect, notFound, forbidden, unauthorized)
console.log("Navigating:", navigationKind);
},
onSettled: ({ result, input }) => {
// Fires after every execution (success, error, or navigation)
// Like finally in a try/catch
analytics.track("action_completed");
},
});
```
### `initResult` [#initresult]
Seed the hook with a preloaded result (e.g. data fetched on the server), so `result.data` is populated before the first execution:
```tsx
const { result } = useAction(myAction, {
initResult: { data: initialData },
});
```
The value is captured **once at mount**, like React's `useActionState` initial state: later changes to the option are ignored, and `reset()` restores the mount value. Available on `useAction`, `useOptimisticAction`, and `useStateAction`. See the [hooks API reference](/docs/api/hooks-api) for details.
### `throwOnNavigation` [#throwonnavigation]
By default, navigation errors (`notFound()`, `forbidden()`, `unauthorized()`, `redirect()`) are caught by the hook and set the status to `"hasNavigated"`, with `onNavigation` and `onSettled` callbacks firing normally.
Set `throwOnNavigation: true` to propagate navigation errors to the nearest error boundary instead. In Next.js, this shows the appropriate error page (404, 403, 401):
```tsx
const { execute } = useAction(myAction, {
throwOnNavigation: true,
// onNavigation and onSettled are NOT available here (TypeScript enforced)
});
```
When `throwOnNavigation` is `true`, `onNavigation` and `onSettled` are not available because React's rendering model prevents effects from running when a component throws during render. For guaranteed navigation side effects, use [server-side action callbacks](/docs/advanced/framework-errors#handling-in-action-callbacks).
### Callback execution order [#callback-execution-order]
1. `onExecute`: always first
2. One of: `onSuccess`, `onError`, or `onNavigation`
3. `onSettled`: always last
## `useStateAction` [#usestateaction]
Use `useStateAction` for stateful actions (defined with `.stateAction()`) that need full lifecycle control. It provides everything `useAction` offers, plus `formAction` for `
);
}
```
### Return object [#return-object-1]
Same as [`useAction`](#return-object) plus:
| Property | Type | Description |
| ------------ | ----------------- | --------------------------------------------------- |
| `formAction` | `(input) => void` | Dispatcher for `
` pattern |
### `useAction` vs `useStateAction` [#useaction-vs-usestateaction]
| | `useAction` | `useStateAction` |
| --------------- | ------------------------------- | -------------------------------------------------------- |
| Action method | `.action()` | `.stateAction()` |
| Previous result | Not available | Server code receives `prevResult` |
| Form action | Not supported | `formAction` for `
` |
| Triggers | `execute(input)` (programmatic) | `execute(input)`, `formAction`, or `executeAsync(input)` |
| Best for | Interactive UI, buttons, events | Forms with state, multi-step wizards |
Use `useAction` when you don't need previous result access and triggers are programmatic. Use `useStateAction` when you need `prevResult` in server code, want `
`, or are building multi-step forms.
`useStateAction` does not support no-JS progressive enhancement. The hook wraps the action to enable error tracking and callbacks, which requires JavaScript. For forms that must work without JavaScript, use React's `useActionState` directly. See the [form actions guide](/docs/guides/form-actions) for a comparison.
## What's next? [#whats-next]
Compare useAction, useStateAction, and useActionState for forms.
Use useOptimisticAction to update the UI instantly while the server processes.
Full type signatures for all hooks.
# Middleware (/docs/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 [#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 [#building-middleware-step-by-step]
### Add logging [#add-logging]
The simplest middleware: log when actions start and finish:
```ts title="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 [#add-authentication]
Check the session and pass the user into the context:
```ts title="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 [#use-the-context-in-actions]
Now actions defined with `authClient` have typed access to `ctx.user`:
```ts title="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 [#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) [#instance-level-shared-across-actions]
```ts
// 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) [#action-level-specific-to-one-action]
```ts
// 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 [#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` | Call the next middleware or server code |
The `next()` function accepts an optional `{ ctx: { ... } }` to extend the context for downstream layers.
## Common patterns [#common-patterns]
### Combining before/after logic [#combining-beforeafter-logic]
```ts
.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 [#short-circuiting]
Throwing an error in middleware stops the entire pipeline:
```ts
.use(async ({ next, ctx }) => {
if (ctx.user.isBanned) {
throw new Error("Account suspended");
// Server code never runs
}
return next();
})
```
### Passing data through context [#passing-data-through-context]
Each middleware can add to the context, building up a rich object:
```ts
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 [#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()` [#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 [#basic-usage]
`useValidated()` can only be called **after** `inputSchema()` or `bindArgsSchemas()`:
```ts title="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 [#execution-order]
All `use()` middleware runs before input validation, and all `useValidated()` middleware runs after. The chain declaration order matches the execution order:
```ts title="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 [#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` | Call the next middleware or server code |
### Schema transforms are visible [#schema-transforms-are-visible]
If your schema includes transforms, `useValidated()` middleware sees the **transformed** output, while `clientInput` retains the original:
```ts title="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 [#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.
```ts
// ✅ 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 [#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:
```ts title="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? [#whats-next]
Create reusable middleware with createMiddleware() and createValidatedMiddleware().
Full .use() and .useValidated() method signatures and type definitions.
# Optimistic updates (/docs/guides/optimistic-updates)
The `useOptimisticAction` hook lets you update the UI **immediately** when the user performs an action, without waiting for the server response. If the server confirms the change, the optimistic state is replaced by the real data. If the server fails, the state reverts automatically.
## How it works [#how-it-works]
1. User triggers the action (e.g., clicks "Add Todo")
2. The `updateFn` runs immediately with the current state and input → UI updates instantly
3. The action executes on the server in the background
4. On success: the page revalidates and real data replaces the optimistic state
5. On failure: the optimistic state reverts to the original
## Full example: Todo list [#full-example-todo-list]
### Define the action [#define-the-action]
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { actionClient } from "@/lib/safe-action";
export const addTodo = actionClient
.inputSchema(z.object({ title: z.string().min(1) }))
.action(async ({ parsedInput }) => {
// Save to database
await db.todo.create({ data: { title: parsedInput.title } });
// Revalidate the page so the server component re-fetches
revalidatePath("/todos");
return { title: parsedInput.title };
});
```
### Create the Server Component (data fetcher) [#create-the-server-component-data-fetcher]
```tsx title="src/app/todos/page.tsx"
import { db } from "@/lib/db";
import { TodoList } from "./todo-list";
export default async function TodosPage() {
const todos = await db.todo.findMany();
return ;
}
```
### Create the Client Component with optimistic updates [#create-the-client-component-with-optimistic-updates]
```tsx title="src/app/todos/todo-list.tsx"
"use client";
import { useOptimisticAction } from "next-safe-action/hooks";
import { addTodo } from "../actions";
type Todo = { id: string; title: string };
export function TodoList({ todos }: { todos: Todo[] }) {
const { execute, optimisticState, isExecuting } = useOptimisticAction(addTodo, {
// The current server state
currentState: todos,
// How to compute the optimistic state from current state + input
updateFn: (currentTodos, input) => {
return [
...currentTodos,
{ id: `temp-${Date.now()}`, title: input.title },
];
},
onError: () => {
// If the server fails, optimisticState reverts automatically
// You can show a toast here
},
});
return (
{optimisticState.map((todo) => (
{todo.title}
))}
);
}
```
## Key concepts [#key-concepts]
### `currentState` [#currentstate]
The current server state, typically passed as a prop from a Server Component. When the page revalidates after a successful action, this prop updates with the real data from the server.
### `updateFn` [#updatefn]
A **pure function** that takes the current state and the action input, and returns the new optimistic state. This runs **synchronously** before the server action starts:
```ts
updateFn: (state, input) => {
// Return a new state with the optimistic change applied
return [...state, { id: "temp", ...input }];
}
```
`updateFn` should not have side effects. It's called during React's render cycle.
### `optimisticState` [#optimisticstate]
The state to render in your component. It's either:
* The **optimistic state** (from `updateFn`) while the action is running
* The **real state** (from `currentState`) when idle or after revalidation
### Automatic revert [#automatic-revert]
If the server action fails, `optimisticState` automatically reverts to `currentState`. You don't need to handle rollbacks manually.
## Return object [#return-object]
`useOptimisticAction` returns the same properties as `useAction` (see [Hooks](/docs/guides/hooks)), plus:
| Property | Type | Description |
| ----------------- | ------- | -------------------------------------- |
| `optimisticState` | `State` | The current optimistic state to render |
All callbacks (`onSuccess`, `onError`, `onSettled`, etc.) work the same way as `useAction`. The return object is also a discriminated union: checking `status` or shorthand booleans like `hasSucceeded` narrows `result` to the matching branch. See [Type narrowing](/docs/guides/hooks#type-narrowing) for details.
## What's next? [#whats-next]
Full useAction guide with all return properties and callbacks.
Full type signatures for useOptimisticAction.
# Migration from v3 to v4 (/docs/migrations/v3-to-v4)
Version 4.x.x of `next-safe-action` introduced many improvements, some fixes, and some breaking changes.
This guide will help you migrate from v3 to v4, hopefully without too much trouble.
You can continue to use version 3 of the library if you want to. There are no security implications, since version 4 introduced some new features and changed some functions and properties names. No security patches were committed to v4, at least for the time being, so v3 is currently still safe to use. You'll not get new features in v3, though.
## BREAKING CHANGES [#breaking-changes]
### Safe action client [#safe-action-client]
* `buildContext()` function is now called `middleware()`, and it can still return a context object.
* `serverErrorLogFunction()` function is now called `handleServerErrorLog()`.
### Hooks [#hooks]
* `res` object is now called `result`.
* Action status before was reported through returned `hasExecuted`, `isExecuting`, `hasSucceeded` and `hasErrored` properties. Now there's a single property of type string called `status` that contains the current action status, and it can be `"idle"`, `"executing"`, `"hasSucceeded"` or `"hasErrored"`.
* Reorganized callbacks arguments for `onSuccess` and `onError`:
* from `onSuccess(data, reset, input)` to `onSuccess(data, input, reset)`
* from `onError(error, reset, input)` to `onError(error, input, reset)`
* `useOptimisticAction` just required a safe action and an initial optimistic state before. Now it requires a `reducer` function too, that determines the behavior of the optimistic state update when the `execute` function is called. Also, now only one input argument is required by `execute`, instead of two. The same input passed to the actual safe action is now passed to the `reducer` function too, as the second argument (`input`). More information about this hook can be found [here](/docs/execute-actions/hooks/useoptimisticaction).
### Types [#types]
* `ActionDefinition` is now called `ServerCode`.
* `HookRes` is now called `HookResult`.
* `ClientCaller` is now called `SafeAction`.
## New features [#new-features]
### Hooks [#hooks-1]
* Added optional `onSettled` callback for `useAction` and `useOptimisticAction` hooks. It gets executed if the action succeeds or fails, after `onSuccess` and `onError`.
## Fixes [#fixes]
* Fixed an issue with Zod input validation parsing. Before, if an async `superRefine()` was used when defining the schema, the validation would fail, resulting in a `serverError` response for the client. Now the validation is done through `safeParseAsync()`, so the problem is gone.
## Misc [#misc]
### Safe action client [#safe-action-client-1]
* Now `Context` returned by `middleware()` (previously called `buildContext()` in v3) is not required to be an object anymore, it can be of any type.
### Hooks [#hooks-2]
* Before, you had to return an object from actions you wanted to execute via `useOptimisticAction` hook. Now, with the new exposed `reducer` function (see above), you can return anything you want from action server code body.
# Migration from v4 to v5 (/docs/migrations/v4-to-v5)
Version 5.x.x of `next-safe-action` is required for Next.js >= 14 applications.
You can continue to use version 4 of the library, compatible with Next.js 13: `npm i next-safe-action@4`
## BREAKING CHANGES [#breaking-changes]
Server Actions are now stable, so there's no need to enable them as an experimental feature in your Next.js config file anymore:
```diff title="next.config.js"
module.exports = {
- experimental: {
- serverActions: true
- }
}
```
### Internal changes (hooks) [#internal-changes-hooks]
React now exports `useOptimistic` hook, instead of the previous `experimental_useOptimistic`. This is why a new major version of `next-safe-action` is required for Next.js >= 14 apps.
# Migration from v5 to v6 (/docs/migrations/v5-to-v6)
## What's new? [#whats-new]
With next-safe-action version 6, you can now use a wide range of validation libraries, even multiple and custom ones at the same time, thanks to the great [TypeSchema](https://typeschema.com/) library. You can find supported libraries [here](https://typeschema.com/#coverage).
Existing code will not be affected, since Zod is supported by TypeSchema. However, now you can for example define a new safe action using [Yup](https://github.com/jquense/yup) or [Valibot](https://valibot.dev/), while still keeping existing actions with Zod validation, and everything will be handled internally by next-safe-action, thanks to the TypeSchema abstractions.
## BREAKING CHANGES [#breaking-changes]
### Action result object [#action-result-object]
* Property `validationError` is now called `validationErrors`.
### Safe action client [#safe-action-client]
* `handleReturnedServerError()` function now directly returns the server error message as a `string`, instead of a `{ serverError: string }` object.
### Hooks [#hooks]
Hooks are now exported from `next-safe-action/hooks` instead of `next-safe-action/hook`.
### Types [#types]
* `ServerCode` is now called `ServerCodeFn`.
## Misc changes [#misc-changes]
### Types [#types-1]
* Exported new `SafeClientOpts` type, which represents the options for the safe action client, used internally by `createSafeActionClient()` function.
# Migration from v6 to v7 (/docs/migrations/v6-to-v7)
## What's new? [#whats-new]
Well, pretty much everything. Version 7 now works using methods; you might be familiar with this design if you have worked with [tRPC](https://trpc.io/) or [Kysely](https://kysely.dev/). A complete rewrite of the library in this direction was needed to vastly improve next-safe-action's APIs, and ensure that future versions will not break them (unless React/Next.js APIs change under the hood). The new design is much more resilient, powerful and flexible.
## TL;DR [#tldr]
*But please still read this migration guide carefully before upgrading to v7.*
Assuming you're using Zod, in previous versions, you'd define an auth action client and then an action like this:
```typescript title="action-client-v6.ts"
import { createSafeActionClient } from "next-safe-action";
import { cookies } from "next/headers";
// Base client
export const baseActionClient = createSafeActionClient();
// Auth client
export const authActionClient = createSafeActionClient({
async middleware(parsedInput) {
const session = cookies().get("session")?.value;
if (!session) {
throw new Error("Session not found!");
}
const userId = await getUserIdFromSessionId(session);
if (!userId) {
throw new Error("Session is not valid!");
}
return { userId };
},
});
```
```typescript title="action-v6.ts"
"use server";
import { authActionClient } from "@/lib/safe-action";
import { z } from "zod";
export const editProfile = authActionClient(z.object({ username: z.string() }), async ({ username }, { ctx: { userId } }) => {
await saveNewUsernameInDb(userId, username);
return {
updated: true,
}
})
```
The same behavior can be achieved in v7 with the following refectored code:
```typescript title="action-client-v7.ts"
import { createSafeActionClient } from "next-safe-action";
import { cookies } from "next/headers";
// Base client
export const actionClient = createSafeActionClient();
// Auth client
export const authActionClient = actionClient.use(async ({ next, ctx }) => {
const session = cookies().get("session")?.value;
if (!session) {
throw new Error("Session not found!");
}
const userId = await getUserIdFromSessionId(session);
if (!userId) {
throw new Error("Session is not valid!");
}
return next({ ctx: { userId } });
});
```
```typescript title="action-v7.ts"
"use server";
import { authActionClient } from "@/lib/safe-action";
import { z } from "zod";
export const editProfile = authActionClient
.schema(z.object({ username: z.string() }))
.action(async ({ parsedInput: { username }, ctx: { userId } }) => {
await saveNewusernameInDb(userId, username)
return {
updated: true,
}
});
```
## New features [#new-features]
### [Allow setting validation errors in action server code function](https://github.com/TheEdoRan/next-safe-action/issues/62) [#allow-setting-validation-errors-in-action-server-code-function]
Sometimes it's useful to set custom validation errors in the action server code function, for example when the user wants to log in, but there was a problem with the email or password fields. next-safe-action v7 introduces a new function called [`returnValidationErrors`](/docs/define-actions/validation-errors#returnvalidationerrors) that allows you to do that.
### [Support schema nested objects validation](https://github.com/TheEdoRan/next-safe-action/issues/51) [#support-schema-nested-objects-validation]
Before v7, next-safe-action allowed you to define schemas with nested objects, but validation errors were not correctly set for nested fields. Version 7 of the library changes the returned errors to be an object with nested fields, that emulates Zod's [`format`](https://zod.dev/ERROR_HANDLING?id=formatting-errors) method.
### [Support middleware chaining](https://github.com/TheEdoRan/next-safe-action/issues/90) [#support-middleware-chaining]
This is a core change in next-safe-action v7. In previous versions, you could define just one "monolithic" middleware at the instance level. So, the previous workflow was to define multiple safe action clients, each one with its own middleware.
With version 7, you can chain multiple middleware functions using the [`use`](/docs/define-actions/instance-methods#use) method, both at the instance level and at the action level. This is explained in detail in the [middleware page](/docs/define-actions/middleware) of the documentation. The new design is much more flexible and powerful, allowing you to do things that just couldn't be done before, such as extending context, logging action execution, [integrating with third party systems for error reporting](https://github.com/TheEdoRan/next-safe-action/issues/39#issuecomment-2062387039), etc.
### [Generic type for `serverError`](https://github.com/TheEdoRan/next-safe-action/issues/86) [#generic-type-for-servererror]
The `serverError` property of the [action result object](/docs/define-actions/action-result-object) is now of generic type. By default it's a `string` with a default value of "Something went wrong while executing the operation.". You can customize error value and type using the [`handleServerError`](/docs/define-actions/create-the-client#handleservererror) initialization function, just like pre-v7. Basically, what you return from that function is what `serverError` will be on the client.
### [Support binding additional arguments](https://github.com/TheEdoRan/next-safe-action/issues/29) [#support-binding-additional-arguments]
Next.js allows you to [pass additional arguments to the action](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#passing-additional-arguments) using JavaScript `bind` method. This approach has the advantage of supporting progressive enhancement.
next-safe-action v7 supports bind arguments via the [`bindArgsSchemas`](/docs/define-actions/instance-methods#bindargsschemas) method.
### [Support setting default validation errors shape per instance](https://github.com/TheEdoRan/next-safe-action/issues/153) [#support-setting-default-validation-errors-shape-per-instance]
By default, next-safe-action v7 returns validation errors in an object of the same shape as Zod's [`format`](https://zod.dev/ERROR_HANDLING?id=formatting-errors) method. You can override this behavior globally by setting the [`defaultValidationErrorsShape`](/docs/define-actions/create-the-client#defaultvalidationerrorsshape) optional property to `flattened` in `createSafeActionClient` method. Doing so, the validation errors are returned in the shape of the Zod's [`format`](https://zod.dev/ERROR_HANDLING?id=formatting-errors) method. If you need a custom format for a specific action, you can override the default shape using the `handleValidationErrorsShape` and `handleBindArgsValidationErrorsShape` optional functions in `schema` and `bindArgsSchemas` methods, as explained below.
### [Support custom validation errors shape](https://github.com/TheEdoRan/next-safe-action/issues/98) [#support-custom-validation-errors-shape]
As already said above, by default version 7 now returns validation errors in the same format of the Zod's [`format`](https://zod.dev/ERROR_HANDLING?id=formatting-errors) method.
This is customizable by using the `handleValidationErrorsShape`/`handleBindArgsValidationErrorsShape` optional functions in `schema`/`bindArgsSchemas` methods. Check out [this page](/docs/define-actions/validation-errors#customize-validation-errors-format) for more information. For example, if you need to work with flattened errors for a specific action, next-safe-action conveniently provides two functions to do that: [`flattenValidationErrors` and `flattenBindArgsValidationErrors`](/docs/define-actions/validation-errors#formatvalidationerrors-utility-function).
### [Allow calling `action` method without `schema`](https://github.com/TheEdoRan/next-safe-action/issues/107) [#allow-calling-action-method-without-schema]
Sometimes it's not necessary to define an action with input. In this case, you can omit the [`schema`](/docs/define-actions/instance-methods#schema) method and use directly the [`action`/`stateAction`](/docs/define-actions/instance-methods#action--stateaction) method.
### [Support passing schema via async function](https://github.com/TheEdoRan/next-safe-action/issues/155) [#support-passing-schema-via-async-function]
When working with i18n solutions, often you'll find implementations that require awaiting a `getTranslations` function in order to get the translations, that then get passed to the schema. Starting from version 7, next-safe-action allows you to pass an async function to the [`schema`](/docs/define-actions/instance-methods#schema) method, that returns a promise of type `Schema`. More information about this feature can be found in [this discussion](https://github.com/TheEdoRan/next-safe-action/discussions/111) on GitHub and in the [i18n](/docs/recipes/i18n) recipe page.
### [Support action execution callbacks](https://github.com/TheEdoRan/next-safe-action/issues/162) [#support-action-execution-callbacks]
It's sometimes useful to be able to execute custom logic on the server side after an action succeeds or fails. Starting from version 7, next-safe-action allows you to pass action callbacks when defining an action. More information about this feature can be found [here](/docs/define-actions/action-utils#action-callbacks).
### [Support stateful actions using React `useActionState` hook](https://github.com/TheEdoRan/next-safe-action/issues/91) [#support-stateful-actions-using-react-useactionstate-hook]
React added a hook called `useActionState` that replaces the previous `useFormState` hook and improves it. next-safe-action v7 uses it under the hood in the exported [`useStateAction`](/docs/execute-actions/hooks/usestateaction) hook, that keeps track of the state of the action execution.
Note that this hook expects as argument actions defined using the `stateAction` method, and not the usual `action` method. Find more information about these two methods [here](/docs/define-actions/instance-methods#action--stateaction).
The `useActionState` hook requires Next.js >= 15 to work, because previous versions do not support the React's [`useActionState`](https://react.dev/reference/react/useActionState) hook that is used under the hood. In the meantime, you can use the [`stateAction`](/docs/define-actions/instance-methods#action--stateaction) method manually with React 18's `useFormState` hook.
The `useActionState` hook is exported from `next-safe-action/stateful-hooks` path, unlike the other two hooks. This is because it uses React 19 features and would cause build errors in React 18.
### [Return input from hooks](https://github.com/TheEdoRan/next-safe-action/issues/117) [#return-input-from-hooks]
Sometimes it's useful to access the input passed to an action when using hooks. Starting from version 7, `input` property is returned from hooks.
### [Return shorthand statuses from hooks](https://github.com/TheEdoRan/next-safe-action/issues/133) [#return-shorthand-statuses-from-hooks]
Starting from version 7, `isIdle`, `isExecuting`, `hasSucceeded` and `hasErrored` are returned from hooks, in addition to the `status` property. This is the same behavior of next-safe-action pre-v4 and very similar to the [TanStack Query](https://tanstack.com/query/latest) API.
### [Return `executeAsync` from `useAction` and `useOptimisticAction` hooks](https://github.com/TheEdoRan/next-safe-action/issues/146) [#return-executeasync-from-useaction-and-useoptimisticaction-hooks]
Sometimes it's useful to await the result of an action execution when using actions via hooks. Starting from version 7, `executeAsync` is returned from `useAction` and `useOptimisticAction` hooks. It's essentially the same as the original safe action function, with the added benefits of the hooks execution behavior. Note that it's currently not possible to return this function from the `useStateAction` hook, due to internal React limitations.
## Refactors [#refactors]
### `serverCodeFn` signature [#servercodefn-signature]
Previously, `serverCodeFn` had two arguments: `parsedInput` and `ctx`. Now, it only has one argument, which is an object that contains `parsedInput` and `ctx`, and other useful properties. In the case of [`stateAction`](/docs/define-actions/instance-methods#action--stateaction) method, `serverCodeFn` also has an additional argument, which is an object that contains the previous result of the action. Find more information about `serverCodeFn` [here](/docs/define-actions/instance-methods#servercodefn).
### `useOptimisticAction` signature [#useoptimisticaction-signature]
The function signature for `useOptimisticAction` has been updated to be much more clear and readable. Before, you had to pass `currentState` and `updateFn` as the second and third argument of the hook. Now, the first argument is the safe action, and additional required and optional properties are placed inside the second argument of the hook, which is an object.
Other than that, now `currentState` is unlinked from the safe action's return value. The action purpose in optimistic state updates is just to make mutations of data. Then, the fresh data is refetched from the parent Server Component, so it didn't make sense to lock the type of `currentState` to the action's return type. This is explained in detail [here](https://github.com/TheEdoRan/next-safe-action/discussions/127#discussioncomment-9480520) and [here](https://github.com/TheEdoRan/next-safe-action/pull/134).
Find more information about the updated `useOptimisticAction` hook [here](/docs/execute-actions/hooks/useoptimisticaction).
### Hook callbacks arguments [#hook-callbacks-arguments]
Previously, there were multiple arguments in hook callbacks. Now, metadata is passed inside a single object that is the first argument of each function. Find more information about the updated callbacks [here](/docs/execute-actions/hooks/hook-callbacks).
### Action metadata [#action-metadata]
In version 6, you could pass metadata to actions via the third argument of the safe action function, after `serverCodeFn`. In version 7, there's a dedicated `metadata` method that lets you define useful data for the action execution. This data can then be accessed in middleware functions and `serverCodeFn`. Find more information about the `metadata` method [here](/docs/define-actions/instance-methods#metadata).
## Internal changes [#internal-changes]
### TypeSchema update [#typeschema-update]
TypeSchema was updated to v0.13, so now, if you want to use a validation library other than Zod, you also need to install the related [TypeSchema adapter](https://typeschema.com/#coverage).
## Requirements [#requirements]
next-safe-action version 7 requires Next.js 14 and React 18.2.0 or later to work. For `useActionState` hook, the minimum required Next.js version is 15, since previous versions don't support the React's `useStateAction` hook that is used under the hood. The `useActionState` hook is exported from `next-safe-action/stateful-hooks` path.
## What about v6? [#what-about-v6]
You can still keep using version 6 and eventually upgrade to version 7. Note that version 6 is frozen and no new features will be released in the future for it. v6 documentation can still be found [here](https://v6.next-safe-action.dev).
# Migration from v7 to v8 (/docs/migrations/v7-to-v8)
Version 8 introduces significant changes to the validation system, improves type safety for metadata, and fixes next/navigation behaviors.
Legend:
* ⚠️ Breaking change
* 🆕 New feature
* ✨ Improvement
* 🔄 Refactor
## What's new? [#whats-new]
### ⚠️🆕 Standard Schema support [#️-standard-schema-support]
The biggest change in v8 is the switch to [Standard Schema](https://github.com/standard-schema/standard-schema) for validation. This removes the need for internal custom validation adapters and simplifies the API. You can find the supported Standard Schema libraries [here](https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec).
```typescript title="v7 - using Valibot"
import { createSafeActionClient } from "next-safe-action";
import { valibotAdapter } from "next-safe-action/adapters/valibot";
export const actionClient = createSafeActionClient({
validationAdapter: valibotAdapter(),
});
```
```typescript title="v8"
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient();
```
### ⚠️🆕 Navigation status and callbacks [#️-navigation-status-and-callbacks]
The behavior when using functions from `next/navigation` was very unclear and confusing in v7 and below, since all functions from `next/navigation` produced a `hasSucceeded` status and triggered `onSuccess` callbacks.
This behavior has been changed in v8. Now, when you're using functions imported from `next/navigation` in an action:
* the hooks `status` value will be `"hasNavigated"` instead of `"hasSucceeded"`;
* a new `onNavigation()` callback will be triggered, both for actions and hooks, instead of `onSuccess()`. This callback receives a `navigationKind` value, that indicates the type of navigation that occurred;
* the `success` property of the middleware result will now be `false`, instead of `true`, if a navigation function was called in a middleware function or in the action's server code function.
```typescript
import { useAction } from "next-safe-action/hooks";
import { redirect } from "next/navigation";
// In the action definition
const action = actionClient.action(
async () => {
redirect("/");
},
{
onNavigation: async ({ navigationKind }) => {
// Do something with the navigation...
},
}
);
// In the component
const { execute, status } = useAction(action, {
onNavigation: ({ navigationKind }) => {
// Do something with the navigation...
},
});
```
### ⚠️✨ Stricter bound args validation [#️-stricter-bound-args-validation]
When using bound arguments with invalid data, errors are now thrown instead of being returned as part of the result object. This is because bound arguments should not be passed to the action from the user, instead they should be received from the server. So, this change aligns the behavior of bound arguments with the behavior of metadata and output validation, and prevents potential leakage of sensitive data on the client side.
```typescript title="v7"
const boundAction = action.bind(null, invalidBindArg);
// No error thrown on the server, `bindArgsValidationErrors`
// gets returned to the client in the result object.
const { bindArgsValidationErrors } = await boundAction(input);
```
```typescript title="v8"
const boundAction = action.bind(null, invalidBindArg);
// No bound arg errors are returned to the client, instead
// an `ActionBindArgsValidationError` is thrown on the server.
const result = await boundAction(input);
```
### ⚠️ Removal of deprecated `executeOnMount` hook option [#️-removal-of-deprecated-executeonmount-hook-option]
The deprecated `executeOnMount` hook functionality has been removed in v8. Server Actions should be used only for mutations, so it doesn't make sense to execute them on mount. Or at least, it shouldn't be a common case and, above all, a library job. If you still need to do it, just use `useEffect()` to trigger the execution, however you want.
### ✨ Type-checked metadata [#-type-checked-metadata]
This is a big improvement in type safety over v7. Metadata is now statically type-checked when passed to actions. So, now if you forget to pass the expected metadata shape, as defined by the `defineMetadataSchema` init option, you will get a type error immediately:
### ✨ Custom thrown validation error messages [#-custom-thrown-validation-error-messages]
The `throwValidationErrors` option now accepts both a boolean (just like in v7) and an object with a `overrideErrorMessage()` function, that allows you to customize the thrown `Error` message on the client side.
```typescript
import { throwValidationErrors, overrideErrorMessage } from "next-safe-action";
const action = actionClient
.inputSchema(z.object({ name: z.string() }))
.action(
async () => {
return {
success: true,
};
},
{
throwValidationErrors: {
// If input validation fails, here we can customize the error message
// returned to the client.
overrideErrorMessage: async (validationErrors) => {
return validationErrors.name?._errors?.join(" ") ?? "";
},
},
}
);
```
### ✨ Added `transitioning` status [#-added-transitioning-status]
The action status, when using hooks, now can be `transitioning` as well. This value is set when the action has finished executing, but the transition is not yet complete. The transition state is managed by the [`useTransition()`](https://react.dev/reference/react/useTransition) React hook used under the hood in next-safe-action hooks.
### 🔄 Safe action result always defined [#-safe-action-result-always-defined]
The action result object is now always defined. This allows you to destructure it without the need to check if it's defined or not first:
```typescript title="v7"
// Cannot access data directly, we need to check if
// result is defined first.
const result = await action(input);
if (result?.data) {
// Do something with the data...
}
```
```typescript title="v8"
// Now we can destructure the result object and
// access data directly.
const { data } = await action(input);
```
### 🔄 `schema` method renamed to `inputSchema` [#-schema-method-renamed-to-inputschema]
The library, since version 7.8.0, supports both input and output validation, respectively using the `schema()` and `outputSchema()` methods. In v8, the `schema()` method has been renamed to `inputSchema()` to better reflect its purpose, and avoid potential confusion.
The `schema()` method is deprecated and will be removed in a future version, but it's still available for backward compatibility. It's now just an alias for `inputSchema()`:
```typescript title="v7"
actionClient.schema(/* ... */)
```
```typescript title="v8"
actionClient.inputSchema(/* ... */)
```
To update your actions, you can just use the search and replace feature of your editor to replace all occurrences of `.schema()` with `.inputSchema()`.
## Requirements [#requirements]
next-safe-action version 8 requires Next.js 14 and React 18.2.0 or later to work.
## What about v7? [#what-about-v7]
You can find the v7 documentation [here](https://v7.next-safe-action.dev).
# Better Auth (/docs/integrations/better-auth)
next-safe-action has a first-party adapter for [Better Auth](https://www.better-auth.com) that provides a `betterAuth()` function. It fetches the session, blocks unauthenticated requests, and injects fully-typed `{ user, session }` data into the action context, including any fields added by Better Auth plugins.
## Installation [#installation]
npm
pnpm
yarn
bun
```bash
npm install next-safe-action better-auth @next-safe-action/adapter-better-auth
```
```bash
pnpm add next-safe-action better-auth @next-safe-action/adapter-better-auth
```
```bash
yarn add next-safe-action better-auth @next-safe-action/adapter-better-auth
```
```bash
bun add next-safe-action better-auth @next-safe-action/adapter-better-auth
```
## Quick start [#quick-start]
### Set up Better Auth [#set-up-better-auth]
Create your Better Auth server instance. If your actions need to set cookies (e.g. `signInEmail`, `signUpEmail`), add the `nextCookies()` plugin:
```ts title="src/lib/auth.ts"
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
export const auth = betterAuth({
// ...your config (database, plugins, etc.)
plugins: [
// ...other plugins
nextCookies(), // must be the last plugin in the array
],
});
```
The `nextCookies()` plugin is only required if you call Better Auth functions that set cookies (like `signInEmail` or `signUpEmail`) from Server Actions. It uses the Next.js `cookies()` helper to set cookies when a `Set-Cookie` header is present in the response. If your actions only read the session, you can skip it. Refer to the [Better Auth documentation](https://better-auth.com/docs/integrations/next#server-action-cookies) for more details.
### Create an authenticated action client [#create-an-authenticated-action-client]
Use `betterAuth()` to add authentication to your action client:
```ts title="src/lib/safe-action.ts"
import { createSafeActionClient } from "next-safe-action";
import { betterAuth } from "@next-safe-action/adapter-better-auth";
import { auth } from "./auth";
// Public action client (no auth required)
export const actionClient = createSafeActionClient();
// Authenticated action client
export const authClient = actionClient.use(betterAuth(auth));
```
### Use it in your actions [#use-it-in-your-actions]
Actions defined with `authClient` have typed access to `ctx.auth.user` and `ctx.auth.session`:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { authClient } from "@/lib/safe-action";
export const updateProfile = authClient
.inputSchema(z.object({ name: z.string().min(1) }))
.action(async ({ parsedInput, ctx }) => {
// ctx.auth.user and ctx.auth.session are fully typed,
// including fields from Better Auth plugins
const userId = ctx.auth.user.id;
await db.user.update({
where: { id: userId },
data: { name: parsedInput.name },
});
return { success: true };
});
```
## How it works [#how-it-works]
`betterAuth()` creates a pre-validation middleware for the safe action client's `.use()` chain:
1. **Fetches the session** by calling `auth.api.getSession({ headers: await headers() })` using the request headers from `next/headers`
2. **Blocks unauthenticated requests** by calling `unauthorized()` from `next/navigation` when no session exists
3. **Injects typed context** by passing `{ auth: { user, session } }` to `next()`, merging it into the action context
The context is namespaced under `auth` to avoid collisions with other middleware that might add their own context properties.
### Type inference [#type-inference]
The middleware infers the exact `user` and `session` types from your Better Auth instance, including any fields added by plugins. For example, if you use the `organization` plugin, `ctx.auth.session` will include `activeOrganizationId`. No manual type annotations are needed.
## `unauthorized()` and auth interrupts [#unauthorized-and-auth-interrupts]
The default behavior uses `unauthorized()` from `next/navigation`, which requires the `authInterrupts` experimental flag in your Next.js configuration:
```ts title="next.config.ts"
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
authInterrupts: true,
},
};
export default nextConfig;
```
When enabled, `unauthorized()` triggers a 401 response that renders your nearest `unauthorized.tsx` boundary. See the [framework errors](/docs/advanced/framework-errors) page for more details on how next-safe-action handles navigation functions.
## Custom authorization with `authorize` [#custom-authorization-with-authorize]
The default flow works for most cases, but you can customize the authorization logic by passing an `authorize` callback. The session is pre-fetched and passed to the callback, so common customizations like role checks don't need to re-fetch:
### Role-based access [#role-based-access]
```ts title="src/lib/safe-action.ts"
import { unauthorized } from "next/navigation";
import { betterAuth } from "@next-safe-action/adapter-better-auth";
import { auth } from "./auth";
export const adminClient = actionClient.use(
betterAuth(auth, {
authorize: ({ authData, next }) => {
if (!authData || authData.user.role !== "admin") {
unauthorized();
}
return next({ ctx: { auth: authData } });
},
})
);
```
### Redirect instead of 401 [#redirect-instead-of-401]
```ts title="src/lib/safe-action.ts"
import { redirect } from "next/navigation";
import { betterAuth } from "@next-safe-action/adapter-better-auth";
import { auth } from "./auth";
export const authClient = actionClient.use(
betterAuth(auth, {
authorize: ({ authData, next }) => {
if (!authData) {
redirect("/login");
}
return next({ ctx: { auth: authData } });
},
})
);
```
### `authorize` callback parameters [#authorize-callback-parameters]
## Server Action cookies [#server-action-cookies]
When calling Better Auth functions that mutate cookies from Server Actions (e.g. `signInEmail`, `signUpEmail`), cookies won't be set by default. This is because Server Actions need to use the Next.js `cookies()` helper to set cookies.
To handle this automatically, add the `nextCookies()` plugin to your Better Auth server instance:
```ts title="src/lib/auth.ts"
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
export const auth = betterAuth({
// ...your config
plugins: [
// ...other plugins
nextCookies(), // must be the last plugin in the array
],
});
```
With this plugin, any Better Auth function called from a Server Action that returns a `Set-Cookie` header will automatically set the cookie via Next.js:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { authClient } from "@/lib/safe-action";
export const signIn = authClient
.inputSchema(z.object({ email: z.string().email(), password: z.string() }))
.action(async ({ parsedInput, ctx }) => {
// This works because nextCookies() handles cookie setting
await ctx.auth.session; // session is already available from the middleware
// Or call other Better Auth functions that set cookies:
// await auth.api.signInEmail({ body: parsedInput });
});
```
## Package entry points [#package-entry-points]
| Entry point | Exports | Environment |
| --------------------------------------- | ------------ | ----------- |
| `@next-safe-action/adapter-better-auth` | `betterAuth` | Server |
### Exported types [#exported-types]
| Type | Description |
| ---------------------------------- | ------------------------------------------------------------------------ |
| `BetterAuthContext` | The context shape added by the middleware: `{ auth: { user, session } }` |
| `AuthorizeFn` | The `authorize` callback signature |
| `BetterAuthOpts` | The options object type for `betterAuth` |
***
## See also [#see-also]
* [Middleware guide](/docs/guides/middleware): how middleware and context chaining work
* [Framework errors](/docs/advanced/framework-errors): handling `unauthorized()`, `redirect()`, and other navigation functions
* [Standalone middleware](/docs/advanced/standalone-middleware): `createMiddleware()` for reusable middleware
* [TanStack Query integration](/docs/integrations/tanstack-query): sibling adapter for TanStack Query mutations
* [React Hook Form integration](/docs/integrations/react-hook-form): sibling adapter for react-hook-form
# React Hook Form (/docs/integrations/react-hook-form)
next-safe-action has a first-party adapter for [React Hook Form](https://react-hook-form.com/) that provides seamless integration between validated server actions and form state management.
## Installation [#installation]
npm
pnpm
yarn
bun
```bash
npm install next-safe-action react-hook-form @hookform/resolvers @next-safe-action/adapter-react-hook-form
```
```bash
pnpm add next-safe-action react-hook-form @hookform/resolvers @next-safe-action/adapter-react-hook-form
```
```bash
yarn add next-safe-action react-hook-form @hookform/resolvers @next-safe-action/adapter-react-hook-form
```
```bash
bun add next-safe-action react-hook-form @hookform/resolvers @next-safe-action/adapter-react-hook-form
```
## Quick start [#quick-start]
### Define the action [#define-the-action]
Create a standard server action with an input schema:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
const createUserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.coerce.number().min(18, "Must be at least 18"),
});
export const createUser = actionClient
.inputSchema(createUserSchema)
.action(async ({ parsedInput }) => {
const user = await db.user.create({ data: parsedInput });
return { user };
});
```
### Create the form component [#create-the-form-component]
Use the `useHookFormAction` hook from the adapter to connect your action with React Hook Form:
```tsx title="src/app/create-user-form.tsx"
"use client";
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { createUser } from "./actions";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.coerce.number().min(18, "Must be at least 18"),
});
export function CreateUserForm() {
const { form, action, handleSubmitWithAction, resetFormAndAction } =
useHookFormAction(createUser, zodResolver(schema), {
formProps: {
defaultValues: {
name: "",
email: "",
age: 18,
},
},
actionProps: {
onSuccess: ({ data }) => {
alert(`Created user: ${data.user.name}`);
resetFormAndAction();
},
},
});
return (
{form.formState.errors.name && (
{form.formState.errors.name.message}
)}
{form.formState.errors.email && (
{form.formState.errors.email.message}
)}
{form.formState.errors.age && (
{form.formState.errors.age.message}
)}
{action.result.serverError && (
{action.result.serverError}
)}
);
}
```
## How it works [#how-it-works]
The adapter bridges two concerns:
1. **Client-side validation**: React Hook Form validates the form using the resolver (zodResolver, valibotResolver, etc.) for instant feedback
2. **Server-side validation**: next-safe-action validates the same data on the server for security
When the form is submitted:
1. React Hook Form validates client-side first
2. If valid, the data is sent to the server action
3. Server-side validation errors are automatically mapped back to the form fields via react-hook-form's `errors` prop
## Package entry points [#package-entry-points]
The adapter is split into two entry points:
| Entry point | Exports | Environment |
| ------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------- |
| `@next-safe-action/adapter-react-hook-form` | `mapToHookFormErrors`, `ErrorMapperProps` | Server & Client |
| `@next-safe-action/adapter-react-hook-form/hooks` | `useHookFormAction`, `useHookFormOptimisticAction`, `useHookFormActionErrorMapper` | Client only |
***
## `useHookFormAction` [#usehookformaction]
The primary hook for using safe actions with React Hook Form. It combines `useAction` and `useForm` into a single hook, automatically mapping server validation errors to form field errors.
```ts
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks";
const { form, action, handleSubmitWithAction, resetFormAndAction } =
useHookFormAction(safeAction, hookFormResolver, props?);
```
### Parameters [#parameters]
### Props [#props]
The optional `props` object has the following shape:
### Return object [#return-object]
### Example [#example]
See the [Quick start](#quick-start) section above for a full example.
***
## `useHookFormOptimisticAction` [#usehookformoptimisticaction]
Combines `useOptimisticAction` and `useForm` into a single hook. Use this when you want optimistic UI updates: the form's result updates immediately before the server responds, then reverts if the action fails.
```ts
import { useHookFormOptimisticAction } from "@next-safe-action/adapter-react-hook-form/hooks";
const { form, action, handleSubmitWithAction, resetFormAndAction } =
useHookFormOptimisticAction(safeAction, hookFormResolver, props);
```
### Parameters [#parameters-1]
### Props [#props-1]
Same as [`useHookFormAction` props](#props), with the following required additions in `actionProps`:
### Return object [#return-object-1]
Same as [`useHookFormAction`](#return-object), but `action` is the return type of `useOptimisticAction` instead of `useAction`. This means `action` also includes `optimisticState`:
### Example [#example-1]
### Define the action [#define-the-action-1]
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { actionClient } from "@/lib/safe-action";
export type Item = { id: string; name: string };
const addItemSchema = z.object({
name: z.string().min(1, "Name is required").max(50),
});
export const addItem = actionClient
.inputSchema(addItemSchema)
.action(async ({ parsedInput }) => {
const item = { ...parsedInput, id: crypto.randomUUID() };
await db.item.create({ data: item });
revalidatePath("/items");
return { newItem: item };
});
```
### Create the Server Component [#create-the-server-component]
```tsx title="src/app/items/page.tsx"
import { db } from "@/lib/db";
import { ItemForm } from "./item-form";
export default async function ItemsPage() {
const items = await db.item.findMany();
return ;
}
```
### Create the Client Component with optimistic updates [#create-the-client-component-with-optimistic-updates]
```tsx title="src/app/items/item-form.tsx"
"use client";
import { useHookFormOptimisticAction } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import type { Item } from "./actions";
import { addItem } from "./actions";
const schema = z.object({
name: z.string().min(1, "Name is required").max(50),
});
export function ItemForm({ items }: { items: Item[] }) {
const { form, action, handleSubmitWithAction, resetFormAndAction } =
useHookFormOptimisticAction(addItem, zodResolver(schema), {
actionProps: {
currentState: { items },
updateFn: (state, input) => ({
items: [...state.items, { ...input, id: crypto.randomUUID() }],
}),
onSuccess() {
form.reset();
},
},
formProps: {
defaultValues: {
name: "",
},
},
});
return (
{form.formState.errors.name && (
{form.formState.errors.name.message}
)}
{action.optimisticState.items.map((item) => (
{item.name}
))}
);
}
```
***
## `useHookFormActionErrorMapper` [#usehookformactionerrormapper]
A lower-level hook for advanced use cases where you want full control over `useAction` and `useForm` separately. It takes a validation errors object and returns react-hook-form-compatible `FieldErrors` that you can pass to `useForm`'s `errors` prop.
```ts
import { useHookFormActionErrorMapper } from "@next-safe-action/adapter-react-hook-form/hooks";
const { hookFormValidationErrors } = useHookFormActionErrorMapper(validationErrors, props?);
```
### Parameters [#parameters-2]
### Return object [#return-object-2]
### Example [#example-2]
```tsx title="src/app/buy-product-form.tsx"
"use client";
import { useHookFormActionErrorMapper } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAction } from "next-safe-action/hooks";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { buyProduct } from "./actions";
const schema = z.object({
productId: z.string().min(1, "Product ID is required"),
quantity: z.coerce.number().min(1, "Must order at least 1"),
});
export function BuyProductForm() {
// Step 1: Use the action hook separately
const {
execute,
result,
status,
reset: resetAction,
isPending,
} = useAction(buyProduct);
// Step 2: Map server validation errors to react-hook-form format
const { hookFormValidationErrors } = useHookFormActionErrorMapper(
result.validationErrors
);
// Step 3: Use the form hook separately, passing mapped errors
const {
register,
handleSubmit,
reset: resetForm,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
errors: hookFormValidationErrors,
defaultValues: {
productId: "",
quantity: 1,
},
});
return (
execute(data))}>
{errors.productId &&
{errors.productId.message}
}
{errors.quantity &&
{errors.quantity.message}
}
);
}
```
This pattern is useful when you need to customize the action hook behavior (e.g. use `execute` instead of `executeAsync`), add logic between form submission and action execution, or integrate with existing `useForm` setups.
***
## `mapToHookFormErrors` [#maptohookformerrors]
A non-hook utility function that maps next-safe-action validation errors to react-hook-form `FieldErrors`. This is the underlying function used by `useHookFormActionErrorMapper` (which wraps it in `useMemo`).
```ts
import { mapToHookFormErrors } from "@next-safe-action/adapter-react-hook-form";
```
### Parameters [#parameters-3]
### Return value [#return-value]
Returns a `FieldErrors` object compatible with react-hook-form, or `undefined` if there are no validation errors. Each field error has `type: "validate"` and a `message` string.
### Example [#example-3]
```ts
import { mapToHookFormErrors } from "@next-safe-action/adapter-react-hook-form";
const validationErrors = {
email: { _errors: ["Invalid email", "Email already taken"] },
name: { _errors: ["Too short"] },
};
const fieldErrors = mapToHookFormErrors(validationErrors);
// → { email: { type: "validate", message: "Invalid email Email already taken" }, name: { type: "validate", message: "Too short" } }
// Use joinBy to customize how multiple errors are joined
const fieldErrors2 = mapToHookFormErrors(validationErrors, { joinBy: ", " });
// → { email: { type: "validate", message: "Invalid email, Email already taken" }, ... }
```
***
## Type utilities [#type-utilities]
The adapter exports two utility types for inferring the return type of the hooks from an action. These are useful when you need to type a variable or prop that holds the hook's return value.
### `InferUseHookFormActionHookReturn` [#inferusehookformactionhookreturn]
Infer the return type of `useHookFormAction` from a safe action function:
```ts
import type { InferUseHookFormActionHookReturn } from "@next-safe-action/adapter-react-hook-form/hooks";
// Given a safe action:
const myAction = actionClient.inputSchema(schema).action(async ({ parsedInput }) => {
return { success: true };
});
// Infer the hook return type:
type MyHookReturn = InferUseHookFormActionHookReturn;
```
### `InferUseHookFormOptimisticActionHookReturn` [#inferusehookformoptimisticactionhookreturn]
Infer the return type of `useHookFormOptimisticAction` from a safe action function and a state type:
```ts
import type { InferUseHookFormOptimisticActionHookReturn } from "@next-safe-action/adapter-react-hook-form/hooks";
type MyState = { items: Item[] };
type MyOptimisticHookReturn = InferUseHookFormOptimisticActionHookReturn;
```
***
## See also [#see-also]
* [Hooks guide](/docs/guides/hooks): the `useAction` hook that the adapter builds on
* [Hooks API reference](/docs/api/hooks-api): full type signatures for `useAction` and `useOptimisticAction`
* [Optimistic updates](/docs/guides/optimistic-updates): `useOptimisticAction` in depth
* [Form actions](/docs/guides/form-actions): alternative form patterns without React Hook Form
* [Custom validation errors](/docs/advanced/custom-validation-errors): customizing error shapes for form display
# Standard Schema (/docs/integrations/standard-schema)
next-safe-action supports any validation library that implements the [Standard Schema](https://github.com/standard-schema/standard-schema) specification (v1). This means you can use Zod, Valibot, ArkType, or any other compliant library, with no adapters or plugins needed.
## Supported libraries [#supported-libraries]
The most popular TypeScript-first schema library. Rich ecosystem, extensive documentation.
Modular schema library optimized for bundle size. Tree-shakeable API.
TypeScript's 1:1 validator with native type syntax and optimized performance.
Any library that implements the `StandardSchemaV1` interface will work with next-safe-action. Check the [Standard Schema repository](https://github.com/standard-schema/standard-schema) for the full list of compliant libraries.
## Usage [#usage]
The API is identical regardless of which library you choose. Just pass your schema to `.inputSchema()` and `.outputSchema()`:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
export const createUser = actionClient
.inputSchema(
z.object({
name: z.string().min(2),
email: z.string().email(),
})
)
.action(async ({ parsedInput }) => {
// parsedInput: { name: string, email: string }
});
```
```ts title="src/app/actions.ts"
"use server";
import * as v from "valibot";
import { actionClient } from "@/lib/safe-action";
export const createUser = actionClient
.inputSchema(
v.object({
name: v.pipe(v.string(), v.minLength(2)),
email: v.pipe(v.string(), v.email()),
})
)
.action(async ({ parsedInput }) => {
// parsedInput: { name: string, email: string }
});
```
```ts title="src/app/actions.ts"
"use server";
import { type } from "arktype";
import { actionClient } from "@/lib/safe-action";
export const createUser = actionClient
.inputSchema(
type({
name: "string >= 2",
email: "string.email",
})
)
.action(async ({ parsedInput }) => {
// parsedInput: { name: string, email: string }
});
```
## How it works [#how-it-works]
next-safe-action doesn't import or depend on any validation library directly. Instead, it uses the Standard Schema protocol:
1. Your schema exposes a `~standard` property with a `validate` method
2. next-safe-action calls `schema["~standard"].validate(input)` at runtime
3. The result is either `{ value }` (success) or `{ issues }` (failure)
4. Type inference works through `StandardSchemaV1.InferInput` and `StandardSchemaV1.InferOutput`
This means:
* **Zero adapter code**, validation libraries work directly
* **Full type inference**, `parsedInput` and validation errors are typed
* **Runtime agnostic**, any compliant library works identically
## Choosing a library [#choosing-a-library]
| Feature | Zod | Valibot | ArkType |
| ------------------ | --------------------------- | ------------------------- | ----------------- |
| **Bundle size** | \~14 KB | \~1-5 KB (tree-shakeable) | \~30 KB |
| **API style** | Method chaining | Functional / pipe-based | TypeScript syntax |
| **Ecosystem** | Largest (adapters, plugins) | Growing | Smaller |
| **Performance** | Good | Good | Excellent |
| **Error messages** | Customizable | Customizable | Built-in |
All three libraries work identically with next-safe-action. Choose based on your project's needs: bundle size, API preference, or ecosystem requirements.
## Mixing libraries [#mixing-libraries]
Since next-safe-action uses Standard Schema at the protocol level, you can even use different libraries for different actions in the same project:
```ts
// Action using Zod
export const createUser = actionClient
.inputSchema(z.object({ name: z.string() }))
.action(async ({ parsedInput }) => { /* ... */ });
// Action using Valibot (in the same project!)
export const updateSettings = actionClient
.inputSchema(v.object({ theme: v.picklist(["light", "dark"]) }))
.action(async ({ parsedInput }) => { /* ... */ });
```
This works because the action client only interacts with schemas through the Standard Schema interface. It doesn't know (or care) which library created the schema.
## See also [#see-also]
* [Input validation](/docs/concepts/input-validation): how schemas are used in the action lifecycle
* [Custom validation errors](/docs/advanced/custom-validation-errors): customizing how validation errors are shaped
* [i18n](/docs/advanced/i18n): async schema factories for translated error messages
# TanStack Query (/docs/integrations/tanstack-query)
next-safe-action has a first-party adapter for [TanStack Query](https://tanstack.com/query) that provides a `mutationOptions()` factory function for use with `useMutation()`. It bridges next-safe-action's result-based error model to TanStack Query's thrown-error model via a typed `ActionMutationError` class.
## Installation [#installation]
npm
pnpm
yarn
bun
```bash
npm install next-safe-action @tanstack/react-query @next-safe-action/adapter-tanstack-query
```
```bash
pnpm add next-safe-action @tanstack/react-query @next-safe-action/adapter-tanstack-query
```
```bash
yarn add next-safe-action @tanstack/react-query @next-safe-action/adapter-tanstack-query
```
```bash
bun add next-safe-action @tanstack/react-query @next-safe-action/adapter-tanstack-query
```
## Quick start [#quick-start]
### Define the action [#define-the-action]
Create a standard server action with an input schema:
```ts title="src/app/actions.ts"
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
const createUserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
});
export const createUser = actionClient
.inputSchema(createUserSchema)
.action(async ({ parsedInput }) => {
const user = await db.user.create({ data: parsedInput });
return { user };
});
```
### Create the mutation component [#create-the-mutation-component]
Use `mutationOptions()` from the adapter with TanStack Query's `useMutation`:
```tsx title="src/app/create-user-form.tsx"
"use client";
import { useMutation } from "@tanstack/react-query";
import { mutationOptions, hasValidationErrors } from "@next-safe-action/adapter-tanstack-query";
import { createUser } from "./actions";
export function CreateUserForm() {
const { mutate, isPending, isError, error, data } = useMutation(
mutationOptions(createUser)
);
return (
{
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutate({
name: formData.get("name") as string,
email: formData.get("email") as string,
});
}}
>
{isError && hasValidationErrors(error) && (
Validation failed
)}
{isError && !hasValidationErrors(error) && (
Server error: {String(error.serverError)}
)}
{data &&
Created: {data.user.name}
}
);
}
```
## Hooks vs. adapter [#hooks-vs-adapter]
next-safe-action provides two ways to call server actions from client components: the **built-in hooks** (`useAction`, `useOptimisticAction`, `useStateAction`) from `next-safe-action/hooks`, and this **TanStack Query adapter** (`mutationOptions`) from `@next-safe-action/adapter-tanstack-query`. Both give you type-safe action execution, but they use fundamentally different mechanisms under the hood and shine in different scenarios.
### How they differ [#how-they-differ]
The built-in hooks are built on top of React 19 concurrent primitives: [`useTransition`](https://react.dev/reference/react/useTransition), [`useOptimistic`](https://react.dev/reference/react/useOptimistic), and [`useActionState`](https://react.dev/reference/react/useActionState). When you call `execute()`, the action runs inside a React Transition, which means React keeps the current UI responsive while the action is in flight, and features like Suspense boundaries and optimistic state rollbacks integrate automatically. The hooks also work directly with next-safe-action's result envelope, so you access `result.data`, `result.serverError`, and `result.validationErrors` as structured fields without any error transformation.
The TanStack Query adapter delegates all state management to TanStack Query's `useMutation`. It bridges the result envelope to TanStack Query's thrown-error model: when an action returns `serverError` or `validationErrors`, the adapter extracts them from the result and wraps them in a typed `ActionMutationError` instance on the client. In return, you gain TanStack Query's full mutation lifecycle: configurable retry strategies with exponential backoff, client-side query cache invalidation after mutations (in addition to Next.js's server-side `revalidatePath`/`revalidateTag`, which work with both approaches), TanStack Query DevTools integration, mutation persistence for offline support, and the ability to coordinate mutations with queries in a single client cache.
### When to use which [#when-to-use-which]
| Scenario | Recommendation |
| -------------------------------------------------------------------------------- | -------------------------------------- |
| New Next.js project without TanStack Query | Built-in hooks |
| Simple form submissions and button actions | Built-in hooks |
| You want instant optimistic UI via React's `useOptimistic` | Built-in hooks (`useOptimisticAction`) |
| You want zero additional dependencies | Built-in hooks |
| Already using TanStack Query for data fetching | Adapter |
| Already using tRPC + TanStack Query | Adapter |
| You need automatic retries with backoff | Adapter |
| You need to invalidate TanStack Query's client cache after a mutation | Adapter |
| You want TanStack Query DevTools visibility for mutations | Adapter |
| You need mutations to survive page reloads or network loss (offline persistence) | Adapter |
### Feature comparison [#feature-comparison]
| Feature | Built-in hooks | Adapter |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| React Transitions | Yes, actions run inside `startTransition` | No |
| Optimistic updates | `useOptimisticAction` via React's `useOptimistic` | Manual via `onMutate` + query cache |
| Automatic retries | No | Yes, `retry` option with backoff |
| Server cache invalidation | Yes, `revalidatePath()` / `revalidateTag()` inside server actions | Yes, same Next.js APIs inside server actions |
| Client query cache invalidation | No (not applicable) | Yes, `queryClient.invalidateQueries()` in `onSuccess` |
| DevTools | No | Yes, TanStack Query DevTools |
| Error model | Result envelope (`result.serverError`, `result.validationErrors`) | Thrown `ActionMutationError` with type guards |
| Offline mutation persistence | No, state is lost on unmount or reload | Yes, paused mutations can be serialized to storage and resumed via `dehydrate`/`hydrate` |
| Async execution | `executeAsync()` returns `Promise` | `mutateAsync()` returns `Promise` |
| Status tracking | `status` string + shorthand booleans (`isIdle`, `isPending`, `hasSucceeded`, `hasErrored`) | Boolean flags (`isPending`, `isError`, `isSuccess`) |
| Extra dependencies | None (React only) | `@tanstack/react-query` |
### General guidance [#general-guidance]
**Prefer built-in hooks** for most Next.js applications. They require no extra dependencies, integrate deeply with React's concurrent rendering model, and give you direct access to the result envelope without error transformation. If your app primarily uses Server Components for data fetching and only needs server actions for mutations (forms, button clicks, state changes), the built-in hooks are the simplest and most natural choice.
**Prefer the adapter** when TanStack Query is already part of your stack, especially alongside tRPC for a type-safe API layer. If you are fetching data with `useQuery` and want mutations to participate in the same client-side cache lifecycle (query invalidation, optimistic cache updates, retry strategies, DevTools inspection), the adapter keeps everything in one ecosystem instead of splitting state management between two systems. Note that Next.js's server-side cache invalidation (`revalidatePath`/`revalidateTag`) works regardless of which approach you choose, since it runs inside the server action itself.
## Why mutations only? [#why-mutations-only]
This adapter intentionally provides only `mutationOptions()` for `useMutation()`. There is **no** `queryOptions()` or `useQuery()` support, by design.
Server Actions in React and Next.js are built exclusively for mutations, not data fetching:
* **POST-only transport.** Server Actions always use `POST`. [Next.js docs](https://nextjs.org/docs/13/app/building-your-application/data-fetching/server-actions-and-mutations): *"Behind the scenes, actions use the POST method, and only this HTTP method can invoke them."* Queries should use `GET`, the correct method for safe, cacheable reads.
* **Sequential queuing.** Server Actions are queued per client to preserve ordering. [Next.js docs](https://nextjs.org/docs/app/guides/backend-for-frontend): *"Server Actions are queued, which means using them for data fetching introduces sequential execution."* This creates request waterfalls where concurrent reads should run in parallel.
* **No HTTP caching.** `POST` requests bypass browser cache, `Cache-Control`, `ETag`, and conditional requests. `useQuery` relies on stable cache keys from URLs and parameters, which Server Actions provide neither.
* **No request deduplication.** Without a stable resource identity, TanStack Query cannot deduplicate simultaneous reads across components.
### What to use instead for data fetching [#what-to-use-instead-for-data-fetching]
* **Server-side reads:** [React Server Components](https://nextjs.org/docs/app/getting-started/fetching-data): data is fetched during rendering on the server with full Next.js caching support.
* **Client-side reads:** Create a [Route Handler](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) (`GET` endpoint) and use `useQuery` / `queryOptions`. This gives you HTTP caching, deduplication, `staleTime`, background refetching, and all TanStack Query cache features.
* **Full-stack type-safe API layer:** If you want end-to-end type safety for both queries (`GET`) and mutations (`POST`), first-class TanStack Query integration, and the ability to share your API procedures across multiple clients or applications, [tRPC](https://trpc.io) is the best fit. tRPC provides `queryOptions()` and `mutationOptions()` factories that plug directly into `useQuery` and `useMutation`, giving you the complete TanStack Query experience (caching, deduplication, background refetching, and optimistic updates) for reads and writes alike.
## How it works [#how-it-works]
The `mutationOptions()` function creates a complete `UseMutationOptions` object that bridges next-safe-action's result envelope to TanStack Query's error model:
1. **Calls the safe action** with the input provided to `mutate()` / `mutateAsync()`
2. **Inspects the result envelope** for `serverError` or `validationErrors`
3. **Throws `ActionMutationError`** if either is present, a custom error class created on the client, so `instanceof` checks work reliably
4. **Returns `data`** directly as TanStack Query's `TData` on success
5. **Handles navigation errors** (`redirect()`, `notFound()`, `forbidden()`, `unauthorized()`) by composing TanStack Query's `throwOnError` option to always re-throw them during React's render phase, allowing Next.js to catch them and perform the navigation
## Package entry points [#package-entry-points]
| Entry point | Exports | Environment |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ----------- |
| `@next-safe-action/adapter-tanstack-query` | `mutationOptions`, `ActionMutationError`, `isActionMutationError`, `hasServerError`, `hasValidationErrors` | Client |
***
## `mutationOptions` [#mutationoptions]
Creates a complete `UseMutationOptions` object for use with `useMutation`.
```ts
import { mutationOptions } from "@next-safe-action/adapter-tanstack-query";
const options = mutationOptions(safeActionFn, opts?);
```
### Parameters [#parameters]
### Return value [#return-value]
Returns a complete `UseMutationOptions, Input, TOnMutateResult>` object, ready to be spread into `useMutation()`.
### Examples [#examples]
#### Basic [#basic]
```tsx
import { useMutation } from "@tanstack/react-query";
import { mutationOptions } from "@next-safe-action/adapter-tanstack-query";
import { createUserAction } from "./actions";
function CreateUserForm() {
const { mutate, isPending, isError, error, data } = useMutation(
mutationOptions(createUserAction)
);
return (
);
}
```
#### With callbacks and query invalidation [#with-callbacks-and-query-invalidation]
```tsx
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { mutationOptions, hasValidationErrors } from "@next-safe-action/adapter-tanstack-query";
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation(mutationOptions(createUserAction, {
onSuccess: (data) => {
toast.success(`Created ${data.name}`);
queryClient.invalidateQueries({ queryKey: ["users"] });
},
onError: (error) => {
if (hasValidationErrors(error)) {
showFieldErrors(error.validationErrors);
} else {
toast.error(`Server error: ${error.serverError}`);
}
},
retry: (count, error) => {
if (hasValidationErrors(error)) return false;
return count < 3;
},
}));
}
```
#### With optimistic updates [#with-optimistic-updates]
```tsx
const mutation = useMutation(mutationOptions(toggleTodoAction, {
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (old) =>
old.map((t) => t.id === input.id ? { ...t, done: !t.done } : t)
);
return { previous };
},
onError: (_error, _input, context) => {
if (context?.previous) {
queryClient.setQueryData(["todos"], context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
}));
```
#### With `mutateAsync` [#with-mutateasync]
```tsx
const { mutateAsync } = useMutation(mutationOptions(createUserAction));
async function handleSubmit(formData: FormData) {
try {
const user = await mutateAsync({ name: formData.get("name") as string });
router.push(`/users/${user.id}`);
} catch (error) {
if (isActionMutationError(error) && hasValidationErrors(error)) {
// handle validation errors
}
}
}
```
***
## Error handling [#error-handling]
When a safe action returns `serverError` or `validationErrors`, the adapter throws an `ActionMutationError`. This means TanStack Query's `isError`, `error`, `failureCount`, and retry mechanism all work naturally:
* `isSuccess` is only `true` when the action succeeds without errors
* `isError` is `true` when the action has server or validation errors
* `error` is a typed `ActionMutationError` with `serverError` and `validationErrors` properties
### `ActionMutationError` [#actionmutationerror]
```ts
class ActionMutationError extends Error {
readonly kind: "server" | "validation" | "both";
readonly serverError?: ServerError;
readonly validationErrors?: ShapedErrors;
}
```
The `kind` property tells you which errors are present:
* `"server"`: only `serverError` is set
* `"validation"`: only `validationErrors` is set
* `"both"`: both are set
### Type guards [#type-guards]
```ts
import {
isActionMutationError,
hasServerError,
hasValidationErrors,
} from "@next-safe-action/adapter-tanstack-query";
// Check if an unknown error is an ActionMutationError
if (isActionMutationError(error)) {
error.serverError; // typed access
error.validationErrors; // typed access
}
// Narrow to server errors
if (hasServerError(error)) {
error.serverError; // guaranteed non-undefined
}
// Narrow to validation errors
if (hasValidationErrors(error)) {
error.validationErrors; // guaranteed non-undefined
}
```
### `throwValidationErrors` / `throwServerError` incompatibility [#throwvalidationerrors--throwservererror-incompatibility]
**Do not** use `throwValidationErrors: true` or `throwServerError: true` on actions passed to `mutationOptions()`.
React's Flight protocol serializes errors thrown in Server Actions across the server–client boundary. Custom error classes are converted to plain `Error` objects, all custom properties (like `validationErrors`) are lost, and `instanceof` checks fail. In production, even the error message is replaced with a generic string.
The adapter relies on the result envelope (the default behavior) to extract structured error data. When `throwValidationErrors` or `throwServerError` is enabled, errors are thrown on the server and lose all structured data before reaching the client.
### Navigation errors [#navigation-errors]
Server actions that call `redirect()`, `notFound()`, `forbidden()`, or `unauthorized()` throw framework-level navigation errors. The adapter automatically handles these by composing TanStack Query's `throwOnError` option to always re-throw navigation errors during React's render phase, allowing Next.js to catch them and perform the navigation.
If you provide your own `throwOnError` option, the adapter composes it: navigation errors are always re-thrown, and your function handles everything else.
***
## Type utilities [#type-utilities]
### `InferMutationOptions` [#infermutationoptions]
Infer the `UseMutationOptions` type from a safe action function:
```ts
import type { InferMutationOptions } from "@next-safe-action/adapter-tanstack-query";
type MyMutationOptions = InferMutationOptions;
```
### `InferActionMutationError` [#inferactionmutationerror]
Infer the `ActionMutationError` type from a safe action function:
```ts
import type { InferActionMutationError } from "@next-safe-action/adapter-tanstack-query";
type MyError = InferActionMutationError;
```
***
## See also [#see-also]
* [Hooks guide](/docs/guides/hooks): the `useAction` hook for direct server action calls without TanStack Query
* [React Hook Form integration](/docs/integrations/react-hook-form): sibling adapter for react-hook-form
* [Optimistic updates](/docs/guides/optimistic-updates): `useOptimisticAction` in depth
* [Custom validation errors](/docs/advanced/custom-validation-errors): customizing error shapes