Next.js 16 forbidden() Helper Returning 500 Instead of 403

Next.js 16 forbidden() and unauthorized() helpers returning 500 in production? You need forbidden.tsx and unauthorized.tsx files in the route segment.
Next.jsReactApp Router
May 15, 20265 min read960 words

The Problem

I ran into this on a client project moving an internal admin app to Next.js 16. The team had wrapped a Server Component in an authorisation check that called forbidden() from next/navigation whenever the user lacked the required role. In next dev it rendered a clean 403 with their custom UI. As soon as the app deployed to Vercel, every protected page returned a generic Next.js 500 with Internal Server Error in the response body, no logs, and a useless stack trace pointing at react-server-dom-webpack.

If you adopted forbidden() and unauthorized() and production is serving 500s where you expected 403s, the cause is almost always a missing UI file at the right segment level, not the helper itself.

Why It Happens

forbidden() and unauthorized() are not API throws like notFound() was in earlier App Router versions. They are typed errors that the Next.js runtime intercepts and resolves by rendering the matching forbidden.tsx or unauthorized.tsx file in the route segment. If the runtime cannot find that file as it walks up the segment tree, it falls back to the global error boundary, which by design renders a 500 because it is meant for unexpected exceptions, not control flow.

Three things conspire to make this fail in production but not in dev:

  1. Dev mode tolerates missing UI files with a Next.js diagnostic page. The dev overlay catches the unhandled Forbidden error and shows you a dedicated panel that looks like a working 403. It is not. The dev overlay is just rendering the helper's name. Production has no overlay, so the bare Forbidden symbol bubbles up and the runtime returns 500.
  2. forbidden.tsx only handles requests under the segment that defines it. A forbidden.tsx at app/forbidden.tsx does not catch a forbidden() thrown inside app/(admin)/users/page.tsx if the route group is not wired into the segment tree the way you think. The boundary lookup walks up from the segment where the error is thrown, through layouts, until it finds a sibling forbidden.tsx. Route groups ((admin)) are transparent in the URL but real in the segment tree.
  3. The experimental.authInterrupts flag is now stable in 16, and old configs silently break. If your next.config.ts still has experimental: { authInterrupts: true }, Next.js 16 ignores it because the option moved out of experimental. The helpers still import from next/navigation, so type-check passes, but the runtime treats forbidden() as an uncaught throw.

The Next.js authentication docs describe the contract once you know what to look for.

The Fix

You need three things: enable the feature in next.config.ts with the new key, add the boundary files to every segment that calls a helper, and verify the response code in a real production build before shipping.

Step 1: Update the config. In next.config.ts, move the flag out of experimental:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  authInterrupts: true,
}

export default nextConfig

If you leave the old shape, type-check passes but the runtime quietly disables interception. No warning in the build output. This one line is the cause of half the bug reports I see for this issue.

Step 2: Add the boundary files at the right segment. If your route is app/(admin)/users/page.tsx and it calls forbidden(), add both files in the same segment as the layout that wraps it:

// app/(admin)/forbidden.tsx
export default function Forbidden() {
  return (
    <main className="p-12 text-center">
      <h1 className="text-2xl font-semibold">403: Forbidden</h1>
      <p className="mt-2 text-sm text-zinc-500">No access.</p>
    </main>
  )
}

// app/(admin)/unauthorized.tsx
import { redirect } from 'next/navigation'
export default function Unauthorized() {
  redirect('/login?next=/admin')
}

forbidden.tsx renders for forbidden(). unauthorized.tsx renders for unauthorized(). They are not interchangeable. The runtime matches by error type, not file presence.

Step 3: Let the helpers throw cleanly. The helpers throw, and that throw must propagate through the Server Component render to be intercepted. Wrap the call in try/catch to log a denial and you swallow the interrupt — the boundary never runs:

// app/(admin)/users/page.tsx
import { forbidden, unauthorized } from 'next/navigation'
import { getSession } from '@/lib/auth'

export default async function UsersPage() {
  const session = await getSession()
  if (!session) unauthorized()
  if (session.role !== 'admin') forbidden()

  const users = await db.user.findMany()
  return <UsersTable users={users} />
}

No try/catch. The helpers throw, Next.js intercepts, the segment boundary renders, the response is 401 or 403. Log denials on a line above the throw.

Step 4: Verify the status code. Build for production and curl the route as an unauthorised user:

pnpm build && pnpm start
curl -i -H "Cookie: session=guest" http://localhost:3000/admin/users

You want HTTP/1.1 403 Forbidden and the markup from app/(admin)/forbidden.tsx. A 500 means one of the three steps above is wrong, and the config flag is where to start.

The Lesson

forbidden() and unauthorized() are not exceptions you throw and forget. They are control-flow signals the runtime turns into HTTP responses by rendering specific files at specific segments. Skip the segment file or leave the legacy experimental flag and the runtime treats them as crashes. Once the config, boundary, and call shape line up, the 500s vanish.

If your App Router authorisation layer is throwing 500s under load, that is the kind of work I do. See my services. For another App Router pitfall, see Next.js cookies async error in route handlers.

Back to blogStart a project