Custom Validation Errors
next-safe-action gives you control over how validation errors are shaped, formatted, and returned to the client.
Error shapes
There are two built-in shapes:
The formatted shape mirrors the schema structure with _errors arrays at each level:
// 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:
// 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
Set the shape for all actions when creating the client:
const actionClient = createSafeActionClient({
defaultValidationErrorsShape: "flattened",
});Overriding per action
Override the shape for a specific action by passing a function to inputSchema():
export const myAction = actionClient
.inputSchema(schema, {
handleValidationErrorsShape: (ve) => flattenValidationErrors(ve),
})
.action(async ({ parsedInput }) => { /* ... */ });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:
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
Use _errors at the root level for errors that don't belong to a specific field:
return returnValidationErrors(schema, {
_errors: ["Invalid credentials"],
});Throwing validation errors
If you prefer throwing validation errors instead of returning them in the result, enable throwValidationErrors:
// 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
| 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 for full signatures.
See also
- Error Handling — the complete error taxonomy
- Input Validation — how validation errors are generated
- Error Classes —
ActionValidationErrorand related classes