Next.js 16 Server Action redirect() Caught in try/catch

Next.js 16 server action redirect() silently swallowed by a try/catch and the user stays on the form? Here is the NEXT_REDIRECT error and the rethrow fix.
Next.js 16 Server Action redirect() Caught in try/catch
Next.jsServer ActionsReact 19
May 29, 20266 min read1006 words

The Problem

Submitted a contact form on a client site this week, hit submit, watched the network tab fire the server action, watched the database row land, and then nothing happened. The form sat there. No redirect to /thank-you, no error toast, no console warning. The user was supposed to land on a confirmation page. Instead they got the same form with the same values, no feedback at all.

The server action looked completely reasonable. It validated the payload, wrote a row to the database, sent a Resend email, and called redirect('/thank-you') at the end. All of it wrapped in a try block with a catch that returned a generic error state for the form's useActionState hook to render. Production on Vercel, Next.js 16.2.3, React 19.2. Works perfectly in dev when you remove the try/catch. Breaks silently in dev and prod when you put it back.

If you have ever shipped a form that "just doesn't redirect" and your logs are clean, you have hit this exact bug. It is one of the most common Next.js server action gotchas I see in code reviews.

Why It Happens

redirect() from next/navigation does not return a value. It throws. Specifically, it throws a special error object with a digest property that starts with NEXT_REDIRECT;. The Next.js runtime catches that error at the server action boundary, reads the digest, and tells the client to navigate. The whole redirect mechanism is built on the assumption that the throw will travel uninterrupted from redirect() up to the framework's outermost handler.

A try/catch block around the action body breaks that assumption. Your catch clause runs first. It catches the NEXT_REDIRECT error, treats it as a normal failure, returns an error state, and the framework never sees the throw. The action returns successfully from Next.js's point of view. There is no redirect because there is no redirect signal to act on. There is no log because no one threw an error that escaped.

Next.js 15 had the same behaviour but threw a different shape of error, so a lot of older Stack Overflow answers point you at checking error.message.includes('NEXT_REDIRECT'). In 16 the digest moved to the error.digest field and the message is now an empty string for forward compatibility. Code that string-matches the message will silently stop working when you upgrade. The redirect() API docs call this out at the bottom of the page but the placement is easy to miss.

The same trap exists for notFound(), which throws NEXT_NOT_FOUND, and for forbidden() and unauthorized() in 16.

The Fix

The cleanest fix is to use redirect() outside the try/catch. Move the redirect to after the catch block, where any real error has either been handled or returned. If you absolutely need the redirect inside the try (which you usually do, because you only want to redirect on success), use unstable_rethrow() from next/navigation to let the framework errors back out:

'use server'

import { redirect, unstable_rethrow } from 'next/navigation'
import { z } from 'zod'

const ContactSchema = z.object({
  name: z.string().min(1),
  email: z.email(),
  message: z.string().min(10),
})

export async function submitContact(_prev: unknown, formData: FormData) {
  try {
    const parsed = ContactSchema.safeParse({
      name: formData.get('name'),
      email: formData.get('email'),
      message: formData.get('message'),
    })

    if (!parsed.success) {
      return { ok: false, error: 'Please fill in every field.' }
    }

    await db.contact.create({ data: parsed.data })
    await sendResendEmail(parsed.data)

    redirect('/thank-you')
  } catch (error) {
    unstable_rethrow(error)

    console.error('Contact submit failed', error)
    return { ok: false, error: 'Something went wrong. Please try again.' }
  }
}

unstable_rethrow() checks the error's digest. If it is one of Next.js's internal signals (NEXT_REDIRECT, NEXT_NOT_FOUND, NEXT_HTTP_ERROR_FALLBACK), it rethrows so the framework can do its job. Anything else falls through and lands in your real error handling. The name has unstable_ on it but the behaviour has been stable since 15.1 and is the recommended pattern for 16. Expect the prefix to drop in a future minor.

If you cannot use unstable_rethrow() because you are still on an older Next, the manual check uses the digest:

function isFrameworkError(error: unknown): boolean {
  if (!(error instanceof Error)) return false
  const digest = (error as { digest?: string }).digest
  return typeof digest === 'string' && (
    digest.startsWith('NEXT_REDIRECT') ||
    digest.startsWith('NEXT_NOT_FOUND') ||
    digest.startsWith('NEXT_HTTP_ERROR_FALLBACK')
  )
}

try {
  // ...
  redirect('/thank-you')
} catch (error) {
  if (isFrameworkError(error)) throw error
  return { ok: false, error: 'Something went wrong.' }
}

Do not instanceof check against RedirectError from next/dist/.... The internal module path moves between minors and your build will break on the next upgrade.

If you are wiring the form up with useActionState, double-check the action signature. The previous state is the first argument, not the formData. Reversed signatures are the second most common reason a server action "just doesn't redirect". The action gets called with undefined where it expected a FormData and Zod fails the parse before the redirect ever runs.

The Lesson

redirect() is a thrown signal, not a return value. Anything that catches errors in front of the framework will swallow it. Use unstable_rethrow() inside catch blocks, or move the redirect outside the try entirely. Stop string-matching on error.message — the digest is the contract Next.js promises to keep stable.

If your server actions are silently failing to redirect and your forms look broken to users but clean in your logs, that is the kind of Next.js 16 audit I run on production codebases. See my services. For another 16 server action gotcha, read Next.js 16 server actions returning Invalid Server Actions request error.

Server actions failing silently? Let me debug your form flow.

Back to blogStart a project