next-safe-action
Advanced

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:

  1. Catches the error and identifies it as a framework error
  2. Re-throws it so Next.js can handle the actual navigation
  3. Notifies hooks via onNavigation callback and hasNavigated status
src/app/actions.ts
"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");
	});

Each navigation function maps to a navigationKind:

FunctionnavigationKindHTTP StatusDescription
redirect(url)"redirect"303/307/308Navigate to another page
notFound()"notFound"404Show the not-found page
forbidden()"forbidden"403Show the forbidden page
unauthorized()"unauthorized"401Show 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 page
  • forbidden() shows the forbidden page
  • unauthorized() shows the unauthorized page
  • redirect() 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:

  1. React's render must be pure. The render phase is where React calls your component function. It must be synchronous and side-effect free.
  2. Effects only run after commit. useEffect and useLayoutEffect callbacks are scheduled during render but only execute during the commit phase, after React has successfully rendered the component.
  3. 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.
  4. 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 instead

See also

  • Error handling: where framework errors fit in the error taxonomy
  • Hooks: onNavigation callback and hasNavigated status
  • ActionCallbacks: server-side onNavigation callback

On this page