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:
- Dev mode tolerates missing UI files with a Next.js diagnostic page. The dev overlay catches the unhandled
Forbiddenerror 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 bareForbiddensymbol bubbles up and the runtime returns 500. forbidden.tsxonly handles requests under the segment that defines it. Aforbidden.tsxatapp/forbidden.tsxdoes not catch aforbidden()thrown insideapp/(admin)/users/page.tsxif 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 siblingforbidden.tsx. Route groups ((admin)) are transparent in the URL but real in the segment tree.- The
experimental.authInterruptsflag is now stable in 16, and old configs silently break. If yournext.config.tsstill hasexperimental: { authInterrupts: true }, Next.js 16 ignores it because the option moved out ofexperimental. The helpers still import fromnext/navigation, so type-check passes, but the runtime treatsforbidden()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.