Next.js 16 Server Action redirect() Swallowed by try/catch

Next.js 16 redirect() not firing in a server action and the catch logs NEXT_REDIRECT? Here is why try/catch swallows it and the pattern that navigates.
Next.jsReactServer Actions
June 12, 20266 min read1104 words

The Problem

A client checkout form was supposed to send the user to /order/[id] after a successful Stripe charge. It did not. The order was created, the charge captured, the user sat on the form with no feedback. The browser console was empty. The server logs had this:

[server] POST /checkout 200
[server] error in checkout action: { digest: 'NEXT_REDIRECT;replace;/order/ord_8K2;303;' }

The server action was a single try/catch wrapping a Stripe call and a redirect():

'use server'

import { redirect } from 'next/navigation'

export async function placeOrder(formData: FormData) {
  try {
    const order = await stripe.charges.create({ /* ... */ })
    await db.orders.create({ data: { id: order.id, status: 'paid' } })
    redirect(`/order/${order.id}`)
  } catch (err) {
    console.error('error in checkout action:', err)
    return { ok: false, error: 'Payment failed' }
  }
}

The action looked clean. It tested locally. In production on Next.js 16.2, redirects worked roughly half the time. The half that failed were always the ones inside a try/catch. If your redirect() in a server action is logging a NEXT_REDIRECT digest and not actually navigating, this is the same bug.

Why It Happens

redirect() in Next.js works by throwing. It is not a function that returns a navigation instruction, it raises a special error object whose digest starts with NEXT_REDIRECT. The framework catches that error at the top of the server action boundary, reads the digest, and turns it into an HTTP 303 with the target location. The throw is how redirect() is allowed to be called from anywhere in the call stack without threading a return value back to the framework.

The problem is that a try/catch further up the stack catches the same error before the framework can. Your catch (err) block sees an error object, has no idea it is a redirect signal, swallows it, logs it, and returns a plain JSON response. The framework sees a normal 200 with a JSON body, the browser stays put, and the only trace is the digest string in your error log.

This has been the redirect model since the App Router shipped, but it became more visible in Next.js 16 because server actions are used for more of the post-mutation flow, and because the new error boundary changes made the digest format show up in more logs. The redirect API docs call this out, but the warning is easy to miss when you are wrapping everything defensively.

notFound(), forbidden(), and unauthorized() work the same way. Any of them inside a try/catch will be swallowed if the catch is not aware of the digest. So fixing this for redirect() also fixes a class of bugs you have probably not hit yet.

The Fix

There are two patterns. Pick by whether you actually need the catch or you just wrapped it out of habit.

Pattern 1: Call redirect() outside the try/catch. This is the right answer ninety percent of the time. The try/catch should only cover the work that can fail. The redirect is the success path, so it belongs after the block:

'use server'

import { redirect } from 'next/navigation'

export async function placeOrder(formData: FormData) {
  let order
  try {
    order = await stripe.charges.create({ /* ... */ })
    await db.orders.create({ data: { id: order.id, status: 'paid' } })
  } catch (err) {
    console.error('checkout failed:', err)
    return { ok: false, error: 'Payment failed' }
  }

  redirect(`/order/${order.id}`)
}

Now redirect() throws into the framework's handler, and the response carries the 303. The catch only ever runs when Stripe or the database actually fails.

Pattern 2: Re-throw redirect errors inside the catch. When you genuinely need the redirect inside the try (for example, the redirect target depends on a value you computed in a nested helper that also touches Stripe), import the type guard and re-throw it:

'use server'

import { redirect } from 'next/navigation'
import { isRedirectError } from 'next/dist/client/components/redirect-error'

export async function placeOrder(formData: FormData) {
  try {
    const order = await chargeAndPersist(formData) // may call redirect() internally
    redirect(`/order/${order.id}`)
  } catch (err) {
    if (isRedirectError(err)) throw err
    console.error('checkout failed:', err)
    return { ok: false, error: 'Payment failed' }
  }
}

isRedirectError is the supported type guard. The same module exports isNotFoundError for the matching notFound() case. Re-throwing puts the digest back on the stack and the framework's handler resumes its work.

If you do not want to import from a deep path, write a small guard:

function isInternalNavError(err: unknown): err is Error {
  return (
    err instanceof Error &&
    'digest' in err &&
    typeof (err as { digest: unknown }).digest === 'string' &&
    ((err as { digest: string }).digest.startsWith('NEXT_REDIRECT') ||
      (err as { digest: string }).digest === 'NEXT_NOT_FOUND' ||
      (err as { digest: string }).digest === 'NEXT_HTTP_ERROR_FALLBACK;404')
  )
}

That covers redirect(), notFound(), and the 404 helper without depending on internal paths.

Verify the fix. The cleanest check is a curl that posts to the action endpoint and watches for the 303:

curl -i -X POST https://localhost:3000/checkout \
  -H "Next-Action: $(grep -r 'placeOrder' .next/server | grep -oE '[a-f0-9]{40}' | head -1)" \
  -F "amount=1999"

A working redirect responds with HTTP/1.1 303 See Other and a Location: /order/... header. A swallowed redirect responds with HTTP/1.1 200 OK and a JSON body containing your error message. Same action, two completely different responses depending on which side of the try your redirect() sits on.

The Lesson

redirect(), notFound(), and friends signal navigation by throwing. A try/catch that does not re-throw them turns the navigation into a silent 200. Move redirect() out of the try whenever you can. When you cannot, re-throw the error after checking it with isRedirectError. The fix is two lines, the bug is invisible in the browser, and you only see it once you check the server logs for the NEXT_REDIRECT digest.

If your Next.js 16 server actions look right and behave wrong, that is the kind of debug I get paid to chase down. See my services. For a related server-action issue from this upgrade cycle, read Next.js 16 server actions invalid action error.

Need a Next.js 16 server action that actually navigates? Get in touch.

Back to blogStart a project