Raw Next.js Server Actions have a gap. You define a function marked 'use server', call it from a form or a button click, and somewhere between the client and the server you're trusting that the data arrived with the right shape. No guaranteed validation, no type-safe context, no structured error responses — just a function call that could receive anything.
next-safe-action closes that gap. It wraps Server Actions with a schema layer (Zod or any Standard Schema compliant validator), a composable middleware chain, and React hooks that give you reactive status, typed results, and lifecycle callbacks. The v7 API made the middleware system composable in a way earlier versions weren't.
Installation
npm install next-safe-action zod
Creating the Action Client
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
export const actionClient = createSafeActionClient({
handleServerError(error) {
console.error('Server action error:', error)
if (error instanceof ActionError) return error.message
return 'Something went wrong. Please try again.'
},
})
ActionError is next-safe-action's built-in class for surfacing user-facing errors:
import { ActionError } from 'next-safe-action'
throw new ActionError('This email is already registered.')
// → client sees this message in result.serverError
Any other thrown error gets replaced by your handleServerError return — database errors and stack traces never leak to the client.
Authenticated Action Client
export const authActionClient = createSafeActionClient({
handleServerError(error) {
if (error instanceof ActionError) return error.message
return 'Something went wrong.'
},
}).use(async ({ next }) => {
const session = await auth()
if (!session?.user?.id) {
throw new ActionError('You must be signed in.')
}
return next({
ctx: {
userId: session.user.id,
user: session.user,
},
})
})
Actions that use authActionClient get ctx.userId and ctx.user fully typed — no casting.
Defining Actions
// actions/todos.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { authActionClient } from '@/lib/safe-action'
const createTodoSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
})
export const createTodo = authActionClient
.schema(createTodoSchema)
.action(async ({ parsedInput, ctx }) => {
const [todo] = await db
.insert(todos)
.values({
title: parsedInput.title,
priority: parsedInput.priority,
userId: ctx.userId,
})
.returning()
revalidatePath('/todos')
return { todo }
})
parsedInput is fully typed from your Zod schema. ctx is typed from your middleware chain.
The useAction Hook
'use client'
import { useAction } from 'next-safe-action/hooks'
import { createTodo } from '@/actions/todos'
function CreateTodoButton() {
const { execute, result, isPending, reset } = useAction(createTodo, {
onSuccess: ({ data }) => {
toast.success(`"${data?.todo.title}" added`)
reset()
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Failed to create todo')
},
})
return (
<button
onClick={() => execute({ title: 'New todo', priority: 'medium' })}
disabled={isPending}
>
{isPending ? 'Adding...' : 'Add todo'}
</button>
)
}
| Property | Description |
|---|---|
execute |
Triggers the action |
executeAsync |
Same, but awaitable |
result |
Last result: data, validationErrors, serverError |
isPending |
True while running |
status |
`'idle' \ |
{% raw %}reset
|
Clears result state |
Handling Validation Errors
Zod validation errors are separated from server errors and available field-by-field:
function CreateTodoForm() {
const { execute, result, isPending } = useAction(createTodo)
return (
<form onSubmit={(e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
execute({ title: fd.get('title') as string, priority: 'medium' })
}}>
<input name="title" placeholder="Todo title" />
{result.validationErrors?.title?._errors?.map((err) => (
<p key={err} className="text-sm text-red-500">{err}</p>
))}
{result.serverError && (
<p className="text-sm text-red-500">{result.serverError}</p>
)}
<button type="submit" disabled={isPending}>Create</button>
</form>
)
}
Integration with React Hook Form
export function CreateTodoForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { title: '', priority: 'medium' },
})
const { execute, isPending, result } = useAction(createTodo, {
onSuccess: () => form.reset(),
})
// Merge server-side validation errors back into the form
useEffect(() => {
if (result.validationErrors) {
Object.entries(result.validationErrors).forEach(([field, errors]) => {
if (errors?._errors?.length) {
form.setError(field as keyof FormValues, {
message: errors._errors[0],
})
}
})
}
}, [result.validationErrors, form])
return (
<form onSubmit={form.handleSubmit(execute)}>
{/* form fields */}
<Button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</Button>
</form>
)
}
Server-Side Validation Errors
For constraints only the DB can verify (email uniqueness, etc.):
import { returnValidationErrors } from 'next-safe-action'
export const createUser = authActionClient
.schema(createUserSchema)
.action(async ({ parsedInput }) => {
const existing = await db.query.users.findFirst({
where: eq(users.email, parsedInput.email),
})
if (existing) {
return returnValidationErrors(createUserSchema, {
email: { _errors: ['This email is already registered.'] },
})
}
const user = await db.insert(users).values(parsedInput).returning()
return { user: user[0] }
})
The client gets result.validationErrors.email._errors[0] — same shape as Zod errors.
Composable Middleware
// Rate-limited on top of auth
export const rateLimitedActionClient = authActionClient
.use(async ({ next, ctx }) => {
const { success } = await ratelimit.limit(ctx.userId)
if (!success) throw new ActionError('Too many requests.')
return next({ ctx })
})
// Audit-logged for sensitive operations
export const auditedActionClient = authActionClient
.use(async ({ next, ctx, clientInput }) => {
const result = await next({ ctx })
await db.insert(auditLogs).values({
userId: ctx.userId,
input: JSON.stringify(clientInput),
timestamp: new Date(),
})
return result
})
useOptimisticAction
const { execute, optimisticState } = useOptimisticAction(toggleTodo, {
currentState: todo,
updateFn: (state, input) => ({
...state,
completed: input.completed,
}),
})
// optimisticState reflects the update immediately,
// reverts automatically if the server fails
Quick Reference
// Setup
export const actionClient = createSafeActionClient({ handleServerError })
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await auth()
if (!session) throw new ActionError('Unauthorized')
return next({ ctx: { userId: session.user.id } })
})
// Define
'use server'
export const myAction = authActionClient
.schema(z.object({ name: z.string() }))
.action(async ({ parsedInput, ctx }) => {
return { result: 'ok' }
})
// Use
const { execute, result, isPending } = useAction(myAction, {
onSuccess: ({ data }) => {},
onError: ({ error }) => console.error(error.serverError),
})
// Errors
result.validationErrors?.fieldName?._errors // Zod field errors
result.serverError // ActionError message
Full article at stacknotice.com/blog/next-safe-action-nextjs-guide-2026
Top comments (0)