Error handling
next-safe-action has three categories of errors, each handled differently:
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:
const result = await createUser({ name: "", email: "bad" });
result.validationErrors;
// → { name: { _errors: ["Too short"] }, email: { _errors: ["Invalid email"] } }Validation errors are produced in two ways:
- Automatically: when Standard Schema validation fails on input or bind args
- Manually: when you call
returnValidationErrors()in your server code (e.g., "email already taken")
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 for error shapes and Custom Validation Errors for advanced formatting.
Server errors
Server errors are unexpected failures, such as database timeouts, API failures, or bugs in your server code. By default, next-safe-action:
- Catches the thrown error
- Passes it to
handleServerError(which you define in client options) - Returns the handler's return value as
result.serverError
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
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:
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
ServerErrortype, which is inferred fromhandleServerError's return type. See 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 whencacheComponentsis enabled. Non-serializable payloads fail loudly with aTypeErroron the server (handled byhandleServerErrorlike any unexpected error), instead of silently degrading.
Typing the error payload
returnServerError is generic (returnServerError<SE>(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:
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:
returnServerError<AppServerError>({ 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:
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
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:
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:
// 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 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:
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
- On the server: The error is caught, identified as a framework error, and re-thrown so Next.js can handle the navigation
- On the client: If using
useAction, the hook detects the navigation and setsstatusto"hasNavigated"(instead of"hasSucceeded"or"hasErrored") - Callbacks: The
onNavigationcallback fires (instead ofonSuccessoronError)
Navigation kinds
The navigationKind property tells you what type of navigation occurred:
| Function | navigationKind |
|---|---|
redirect() | "redirect" |
notFound() | "notFound" |
forbidden() | "forbidden" |
unauthorized() | "unauthorized" |
const { execute } = useAction(myAction, {
onNavigation: ({ navigationKind }) => {
if (navigationKind === "redirect") {
// Action triggered a redirect
}
},
});See Framework Errors for advanced configuration.
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 containsvalidationErrors, 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 > dataThe 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
The precedence rule only affects what appears in the returned result, not whether handleServerError executes:
- When middleware or server code throws,
handleServerErroris invoked immediately with the thrown error, regardless of any pre-existingvalidationErrors. - Only after
handleServerErrorhas 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
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:
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 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) |