Framework navigation/errors
Next.js provides navigation functions like redirect(), notFound(), forbidden(), and unauthorized(). These work inside safe actions, and next-safe-action detects and handles them automatically.
How framework errors work
When you call a navigation function inside an action, Next.js throws a special error internally. next-safe-action:
- Catches the error and identifies it as a framework error
- Re-throws it so Next.js can handle the actual navigation
- Notifies hooks via
onNavigationcallback andhasNavigatedstatus
"use server";
import { redirect } from "next/navigation";
import { actionClient } from "@/lib/safe-action";
export const loginAction = actionClient
.inputSchema(loginSchema)
.action(async ({ parsedInput }) => {
const user = await authenticate(parsedInput);
if (!user) {
// Return validation error, NOT a framework error
return returnValidationErrors(loginSchema, {
_errors: ["Invalid credentials"],
});
}
// This is a framework error: triggers navigation
redirect("/dashboard");
});Navigation kinds
Each navigation function maps to a navigationKind:
| Function | navigationKind | HTTP Status | Description |
|---|---|---|---|
redirect(url) | "redirect" | 303/307/308 | Navigate to another page |
notFound() | "notFound" | 404 | Show the not-found page |
forbidden() | "forbidden" | 403 | Show the forbidden page |
unauthorized() | "unauthorized" | 401 | Show the unauthorized page |
Handling in hooks
By default, useAction catches navigation errors and sets the status to "hasNavigated", firing the onNavigation callback. The component stays mounted:
const { execute, hasNavigated } = useAction(loginAction, {
onNavigation: ({ navigationKind }) => {
if (navigationKind === "redirect") {
// The user was redirected
} else if (navigationKind === "notFound") {
// The resource wasn't found
}
},
onSuccess: ({ data }) => {
// This does NOT fire when redirect() is called
// redirect is a navigation, not a success
},
});throwOnNavigation: true
Set throwOnNavigation to true to propagate navigation errors to the nearest error boundary. In Next.js, this shows the appropriate error page:
notFound()shows the not-found pageforbidden()shows the forbidden pageunauthorized()shows the unauthorized pageredirect()performs the redirect (via HTTP headers)
// Navigation errors propagate to Next.js error boundaries
const { execute } = useAction(loginAction, {
throwOnNavigation: true,
});When throwOnNavigation is true, onNavigation and onSettled callbacks are not available. TypeScript will prevent you from passing them. This is because they cannot execute: see why callbacks can't fire below.
When redirect() is called in an action, onSuccess does not fire regardless of throwOnNavigation. If your action redirects after success, put cleanup logic in onNavigation or onSettled (with default throwOnNavigation), or use server-side action callbacks.
Why callbacks can't fire with throwOnNavigation: true
When throwOnNavigation is true, the navigation error is thrown during React's render phase to reach the error boundary. This is a fundamental constraint of React's rendering model, not a library limitation:
- React's render must be pure. The render phase is where React calls your component function. It must be synchronous and side-effect free.
- Effects only run after commit.
useEffectanduseLayoutEffectcallbacks are scheduled during render but only execute during the commit phase, after React has successfully rendered the component. - A render-phase throw prevents commit. When a component throws during render, React never reaches the commit phase for that component. All scheduled effects are discarded.
- The error boundary catches the throw. React propagates the error to the nearest error boundary, which unmounts the throwing component and renders its fallback.
Since onNavigation and onSettled are fired via useEffect, they can never execute when the render throws. There is no React API that allows committed side effects from a component whose render fails.
For side effects that must run on navigation (analytics, logging, cleanup), use server-side action callbacks instead. These run on the server before the error reaches the client, and are guaranteed to complete:
export const myAction = actionClient
.inputSchema(schema)
.action(
async ({ parsedInput }) => {
notFound();
},
{
onNavigation: async ({ navigationKind }) => {
// Guaranteed to run on the server
await analytics.track("navigation", { navigationKind });
},
}
);Handling in action callbacks
Framework navigation/errors also work with server-side action callbacks:
export const myAction = actionClient
.inputSchema(schema)
.action(
async ({ parsedInput }) => {
redirect("/somewhere");
},
{
onNavigation: ({ navigationKind }) => {
// Runs on the server after navigation is triggered
console.log("Action navigated:", navigationKind);
},
}
);Direct execution
When calling actions directly (not via hooks), framework errors cause the navigation to happen on the server. The function call never returns a result:
const result = await loginAction(input);
// If redirect() was called, this line never executes
// The browser navigates to the redirect URL insteadSee also
- Error handling: where framework errors fit in the error taxonomy
- Hooks:
onNavigationcallback andhasNavigatedstatus - ActionCallbacks: server-side
onNavigationcallback