next-safe-action
Advanced

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

FunctionDescription
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

On this page