A Next.js App Router Server Action wraps its whole body in try/catch and calls redirect('/posts') at the end of the try block. The browser stays on the form and the catch block logs an unexpected error. What is happening, and how should the action be structured?
answer
- it never returns
- the navigation travels as a thrown signal
- your catch gets there first
- narrow the try to the I/O
- nothing after it executes
basics
~20 sredirect() from 'next/navigation' works by throwing a control-flow error that Next catches to issue the navigation. A surrounding try/catch swallows it, so the redirect never happens and the error lands in your catch. Call redirect after the try/catch.
solid answer
~50 s`redirect()` is not a normal function call that returns — it signals the navigation by throwing a special internal error that Next.js recognises and turns into a redirect response. Your `catch` intercepts that signal first, so Next never sees it: the navigation is cancelled and your error handler logs a control-flow object it does not understand. The fix is structural: keep the `try` around only the work that can genuinely fail, and call `redirect()` after it, on the success path. The same applies to `notFound()`. Watch the ordering too — because `redirect()` throws, nothing after it in that function runs, so any `revalidatePath` or `revalidateTag` call must come *before* it. And since the redirect ends the action, it is not a way to return validation errors to the form; that path should return a value instead.
code
typescript · 20 lines'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
let id: string
try {
const res = await fetch('https://api.example.com/posts', {
method: 'POST',
body: formData,
})
if (!res.ok) throw new Error('save failed')
id = (await res.json()).id
} catch {
return { error: 'Could not create the post.' }
}
revalidatePath('/posts')
redirect(`/posts/${id}`)
}go deeper
Remember that redirect() comes from next/navigation and must be called outside try/catch, and that any code written after it will not run.
Explain the mechanism: redirect signals by throwing an internal error the framework catches, so an intervening catch cancels the navigation. Show the corrected structure with a narrow try block.
Get the whole success path right — invalidate before redirecting so the destination is not served stale, keep failures as returned values rather than navigations, and make sure error handling does not swallow framework control flow anywhere in the codebase.
Set the convention for how mutations report outcomes across the app: which results are navigations and which are returned state, and how logging and error-reporting middleware avoids capturing framework control-flow signals as incidents.
## What redirect() actually does `redirect()` is exported from `next/navigation` and is typed as returning `never`. It does not return, because it works by throwing: it raises an internal control-flow error that the framework catches at its own boundary and converts into a redirect. In a Server Action, that becomes a redirect instruction sent back to the client, which then navigates. This is a deliberate design — it lets `redirect()` be called from deep inside helper functions without every caller having to propagate a "please navigate" return value — but it has one sharp consequence: **any `catch` between the call and the framework boundary intercepts the signal**. ## The bug in the described action ```ts 'use server' import { redirect } from 'next/navigation' // BROKEN export async function createPost(formData: FormData) { try { await savePost(formData) redirect('/posts') // throws the redirect signal } catch (err) { console.error(err) // ...and this eats it return { error: 'Something went wrong' } } } ``` The write succeeds, `redirect()` throws its signal, the `catch` swallows it, and the action returns an error object as if the save had failed. The user sees the form, still filled in, with a misleading message — and the logs contain a strange object nobody recognises. It is a nasty bug precisely because everything except the navigation worked. ## The correct structure Narrow the `try` to the fallible work and put the redirect on the success path: ```ts 'use server' import { redirect } from 'next/navigation' import { revalidatePath } from 'next/cache' export async function createPost(formData: FormData) { let id: string try { id = await savePost(formData) } catch (err) { console.error(err) return { error: 'Could not create the post.' } } revalidatePath('/posts') redirect(`/posts/${id}`) } ``` The rule of thumb: `try` wraps I/O, not control flow. The same reasoning applies to `notFound()`, which signals its 404 the same way. ## Ordering: invalidate before you redirect Because `redirect()` throws, no statement after it in that function body executes. So this is wrong: ```ts redirect('/posts') revalidatePath('/posts') // never runs ``` This matters more than it looks. The whole point of redirecting after a write is usually to land the user on a page that shows the result — and if you never invalidated, that destination may be served from the client Router Cache in its pre-mutation state. The user is redirected to the list, and the list does not contain the thing they just created. Invalidate, then redirect. ## Redirect is not an error channel A related design mistake is redirecting on failure — bouncing back to the form, or to an `/error` page. That throws away the submitted values and any field-level detail. Failures a user can fix should be a returned value from the action that the form renders; `redirect()` is for the success path, where the next thing the user should see is a different route. ## Client-side redirects are a different thing Calling `redirect()` inside an action produces a server-driven navigation as part of the action's response. That is distinct from navigating from a client component after the action resolves. Server-side is generally preferable after a mutation: there is no window in which the client sits on a stale form, and it still works when the action was submitted before hydration. ## What an interviewer is listening for That you know `redirect()` throws rather than returns; that you can explain *why* the `catch` breaks it and not merely that it does; that you place invalidation before the redirect; and that you scope `try` blocks to the operations that can actually fail.
- Why does calling revalidatePath after redirect() have no effect?redirect() throws, so it terminates the function body at that point and no later statement runs. Put every invalidation call before the redirect — otherwise the user lands on a destination that may still be served from cached, pre-mutation state, which is exactly the bug the redirect was meant to make invisible.
- Does the same trap apply to notFound()?Yes. notFound() from next/navigation signals its 404 by throwing the same way, so a surrounding catch swallows it and the route renders normally instead of showing the not-found UI. Treat both as control flow that must not sit inside a try block you also catch.
- Should a validation failure redirect the user back to the form route?No. A redirect discards the submitted values and any per-field detail, and costs a round trip. Return a serialisable error value from the action and let the form render it inline; reserve redirect() for the success path where the user genuinely belongs on another route.
saying these in an interview costs you the question
- Thinks redirect() returns and execution continues after it
- Wraps the redirect in try/catch to be safe
- Puts revalidatePath after the redirect call
- Uses redirect to report validation errors to the user
- Adds a return statement after redirect believing it is needed