Type-safe, validated Server Actions with next-safe-action
A Server Action is a server function the client can call directly. That's the
convenience, and it's also the problem: whatever reaches your handler is
whatever the client sent, and nothing guarantees it's the shape you expect.
next-safe-action puts a validation layer in front of the action — it only runs
if the input matches a schema — and carries the return type through to the
component that consumes it.
What it actually solves
Three things repeat in every App Router project without it:
- Manual validation at the top of every action, always similar, always slightly different.
- Thrown errors either leak stack traces to the client or vanish into a generic message.
- The action's return type drifts from what the component expects, and nothing catches it.
On top of that you get chainable middleware — useful for auth checks and logging
that would otherwise be copy-pasted into every action.
Setup
With Zod as the validation library (Valibot, Yup, ArkType and others work too):
npm install next-safe-action zod
Create one client and reuse it. This is where middleware and error handling get
configured later, so having a single place for it matters:
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action';
export const actionClient = createSafeActionClient();
Defining an action
'use server';
import { z } from 'zod';
import { actionClient } from '@/lib/safe-action';
const userSchema = z.object({
id: z.string(),
name: z.string().min(2, 'Name is too short'),
email: z.string().email('Invalid email'),
});
export const updateUserAction = actionClient
.inputSchema(userSchema)
.action(async ({ parsedInput }) => {
const { id, name, email } = parsedInput;
await db.user.update({ where: { id }, data: { name, email } });
return { message: 'User updated' };
});
parsedInput is typed from the schema, so id, name and email are known to
be strings by the time the handler runs. If validation fails, the handler never
executes.
On versions before 7.10, the method is
.schema()instead of.inputSchema().
Consuming it on the client
The hook lives in next-safe-action/hooks, not the package root:
'use client';
import { useAction } from 'next-safe-action/hooks';
import { updateUserAction } from '@/actions/user-actions';
function UserForm() {
const { execute, result, isExecuting } = useAction(updateUserAction);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
execute(Object.fromEntries(formData) as { id: string; name: string; email: string });
};
return (
<form onSubmit={handleSubmit}>
<input name="id" type="text" placeholder="User ID" required />
<input name="name" type="text" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={isExecuting}>
{isExecuting ? 'Saving...' : 'Update'}
</button>
{result?.data && <p>{result.data.message}</p>}
{result?.validationErrors?.name?._errors?.map((error) => (
<p key={error}>{error}</p>
))}
</form>
);
}
Two details worth knowing here.
execute is fire-and-forget — awaiting it does nothing, since the result arrives
reactively through result. When you need the value inline (chaining calls,
branching on the outcome), use executeAsync, which returns a promise:
const outcome = await executeAsync({ id, name, email });
if (outcome?.data) {
// ...
}
And validationErrors defaults to the formatted shape, which mirrors the
schema and puts messages in an _errors array per field:
{
"name": { "_errors": ["Name is too short"] },
"email": { "_errors": ["Invalid email"] }
}
If a flat structure fits your form better, ask for it per action:
import { flattenValidationErrors } from 'next-safe-action';
export const updateUserAction = actionClient
.inputSchema(userSchema, {
handleValidationErrorsShape: (ve) => flattenValidationErrors(ve),
})
.action(async ({ parsedInput }) => { /* ... */ });
That gives you { formErrors, fieldErrors }, with fieldErrors.name as a plain
string[] — at the cost of dropping errors on nested fields.
Where to go next
The schema is the entry point, but the middleware chain is where most of the
value shows up in a real codebase: an authenticated client that resolves the
session once and passes it to every action through ctx, and error handling
configured in a single place instead of per action.
Top comments (0)