Keep redirect() out of try

redirect() and notFound() throw; call them outside try/catch.

Next.js docsredirect · Behavior
Do NOT wrap navigation APIs in try-catch. They throw special errors that Next.js handles internally.
API referenceRead docs ↗

From next-best-practices, error-handling.md.

Saved, yet the form fails

Press Publish twice, or tick Step manually.

redirect() inside tryBad
redirect() after tryGood
'use server'
import { redirect, unstable_rethrow } from 'next/navigation'

// Option 1: navigate after the try
export async function createPost(formData: FormData) {
  let post
  try {
    post = await db.post.create({ data: { title: formData.get('title') } })
  } catch {
    return { error: 'Failed to create post' }
  }
  redirect(`/posts/${post.id}`)
}

// Option 2: rethrow Next.js errors first
export async function createPost(formData: FormData) {
  try {
    const post = await db.post.create({ data: { title: formData.get('title') } })
    redirect(`/posts/${post.id}`)
  } catch (error) {
    unstable_rethrow(error)
    return { error: 'Something went wrong' }
  }
}

One catch eats five responses

Pick an API and compare the responses.

Wrapped in try/catchBad
Outside try/catchGood

307 asks again; 308 never does

Visit, remove the redirect on the server, visit again.

redirect() · 307Default
permanentRedirect 308Migrations only
import { redirect, permanentRedirect } from 'next/navigation'

// 307: the default
redirect('/login')

// 308: browsers cache it
permanentRedirect('/blog/new-slug')

Why it matters, pros and cons

Why it matters

They throw a special error, and a catch-all mistakes it for a failure.

Pros

  • The catch only handles real failures.
  • Real 404, 401 and 403 status codes.
  • No false errors, so no duplicate submits.

Cons

  • Hoisting out of try needs a let outside it.
  • unstable_rethrow is unstable and must come first.
  • forbidden() needs experimental.authInterrupts.