A Next.js App Router page calls `redirect('/login')` from next/navigation inside a try/catch, and the redirect silently stops working. What is the mechanism behind `redirect()` and `notFound()`, and what is the fix?
answer
- it throws, it does not return
- the framework catches the throw above you
- a broad catch eats the signal
- narrow the try, or re-throw
- its TypeScript return type is never
basics
~20 sBoth functions work by throwing a special control-flow error that Next catches above your code. A surrounding try/catch swallows it, so nothing happens. Call them outside the try block, or re-throw the framework's error instead of absorbing it.
solid answer
~50 s`redirect()` and `notFound()` do not return a value you act on — they throw a Next-internal error carrying a recognizable digest, which Next's framework boundary catches and turns into a redirect response or the not-found render. That design is why you never write `return redirect(...)`: in TypeScript the return type is `never`, and execution stops at the call. It also means an enclosing `try/catch` with a broad catch block intercepts the framework's own signal and the navigation quietly disappears — a very common bug in a `try { await save(); redirect('/done') } catch { … }` shape. The fixes are: move the call after the try block, so it runs on the success path outside the catch, or re-throw the control-flow error from the catch instead of swallowing it. In a client event handler you would use `router.push` or `router.replace` from `useRouter` rather than `redirect`.
code
typescript · 17 lines'use server';
import { redirect } from 'next/navigation';
export async function saveProfile(formData: FormData) {
const name = String(formData.get('name') ?? '');
// narrow try: only the operation that can actually fail
try {
await db.profile.update({ name });
} catch {
return { error: 'Could not save your profile.' };
}
// outside the try, so the thrown redirect signal is not swallowed
redirect('/profile');
}go deeper
Know that redirect() and notFound() are imported from next/navigation and are called in server code, and that execution stops at the call — nothing after it runs.
Explain the mechanism: they throw a Next-internal error with a recognizable digest that the framework catches, which is why the TypeScript return type is never and why return redirect(...) is pointless.
Diagnose the swallowed-signal bug on sight: a broad catch around the call absorbs the framework's error. Show both fixes — narrowing the try block, and re-throwing control-flow errors — and pick the narrower one by default.
Own the pattern at the codebase level: catch-all error wrappers, logging decorators, and shared action helpers all silently break framework control flow. Set the convention for how errors are wrapped so this class of bug cannot recur.
## The mechanism When a Server Component, Route Handler, or Server Action calls ```ts import { notFound, redirect } from 'next/navigation'; redirect('/login'); ``` nothing is returned. The function **throws** a purpose-built error object whose `digest` property carries a recognizable marker (`NEXT_REDIRECT` for redirects, a not-found marker for `notFound()`), plus the destination and redirect kind. That error propagates up through your code until it reaches Next's own boundary, which recognizes the digest and converts it into the intended outcome: an HTTP redirect response, or a render of the nearest not-found UI with a 404 status. This is the classic "exceptions as control flow" trick, and it buys real ergonomics. You can bail out from arbitrary depth — inside a helper, inside a data-loading function, three calls down from the component — without threading a `Result` type back up to the top. That is why the TypeScript signature returns `never`: the compiler knows nothing after the call executes, so it narrows types correctly and `return redirect(...)` is redundant. ## Why try/catch breaks it A thrown value obeys the ordinary rules of JavaScript, and a `catch` block that catches everything catches this too: ```ts // broken: the catch eats the redirect signal try { await saveProfile(data); redirect('/profile'); } catch (error) { return { error: 'Could not save' }; } ``` The save succeeds, `redirect()` throws its signal, the catch intercepts it, the function returns an error object, and the user sits on the same page looking at a failure message for an operation that worked. The symptom is confusing precisely because the mutation *did* happen. ## Two correct shapes **Move the call out of the try.** This is the version to reach for by default: the try block wraps only the code that can genuinely fail, and the redirect lives on the success path after it. ```ts try { await saveProfile(data); } catch { return { error: 'Could not save' }; } redirect('/profile'); ``` **Or re-throw the framework's signal.** When the call genuinely has to sit inside the try, the catch must let control-flow errors through rather than absorbing everything. Next 15 added `unstable_rethrow` in `next/navigation` for exactly this: call it first in the catch block so framework errors continue to propagate and only your real errors are handled. The same reasoning applies to `notFound()`. A `getUser()` helper that calls `notFound()` when the row is missing does nothing useful if its caller wraps it in a catch-all. ## Status codes and variants - `redirect()` issues a temporary redirect — 307 in a normal server render, and 303 when it happens as the result of a Server Action, so the browser follows it with a GET rather than repeating the POST. - `permanentRedirect()` from `next/navigation` is the permanent counterpart, issuing 308 (303 from a Server Action for the same method reason). - `notFound()` renders the closest not-found UI and responds 404. Assume Next 15/16 App Router for those codes. ## Where each one belongs `redirect()` and `notFound()` are server-side control flow: Server Components, Route Handlers, and Server Actions. That is the natural home, because the decision to send someone elsewhere is usually made where the data and the session live, and doing it on the server means the browser never renders the page it was not allowed to see. In a Client Component's event handler, the equivalent is the router: ```tsx 'use client'; const router = useRouter(); router.replace('/login'); // no new history entry ``` Use `replace` rather than `push` for auth bounces and post-submit landings, so the back button does not return the user to a page that will just redirect them again. ## Practical guidance - Never write `return redirect(...)`; it signals a misunderstanding of what the function does. - Never catch broadly around a redirect. Narrow the try block to the operation that can fail. - If a redirect "works locally and not in production", check for a logging wrapper or middleware-style helper in the call chain that catches and reports every error — those swallow the signal just as effectively as a hand-written catch.
- Why is `return redirect('/login')` unnecessary in a Next.js Server Component?Because `redirect()` never returns — it throws, and its TypeScript signature is `never`. Execution stops at the call, so nothing after it runs and there is no value to hand back. Writing `return` in front of it is harmless but signals that the author thinks the function produces a response object the caller must forward.
- What status code does redirect() produce, and why is it different inside a Server Action?By default a 307 temporary redirect, which preserves the request method. From a Server Action, Next issues 303 instead so the browser follows the redirect with a GET rather than re-submitting the POST to the new location. `permanentRedirect()` is the 308 counterpart for permanent moves.
- You need a redirect from a button's click handler in a Client Component. What do you use?`useRouter()` from `next/navigation` and then `router.push('/login')`, or `router.replace('/login')` when the current page should not stay in history. `redirect()` is server-side control flow built on throwing, which is not the right instrument for an event handler running in the browser.
saying these in an interview costs you the question
- Writes return redirect(...) expecting a response object
- Wraps redirect in a try/catch that catches everything
- Says redirect returns a value the caller must forward
- Calls redirect from a client onClick handler
- Expects notFound() to work despite a catch-all wrapper