
Next.js forbidden.js
Most authorization failures in a web app fall into one of two buckets, and they deserve different responses: "you're not logged in" (401, please sign in) and "you're logged in, but you're not allowed to see this" (403, this isn't for you). Next.js has had notFound() and not-found.js for a long time to hide the existence of a resource entirely. forbidden.js is the newer, narrower counterpart — a special file that renders when your code explicitly calls the forbidden() function during an authorization check, and it correctly returns a 403 status code rather than reusing 404 for a fundamentally different situation.
This is a short reference by design — the file itself has almost no surface area — but the distinction it represents (403 vs. 404 as a genuine user-facing signal, not just an HTTP status code nobody reads) is worth understanding properly.
Basic Usage
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>
);
}
Placed in the app directory (or scoped to a specific segment, following the same nesting rules as not-found.js and error.js), this file is what renders whenever your authentication or authorization logic calls the paired forbidden() function from next/navigation. You don't invoke forbidden.js directly — it's the UI half of a pair, where forbidden() is the imperative trigger and forbidden.js is the resulting fallback screen.
Why 403 and Not 404
It's tempting to just reuse notFound() for every access-denial case, since it's the tool most people reach for first and it already exists in every Next.js app. But 404 and 403 communicate genuinely different things, both to a human and to any tooling (crawlers, monitoring, security scanners) reading the response:
- 404 (Not Found) — as far as the requester should know, nothing exists at this URL. This is the right choice when you don't want to confirm or deny that a resource even exists to someone who isn't authorized to know that — a genuinely useful privacy property in some systems.
- 403 (Forbidden) — the resource exists, and you know exactly what's there, but this particular request isn't permitted to access it. This is the honest, transparent answer for the far more common case: a logged-in user hitting an admin page they don't have the role for, or a paid feature behind a subscription tier they haven't purchased.
Silently 404-ing every authorization failure trains your users (and your own debugging instincts) to distrust every not-found page — was the thing actually deleted, or was it just permissions? forbidden.js exists so you don't have to make that tradeoff by default; you can be explicit when explicitness is the right call, and reserve 404 for situations where obscuring existence is actually the point.
Props
forbidden.js components accept no props at all. There's no error object, no params, nothing to destructure — it's a pure, static fallback component. If your forbidden page needs to know why access was denied (missing role, expired subscription, IP-restricted region), that logic has to live in whatever code called forbidden() in the first place, and be communicated to the user through some other mechanism — a query param your fallback reads via searchParams if it's a page-level page.js, or a value your authorization layer writes somewhere the UI can independently look up.
import { forbidden } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
export default async function AdminPage() {
const user = await getCurrentUser();
if (user.role !== "admin") {
forbidden();
}
return <AdminDashboard />;
}
Calling forbidden() here interrupts rendering of AdminPage and causes the nearest forbidden.js boundary — following the same segment-nesting rules as error.js and not-found.js — to render instead, with a 403 status code attached to the response.
Experimental Status
Worth flagging clearly, since it changes how you should treat this in production planning: as of this writing, forbidden.js (and its paired forbidden() function) is still marked experimental in the official docs, with an explicit note that it's not recommended for production use yet. If you're building something today, that means:
- Expect the API surface (function signature, file behavior, or even the convention's existence) to potentially change in a future release without the usual deprecation runway a stable API would get.
- If you need reliable 403 handling in production right now, the more conservative path is to implement it yourself — a custom Route Handler middleware, or simply rendering a 403-styled response directly from within your page/layout logic without relying on this specific convention — and revisit adopting
forbidden.jsonce it graduates to stable. - If you do experiment with it, the Next.js team explicitly invites feedback via GitHub issues — this is exactly the kind of feature where early real-world usage reports shape what ships as stable.
Version History
| Version | Changes |
|---|---|
v15.1.0 | forbidden.js introduced |
Key Takeaways
| Aspect | Detail |
|---|---|
| Purpose | Renders when forbidden() is called; returns a 403 status |
| Props | None — a static component with no error, params, or other data |
vs. not-found.js | Use 403 when you want to be honest that the resource exists but isn't permitted; use 404 to obscure existence entirely |
| Status | Experimental — not recommended for production as of this writing |
| Where to check for updates | Official Next.js GitHub issues track feedback on this feature's stabilization |
The whole point of forbidden.js is to give your app a clean, correct way to say "no" without pretending something doesn't exist when it does. It's a small file with an outsized effect on how trustworthy your error states feel to real users — just build it with the awareness that it's still evolving, and plan your production authorization flow accordingly until it stabilizes.


