
Next.js forbidden function
Most tutorials on handling errors in Next.js focus on the easy case: the thing doesn't exist, so you show a 404. Far fewer talk about the case that actually shows up constantly in production apps — the thing exists, the user is logged in, and they still shouldn't see it. That's not a "not found" problem, it's a "forbidden" problem, and conflating the two either leaks information (a 404 on a resource that exists tells an attacker it exists) or just produces a confusing user experience.
Next.js has a purpose-built primitive for exactly this: the forbidden function from next/navigation. It's still marked experimental, which means the API surface could shift before it stabilizes, but the underlying model — throw to signal an authorization failure, let a special file render the UI — is already the same pattern Next.js uses for notFound() and unauthorized(), so it's worth understanding now even if you hold off using it in production.
What forbidden() Actually Does
Calling forbidden() throws a special error — internally it's NEXT_HTTP_ERROR_FALLBACK;403 — that Next.js intercepts. When it's thrown, three things happen:
- Rendering of the current route segment stops immediately.
- Next.js renders the nearest
forbidden.jsfile in that segment's tree (or a default forbidden UI if you haven't defined one). - A
<meta name="robots" content="noindex" />tag gets injected into the response, so search engines don't index a page that told a specific user "you can't see this."
That third point is easy to miss and genuinely useful — you don't have to remember to keep admin-only pages out of your sitemap or manually add a noindex tag; calling forbidden() handles it as a side effect.
Because this all works by throwing an exception, forbidden() behaves like throw everywhere else in JavaScript: it unwinds the call stack until something catches it (in this case, Next.js's own boundary), and any code after the call in the same function never runs. You don't write return forbidden() — its TypeScript signature returns never, and the function call itself already halts execution.
Enabling It: The authInterrupts Flag
Because forbidden() is experimental, it's gated behind a config flag. You won't be able to import it and have it do anything meaningful until you turn on authInterrupts in next.config.js:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
authInterrupts: true,
},
};
export default nextConfig;
This same flag also gates unauthorized(), so if you're using both functions you only need to enable it once. Treat this the way you'd treat any experimental flag: pin your Next.js version deliberately, and re-check the changelog before upgrading, since the behavior (or the flag's existence) could change.
Where You Can Call It
forbidden() is valid inside Server Components, Server Functions (Server Actions), and Route Handlers. The one place it explicitly cannot be called is the root layout — there's no meaningful "forbidden" state for your entire application shell, so Next.js disallows it there. If you need to gate everything behind auth, do that check one level down, in the first layout or page that actually has a concept of "this section requires access."
Here's the baseline pattern — a Server Component page that checks a role before rendering:
// app/admin/page.tsx
import { verifySession } from "@/app/lib/dal";
import { forbidden } from "next/navigation";
export default async function AdminPage() {
const session = await verifySession();
if (session.role !== "admin") {
forbidden();
}
return (
<main>
<h1>Admin Dashboard</h1>
<p>Welcome, {session.user.name}!</p>
</main>
);
}
The Execution Model: Why It Must Be Awaited
This is the part of forbidden() that trips people up, and it's worth being explicit about because the docs mention it almost in passing. Since the function works by throwing, it only does anything useful if something is actually watching for that throw — which means the code path it's called from has to be awaited by whatever's rendering it.
If you call forbidden() inside a promise that nobody awaits, the throw happens with nothing listening for it. In development you'll see ⨯ unhandledRejection: NEXT_HTTP_ERROR_FALLBACK;403 logged to your server console, but the user won't see a forbidden page — they'll likely see nothing render at all, or a generic error, depending on what else is going on. The fix is mechanical but easy to forget under a stack of .then() chains or fire-and-forget calls: always await any function that might call forbidden() internally.
The other gotcha is try/catch. Since forbidden() works by throwing, a try/catch block wrapped around the call — or around a function that calls it — will happily swallow that "error" like any other, and no forbidden UI renders at all. If you have code that wraps data-access calls in a blanket try/catch for logging or retry logic, and one of those calls might invoke forbidden(), you need to explicitly re-throw Next.js's control-flow errors. That's exactly what unstable_rethrow is for — pass the caught error through it before doing anything else with your catch block, and it'll let forbidden()'s special error continue propagating while still catching genuine application errors.
Practical Pattern: Role-Based Route Protection
The most common real use of forbidden() is guarding an entire route by role, rather than by resource ownership (that's more unauthorized()'s territory, or just a redirect to login). A /admin section is the canonical example:
// app/admin/page.tsx
import { verifySession } from "@/app/lib/dal";
import { forbidden } from "next/navigation";
export default async function AdminPage() {
const session = await verifySession();
if (session.role !== "admin") {
forbidden();
}
return (
<main>
<h1>Admin Dashboard</h1>
<p>Welcome, {session.user.name}!</p>
</main>
);
}
The key architectural detail here isn't the forbidden() call itself — it's where verifySession() lives. Next.js's own guidance (and general good practice) is to centralize this kind of check in a Data Access Layer function rather than scattering role checks across individual pages. That way the authorization logic has exactly one place to audit, and every page that needs it just calls the same function.
Practical Pattern: Protecting Server Action Mutations
Reads aren't the only thing worth protecting — mutations often matter more, since a forgotten check on a write path is how a regular user ends up able to promote themselves to admin. forbidden() works the same way inside a Server Action:
// app/actions/update-role.ts
"use server";
import { verifySession } from "@/app/lib/dal";
import { forbidden } from "next/navigation";
import db from "@/app/lib/db";
export async function updateRole(formData: FormData) {
const session = await verifySession();
if (session.role !== "admin") {
forbidden();
}
// Perform the role update for authorized users
// ...
}
It's worth internalizing that this check has to run on every Server Action independently. Next.js doesn't automatically inherit the authorization check from whatever page rendered the form that called this action — a Server Action is a real network-callable endpoint under the hood, and someone can invoke it directly without ever loading the page. Treat every Server Action as its own attack surface, not as protected-by-association with the UI that happens to trigger it.
Streaming Complicates the Status Code
This is the single most useful piece of nuance in the whole forbidden() API, and it's easy to miss if you only skim the basic example. If you check authorization directly in a page component before anything renders, the 403 status code goes out cleanly — nothing has streamed yet, so Next.js can still set the response status.
But a common performance pattern is to let a page's static shell (title, layout, loading skeleton) stream immediately, and push the actual data-dependent check into a component wrapped in <Suspense>:
// app/projects/page.tsx
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>
);
}
Here, getProjects() calls forbidden() inside the Suspense boundary, and Next.js still swaps in your forbidden.tsx UI in place of the streamed-in content. The problem is that the HTTP response has already started streaming as a 200 by the time that check runs — an HTTP status code can only be set once, before the first byte goes out, and those first bytes (the shell, the loading fallback) went out before the authorization check even started. The user sees the right thing, but any tooling checking the raw status code — a monitoring script, a crawler, an API client — sees a 200.
For a user-facing page, this is usually a non-issue: the visible content is correct, which is what matters to a human. If you genuinely need the wire-level status code to be a real 403 — for an API-like route, or anything a non-browser client depends on — the check has to happen before streaming starts, which under the newer Cache Components model means running it in proxy instead, since every dynamic route streams a static shell first regardless of where you put the check inside the component tree.
forbidden() vs. notFound() vs. unauthorized() vs. a Plain Error
These four options for "cut off rendering and show something else" overlap enough that it's worth a direct comparison:
| Function | Status implied | Use it when |
|---|---|---|
notFound() | 404 | The resource genuinely doesn't exist, or you deliberately want to hide its existence from this user |
unauthorized() | 401 | The user isn't authenticated at all — send them to log in |
forbidden() | 403 | The user is authenticated, but lacks permission for this specific resource or action |
Throwing a plain Error | 500 (via error.js) | Something actually broke — a bug, a downstream failure — not an authorization decision |
Choosing the right one matters beyond semantics: unauthorized() implies "come back after you log in," which is recoverable by the user, while forbidden() implies "no amount of logging in fixes this without a role change," which usually isn't something the user can self-serve. Mixing them up sends the wrong signal about what the user should do next.
Common Mistakes
Calling it in the root layout. Next.js disallows this outright — move the check into the first layout or page beneath the root that actually needs it.
Forgetting authInterrupts. Without the flag enabled, forbidden() isn't a functioning interrupt — set it in next.config.js before you rely on the function anywhere.
Leaving a call in an un-awaited promise. The throw fires into the void; nothing renders the forbidden UI, and you're left debugging a silent failure instead of a visible one.
Wrapping the call in a blanket try/catch. This silently discards the interrupt. Use unstable_rethrow in any catch block that might see this error pass through.
Expecting a real HTTP 403 from inside a Suspense boundary. The visible UI will be correct, but the wire-level status code was already committed as 200 before the check ran. Move the check earlier (or into proxy) if the status code itself matters to a consumer.
Key Takeaways
forbidden() gives Next.js apps a proper vocabulary for "you exist, you're logged in, and you still can't see this" — distinct from a 404 that hides existence entirely and a 401 that says "log in first." It's built on the same throw-and-catch mechanism as the rest of Next.js's navigation interrupts, which means the same rules apply: it has to run somewhere that's awaited, it has to avoid being swallowed by a stray try/catch, and if you genuinely need the wire-level status code to reflect the 403, the check needs to happen before your response starts streaming.
| If you need to... | Do this |
|---|---|
| Gate a whole admin section | Call forbidden() in a layout or page below the root, backed by a centralized DAL check |
| Protect a mutation | Call forbidden() inside the Server Action itself — don't rely on the calling UI being protected |
| Keep pages fast while still checking auth | Run the check inside a <Suspense>-wrapped component, accepting that the wire status stays 200 |
| Guarantee a real 403 status code | Run the check in proxy, before any response streaming begins |
Catch errors without swallowing forbidden() | Pass the caught error through unstable_rethrow before handling it |


