forbidden.js

Renders 403 UI when forbidden() is thrown, for users authenticated but lacking permission.

Signature
export default function Forbidden(): React.ReactNode
  • Added in Next.js v15.1.0

Return value

React.ReactNode | Promise<React.ReactNode>The UI rendered in place of the segment when forbidden() is thrown; Next.js returns a 403 status for non-streamed responses.

Errors

Nothing itself, but the forbidden() call that triggers it throws NEXT_HTTP_ERROR_FALLBACK;403 and is swallowed by any try/catch around it. forbidden() cannot be called in the root layout.

Changelog

  • PHP v15.1.0forbidden.js introduced. Still experimental in v16 and not recommended for production.

The forbidden file is the UI half of forbidden(): a user who is signed in but lacks the required role. Next.js returns a 403 status and injects <meta name="robots" content="noindex">. It is still experimental and not recommended for production.

Nothing renders until you enable the flag:

// next.config.ts
const nextConfig = { experimental: { authInterrupts: true } }

Use it to separate "wrong account" from "not signed in" — the latter belongs in unauthorized.js — so the page can say ask an admin for access rather than showing a pointless login form.

What trips people up

  • Forgetting authInterrupts. Without it the call is not available and this file is inert.
  • try/catch around the call swallows it. forbidden() works by throwing, so a wrapping catch block suppresses the interrupt and no 403 UI renders. Re-throw with unstable_rethrow.
  • Un-awaited promises lose it. A forbidden() inside a promise nobody awaits throws where nothing catches — development logs unhandledRejection: NEXT_HTTP_ERROR_FALLBACK;403.
  • It cannot be called in the root layout.
  • The status is only really 403 before streaming starts. Check inside a <Suspense> boundary and the response is already a 200.
  • Takes no props; never write return forbidden().

Example

Example · typescript
import Link from 'next/link'

export default function Forbidden() {
  return (
    <div>
      <h2>Forbidden</h2>
      <p>You are not authorized to access this resource.</p>
      <Link href="/">Return Home</Link>
    </div>
  )
}

When to use it

  • Show an access-denied screen when a signed-in user lacks the admin role
  • Block role-restricted mutations inside a Server Action and render 403 UI
  • Give a per-section 403 message in /admin that differs from the app-wide one
  • Distinguish a permissions problem from a missing session for support triage

Frequently asked questions

Why is my forbidden.js not rendering?

The most common cause is that experimental.authInterrupts is not enabled in next.config.ts — without it, forbidden() is unavailable and the file is inert. The other common cause is a try/catch around the call: forbidden() works by throwing, so a catch block suppresses the interrupt. Use unstable_rethrow inside the catch to let it through, and make sure any promise that calls it is awaited.

What is the difference between forbidden.js and unauthorized.js?

forbidden.js answers a 403 — the user is authenticated but lacks the permission, so the useful UI explains the restriction or points at an administrator. unauthorized.js answers a 401 — there is no valid session, so the useful UI is a login form. Both are experimental and both require the same authInterrupts flag.

Why does forbidden() return a 200 status instead of 403?

Because the response had already started streaming when it was thrown. Once headers are sent the status is fixed, so the 403 UI renders over a 200 response — this happens whenever the check sits inside a <Suspense> boundary, and with Cache Components every dynamic route streams a static shell first. Run the check before anything suspends, or do it in proxy.js, to get a real 403.

More examples

Enabling the flag and guarding a route

This is next.config.ts. Until experimental.authInterrupts is true, forbidden() and unauthorized() are unavailable and the matching files never render. The flag is a single switch for both interrupts, and being experimental it may change between minor releases.

Example · typescript
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    authInterrupts: true,
  },
}

export default nextConfig

Role check in a Server Component

forbidden() has a TypeScript never return type, so no return keyword is needed and TypeScript narrows session.role below the guard. The throw is caught by the nearest forbidden.js. Because the check runs before any suspending boundary, the response can still carry a genuine 403 status.

Example · typescript
import { verifySession } from '@/app/lib/dal'
import { forbidden } from 'next/navigation'

export default async function AdminPage() {
  const session = await verifySession()

  // Check if the user has the 'admin' role
  if (session.role !== 'admin') {
    forbidden()
  }

  // Render the admin page for authorized users
  return (
    <main>
      <h1>Admin Dashboard</h1>
      <p>Welcome, {session.user.name}!</p>
    </main>
  )
}

Checking inside the data layer, behind Suspense

The shell and heading stream immediately while the session resolves inside the boundary; the forbidden UI then swaps in where the list would have gone. The trade-off is the status code — streaming began as a 200 and cannot be changed, so the user sees 403 UI over a 200 response. Move the check into proxy.js if a real 403 status matters.

Example · typescript
import { Suspense } from 'react'
import { verifySession } from '@/app/lib/dal'
import { forbidden } from 'next/navigation'

async function getProjects() {
  const session = await verifySession()
  if (session?.role !== 'admin') {
    forbidden()
  }
  return db.projects.findMany()
}

async function Projects() {
  const projects = await getProjects()
  return (
    <ul>
      {projects.map((project) => (
        <li key={project.id}>{project.name}</li>
      ))}
    </ul>
  )
}

export default function ProjectsPage() {
  return (
    <main>
      <h1>Projects</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <Projects />
      </Suspense>
    </main>
  )
}

Discussion

  • Be the first to comment on this lesson.