Action result
Every safe action returns a result object, a structured response that tells you exactly what happened. It always has the same shape, making error handling predictable and type-safe.
Result structure
The result is a discriminated union. At most one of data, validationErrors, or serverError is populated, and TypeScript enforces this at the type level — checking one field narrows the others to undefined:
type SafeActionResult<ServerError, Schema, ShapedErrors, Data> =
| { data?: undefined; serverError?: undefined; validationErrors?: undefined } // idle / framework navigation
| { data: Data; serverError?: undefined; validationErrors?: undefined } // success
| { data?: undefined; serverError: ServerError; validationErrors?: undefined } // server error
| { data?: undefined; serverError?: undefined; validationErrors: ShapedErrors }; // validation failureThe four branches correspond to these outcomes:
| Outcome | data | validationErrors | serverError |
|---|---|---|---|
| Success | Present | - | - |
| Validation failure | - | Present | - |
| Server error | - | - | Present |
| Idle / framework navigation | - | - | - |
Framework navigation/errors (like redirect() or notFound()) are a special case. They don't populate any of the three fields. On the server the error is re-thrown so Next.js can handle the navigation; on the client, the hook exposes the idle branch {} together with a "hasNavigated" status. See Error Handling for details.
data
When your server code completes successfully, data contains whatever you returned:
export const greetUser = actionClient
.inputSchema(z.object({ name: z.string() }))
.action(async ({ parsedInput }) => {
return { greeting: `Hello, ${parsedInput.name}!` };
});
// On the client:
const result = await greetUser({ name: "Alice" });
// result.data → { greeting: "Hello, Alice!" }
// Type: { greeting: string } | undefinedThe data type is inferred from your server code's return type. If you define an outputSchema, the type comes from the schema instead.
If your server code doesn't return anything, result.data is typed as exactly undefined rather than void | undefined. The runtime never emits a separate { data: undefined } for void-returning actions (it returns {}), and the types mirror that: the idle and void-success branches collapse into a single shape. Discriminate success for these actions by checking the absence of errors instead of truthy data.
validationErrors
When the input doesn't match the schema, validationErrors contains the validation failures. Your server code never runs in this case:
export const createUser = actionClient
.inputSchema(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.action(async ({ parsedInput }) => {
// This code doesn't run if validation fails
});
// On the client (with invalid input):
const result = await createUser({ name: "", email: "bad" });
// result.validationErrors → {
// name: { _errors: ["String must contain at least 2 character(s)"] },
// email: { _errors: ["Invalid email"] }
// }The shape of validationErrors depends on the configured error shape (formatted or flattened). See Input Validation for details.
You can also manually return validation errors from your server code using returnValidationErrors():
import { returnValidationErrors } from "next-safe-action";
export const createUser = actionClient
.inputSchema(z.object({ email: z.string().email() }))
.action(async ({ parsedInput }) => {
const exists = await db.user.findByEmail(parsedInput.email);
if (exists) {
// Return a validation error for the email field
return returnValidationErrors(inputSchema, {
email: { _errors: ["Email already taken"] },
});
}
return { id: "123" };
});serverError
When an unexpected error is thrown in your server code or middleware, serverError contains the error message:
export const riskyAction = actionClient
.action(async () => {
throw new Error("Database connection failed");
});
// On the client:
const result = await riskyAction();
// result.serverError → "Something went wrong" (default message)By default, the actual error message is not sent to the client, only the generic "Something went wrong" string. This prevents sensitive information from leaking. Customize this with handleServerError in client options.
Reading the result
A typical pattern for handling all cases:
const result = await myAction(input);
if (result.data) {
// TypeScript knows serverError and validationErrors are undefined here
console.log(result.data);
} else if (result.validationErrors) {
// TypeScript knows data and serverError are undefined here
console.log(result.validationErrors);
} else if (result.serverError) {
// TypeScript knows data and validationErrors are undefined here
console.log(result.serverError);
}Destructuring works the same way — checking any one of the three variables narrows the other two:
const { data, serverError, validationErrors } = await myAction(input);
if (data) {
// serverError and validationErrors are `undefined`
}
if (serverError) {
// data and validationErrors are `undefined`
}In hooks
With the useAction hook (and useOptimisticAction, useStateAction), the return object is itself a discriminated union keyed on status and shorthand booleans. Checking any discriminant narrows result to the matching branch:
const action = useAction(myAction);
// Status-based narrowing
if (action.hasSucceeded) {
action.result.data; // Data (guaranteed)
action.result.serverError; // undefined
action.result.validationErrors; // undefined
}
if (action.hasErrored) {
action.result.data; // undefined
// Further narrow between error kinds:
if (action.result.serverError) { /* ... */ }
if (action.result.validationErrors) { /* ... */ }
}Lifecycle callbacks also benefit from narrowing:
const { execute } = useAction(myAction, {
onSuccess: ({ data }) => { /* data is guaranteed here */ },
onError: ({ error }) => {
if (error.validationErrors) { /* ... */ }
if (error.serverError) { /* ... */ }
},
});See the Type narrowing section for more patterns.
Precedence for edge cases. In rare situations where the runtime could otherwise produce multiple populated fields (e.g. invalid bind args combined with invalid main input, or middleware that throws after await next() has already received a validation-error result), next-safe-action applies a fixed precedence: validationErrors > serverError > data. The higher-priority state fully describes the outcome; lower-priority state is discarded. This keeps the discriminated union honest at runtime. See Error precedence for the exact behavior, including how handleServerError and throwServerError interact with this rule.