Not found, forbidden and unauthorized
Stop a page with notFound(), forbidden() or unauthorized(), show not-found.tsx, forbidden.tsx or unauthorized.tsx, and see when 404, 403 or 401 is sent.
- 01Stop a page that can't be shownCall notFound(), forbidden() or unauthorized(), and say which file Next.js shows for each one.
- 02Pick the right oneTell a thing that doesn't exist from a visitor who isn't signed in, and from one who isn't allowed.
- 03Predict the status codeSay when a page answers 404, 403 or 401, and when it answers 200 with a noindex tag instead.
A page for an order that isn't there
Every tea shop order has a page: order 1042 is at /orders/1042. One folder answers them all, app/orders/[id], as Dynamic routes showed. But a visitor can type any number. What should /orders/9999 show, when no order has that number?
Not an error screen: nothing broke. The order just doesn't exist. For that, Next.js has a function, notFound(), and a file, not-found.tsx. Here is the shop with both. Like every example in this lesson, it's a real Next.js 16.4.3 project, built with next build and run with next start. It was also opened in real Chrome: this order page sends its first part before it looks for the order, so its not-found screen is finished in the browser, and only Chrome can show it. The Steps tab opens on /orders/9999:
app/orders/[id]/page.tsximport { notFound } from "next/navigation";import { getOrder } from "../../_data/orders"; export default async function OrderPage({ params }: PageProps<"/orders/[id]">) { const { id } = await params; const order = await getOrder(id); if (!order) notFound(); return ( <main> <h1>Order {order.id}</h1> <p> {order.boxes} boxes of {order.tea} </p> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Check the number on your receipt.
Back to the shop- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (2), without the files it loaded
GET/orders/9999200a whole pageGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /orders/9999
Here is app/orders/[id]/page.tsx, line by line:
- Line 1 imports
notFoundfromnext/navigation. - Line 5 waits for
paramsand takes the order's number,id, out of it. For/orders/9999,idis"9999". - Line 6 looks for the order with
getOrder, fromapp/_data/orders.ts, a stand-in for the shop's database with two orders, 1042 and 1043. - Line 7: if there's no order,
notFound()stops the page. It works by throwing, so nothing after it runs, and you don't writereturnin front of it. TypeScript knows this too: on lines 10 and 12,ordercan't beundefined.
Now click app/orders/[id]/not-found.tsx. It's an ordinary component that takes no props: line 6 is the heading Order not found, and line 8 links back to the shop. The loading.tsx beside it is the loading state from Loading UI: the order page waits for params, and with no list of order numbers during the build, Next.js only learns the number when a request comes in.
In the Steps tab, step 1 opened /orders/9999 and showed Order not found, Check the number on your receipt. and the link, under the root layout's Tea shop link. The address stayed /orders/9999. Step 2 opened /orders/1042, which showed Order 1042 and 2 boxes of Green tea. Now open the Network tab and pick /orders/9999. Two things there may surprise you: the status is 200, not 404, and the HTML holds only Loading your order…, plus a tag, <meta name="robots" content="noindex">. Both come from streaming, sending a page in parts, as in Loading UI: the layout and the loading state went out first, with status 200, before the page ran and called notFound(). React then drew not-found.tsx in the browser.
getOrder("9999") found nothing, so line 7 called notFound(). The page stopped there, and Next.js showed the nearest not-found.tsx in its place. The root layout's link stayed.At a parcel counter, you give a number and get your parcel. When something's wrong, the clerk doesn't close the shop. They give one of three short answers: no parcel has that number, show me your ID first, or this one isn't yours. Each answer tells you what to do next.
- In real life: "No parcel has that number."
- In Next.js:
notFound(), thennot-found.tsx - In real life: "Show me your ID first."
- In Next.js:
unauthorized(), thenunauthorized.tsx - In real life: "I know who you are, but this parcel is someone else's."
- In Next.js:
forbidden(), thenforbidden.tsx - In real life: The counter, the queue and the shop stay open
- In Next.js: The layouts stay on the screen
notFound(), forbidden() and unauthorized() stop a page that can't be shown. Next.js shows the nearest not-found.tsx, forbidden.tsx or unauthorized.tsx in its place, inside the layouts. Called before the response starts, they send 404, 403 or 401; called after, the status stays 200 and Next.js adds a noindex tag.
Which not-found page shows
notFound() goes up the folders to the nearest not-found.tsx, the way an error goes up to the nearest error.tsx. In the order's folder, the docs place not-found.tsx inside the loading state and the error screen: the layout is outside all three, so it stays. Move the file, and guess which screen Chrome shows for /orders/9999:
app/orders/[id]/not-found.tsximport Link from "next/link"; export default function OrderNotFound() { return ( <main> <h1>Order not found</h1> <p>Check the number on your receipt.</p> <Link href="/">Back to the shop</Link> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Check the number on your receipt.
Back to the shop- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (2), without the files it loaded
GET/orders/9999200a whole pageGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /orders/9999
app/orders/[id]/not-found.tsx is the closest one, so Chrome shows its heading, Order not found, under the root layout's link.
With the file in the order's folder, its own screen shows. With only app/not-found.tsx, that one shows: Not on the menu. With none at all, Next.js uses its own, still inside the root layout: 404, This page could not be found., and the tab's title changes to 404: This page could not be found.
app/not-found.tsx has a second job. It also answers addresses that match no route at all, like /oolong, as Project structure showed. Pick Only in app and open the Network tab: /oolong answered 404, and its HTML already holds Not on the menu. Next.js knew before it rendered anything that no route matched. Next.js also has an experimental global-not-found.tsx, for apps with no single root layout; the docs explain it.
Because notFound() throws, a try...catch around it stops it, and the not-found page never shows. The docs suggest calling unstable_rethrow(error) first in such a catch, which throws Next.js's own errors again. An error.tsx doesn't catch it either: Next.js's error boundary throws its own errors again, so they reach the right file (error-boundary.tsx, lines 60 to 64).
forbidden(): signed in, but not allowed
Orders are private. Asha may see her order, 1042, but not Ben's, 1043. When the shop knows who the visitor is, and that visitor may not see the page, call forbidden(). Next.js shows forbidden.tsx. Its status code, when it can send one, is 403 Forbidden.
forbidden() and unauthorized() are still experimental in Next.js 16.4.3. To use them, turn them on in next.config.ts, the file Project structure introduced:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
experimental: {
authInterrupts: true,
},
};
export default nextConfig;
Lines 7 to 9 are the new part: experimental: { authInterrupts: true }. Lines 5 and 6 were already there, as create-next-app writes them. Here is the shop with sign-in added. Click through its files, then read the Steps tab:
app/orders/[id]/page.tsximport { forbidden, notFound, unauthorized } from "next/navigation";import { getCustomer, getOrder } from "../../_data/orders"; export default async function OrderPage({ params }: PageProps<"/orders/[id]">) { const customer = await getCustomer(); if (!customer) unauthorized(); const { id } = await params; const order = await getOrder(id); if (!order) notFound(); if (order.customer !== customer) forbidden(); return ( <main> <h1>Order {order.id}</h1> <p> {order.boxes} boxes of {order.tea} </p> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
You are signed in, but the order belongs to another customer.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (3), without the files it loaded
GET/orders/1043200a whole pageGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /orders/1043
GET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /__PAGE__
- next-url
- /orders/1043
In app/_data/orders.ts, line 15 reads the visitor's name from a cookie called customer: a stand-in for real sign-in, which Authentication teaches. Each order has a customer, on lines 4 and 5. In app/orders/[id]/page.tsx, line 10 compares them: if the order isn't this customer's, forbidden() stops the page. Step 5, signed in as Asha, opened /orders/1043 and showed app/forbidden.tsx: This order is not yours. Step 4 opened her own order, 1042, which showed as usual.
Without the setting, the function doesn't work. Here is the same shop with the next.config.ts that create-next-app writes, asked for /orders/1043 as Asha. Open the Terminal tab and read what next start printed:
app/orders/[id]/page.tsximport { forbidden, notFound, unauthorized } from "next/navigation";import { getCustomer, getOrder } from "../../_data/orders"; export default async function OrderPage({ params }: PageProps<"/orders/[id]">) { const customer = await getCustomer(); if (!customer) unauthorized(); const { id } = await params; const order = await getOrder(id); if (!order) notFound(); if (order.customer !== customer) forbidden(); return ( <main> <h1>Order {order.id}</h1> <p> {order.boxes} boxes of {order.tea} </p> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (5/5) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ /orders/[id]│ └ ◐ /orders/[id]└ ○ /sign-in ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Ready⨯ Error: `forbidden()` is experimental and only allowed to be enabled when `experimental.authInterrupts` is enabled. at d (.next/server/chunks/ssr/_0wxhkcy8ti8qe._.js:1:654) at h (.next/server/chunks/ssr/_0wxhkcy8ti8qe._.js:1:456) { digest: '1905272039'}⨯ Error: `forbidden()` is experimental and only allowed to be enabled when `experimental.authInterrupts` is enabled. at d (.next/server/chunks/ssr/_0wxhkcy8ti8qe._.js:1:654) at h (.next/server/chunks/ssr/_0wxhkcy8ti8qe._.js:1:456) { digest: '2320678624'}The build passed, but the request failed: Error: `forbidden()` is experimental and only allowed to be enabled when `experimental.authInterrupts` is enabled. An ordinary error: like any thrown error, it goes to the nearest error screen, not to forbidden.tsx. With the setting on, the build of the example above lists it: Experiments (use with caution): and ✓ authInterrupts.
Status codes and search engines
A status code is the number at the start of every response: 200 means here is the page, 404 not found, 403 forbidden, 401 sign in first. Search engines read it: a page that answers 404 is dropped from their results. So why did every order page answer 200, even the ones that stopped?
Because of streaming, from Loading UI. The status code is sent first, together with the first part of the page. The order page waits for params, so Next.js sent the layout and Loading your order… at once, with 200. When the page ran and called unauthorized(), the status had already left. Next.js can't take it back. What it can still do is add the tag <meta name="robots" content="noindex">, which tells search engines not to keep the page in their results. React then draws unauthorized.tsx in the browser, which is why the recorded HTML only held the loading state.
A check that runs before anything is sent gets the real status. One place is the build: a page that calls notFound() while next build makes it. The shop's sale page only exists while a sale is on:
import { notFound } from "next/navigation";
import { getSale } from "../_data/sale";
export default async function SalePage() {
const sale = await getSale();
if (!sale) notFound();
return (
<main>
<h1>{sale.title}</h1>
<p>{sale.discount}% off every tea</p>
</main>
);
}
Line 5 asks for the sale, and right now there's none, so line 6 calls notFound(). This page reads nothing from the request, so it runs during the build. Compare both cases:
app/sale/page.tsximport { notFound } from "next/navigation";import { getSale } from "../_data/sale"; export default async function SalePage() { const sale = await getSale(); if (!sale) notFound(); return ( <main> <h1>{sale.title}</h1> <p>{sale.discount}% off every tea</p> </main> );}GET /sale404
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 15 scripts left out
<!DOCTYPE html>
<html id="__next_error__">
<head>
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<meta name="robots" content="noindex">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>There's no sale, so line 6 calls notFound() while next build makes the page. Nothing had been sent yet, so the saved page answers 404, with the noindex tag.
/sale answered 404, with the noindex tag, and the Terminal tab lists it as ○ /sale: the build saved the not-found result, and every visitor gets it. Its HTML is an almost empty document with the noindex tag; in Chrome, React drew Not on the menu from it. The order page answered 200, with the noindex tag. Each row is one way a page can be missing, and what the response says:
| When it runs | What the response says | What search engines see |
|---|---|---|
During next build, like /sale | Nothing has been sent yet. The saved page answers 404, with the noindex tag. | The real status: the page isn't there. |
An address with no route, like /oolong | Next.js knows before it renders anything, so it answers 404. | The real status: the page isn't there. |
For each request, after the first part was sent, like /orders/1042 signed out | The status 200 already went out with the layout and the loading state. Next.js adds the noindex tag. | A 200 page that asks not to be kept in search results. |
/sale called notFound() during the build, before anything was sent: 404. The order page called unauthorized() after the first part had left with 200, so Next.js could only add the noindex tag.Is a 200 with noindex a problem? The docs say some search engines call it a "soft 404", and the noindex tag keeps it out of their results. For private pages like orders, search engines can't see them anyway: they never sign in. When a team still needs the real 401, 403 or 404 for each request, the docs suggest running the check before the page renders, in proxy.ts, which the proxy.ts lesson teaches. And Dynamic routes showed a third case: a tea page whose slug the build didn't list answered 200 the first time and 404 after that, once Next.js had saved the result.
Choosing the right one
The three functions answer three different problems. Each row is one of them, the file that shows, and the status code it stands for:
| Call | When | Shows | Status, before streaming |
|---|---|---|---|
notFound() | The thing doesn't exist: no order with this number. | not-found.tsx | 404 Not Found |
unauthorized() | We don't know who the visitor is: nobody is signed in. | unauthorized.tsx | 401 Unauthorized |
forbidden() | We know who it is, and they may not see it: someone else's order. | forbidden.tsx | 403 Forbidden |
The shop's order pages, ready to ship, have one of each file: not-found.tsx in the order's folder, because its message is about orders, and forbidden.tsx and unauthorized.tsx in app, for every page that checks who's asking:
app/
_data/
orders.ts
orders/
[id]/
loading.tsx
not-found.tsx
page.tsx
sign-in/
page.tsx
sign-in-button.tsx
forbidden.tsx
layout.tsx
page.tsx
unauthorized.tsx
next.config.tsNow sort these visits: which function should the page call?
- Asha types
/orders/9999, and no order has that number. - Someone who isn't signed in opens
/orders/1042. - Asha, signed in, opens Ben's order,
/orders/1043. - A guide page for a tea the shop stopped selling, whose text was deleted.
- A visitor whose sign-in cookie ran out opens their orders.
- Ben, signed in as a customer, opens the staff's stock page.
Sort each visit: which function should the page call?
Common mix-ups
- "notFound() always sends a 404." Only before the response starts. The recorded
/orders/9999answered200, with a noindex tag. - "You have to write return notFound()." No. It throws, so nothing after it runs.
- "error.tsx catches notFound()." No. Next.js passes its own errors on to the right file.
- "forbidden() works like notFound() in every project." No, not in 16.4.3. It needs
experimental.authInterrupts, or it throws an ordinary error. - "401 and 403 mean the same thing." No.
401: sign in first.403: signed in, and still not allowed. - "Without a not-found.tsx in the folder, the visitor gets a blank page." No. The next one up shows, or Next.js's own.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
These are the overview's files. A visitor opens /orders/9999. Which heading does the page show?
app/
_data/
orders.ts
orders/
[id]/
loading.tsx
not-found.tsx
page.tsx
layout.tsx
page.tsxOrder not found, from app/orders/[id]/not-found.tsx, the nearest one. Chrome showed it for /orders/9999, under the root layout's link.
Asha, signed in, opens /orders/1043, Ben's order. Which function stops the page?
import { forbidden, notFound, unauthorized } from "next/navigation";
import { getCustomer, getOrder } from "../../_data/orders";
export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {
const customer = await getCustomer();
if (!customer) unauthorized();
const { id } = await params;
const order = await getOrder(id);
if (!order) notFound();
if (order.customer !== customer) forbidden();
return (
<main>
<h1>Order {order.id}</h1>
<p>
{order.boxes} boxes of {order.tea}
</p>
</main>
);
}
forbidden() on line 10. Asha is signed in, so line 6 passes; order 1043 exists, so line 9 passes; but it's Ben's. Chrome showed This order is not yours.
Line 7 is only a comment. Write the line that shows the not-found page when no order has this number.
import { notFound } from "next/navigation";
import { getOrder } from "../../_data/orders";
export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {
const { id } = await params;
const order = await getOrder(id);
// If no order has this number, show the not-found page
return (
<main>
<h1>Order {order.id}</h1>
<p>
{order.boxes} boxes of {order.tea}
</p>
</main>
);
}
import { notFound } from "next/navigation";
import { getOrder } from "../../_data/orders";
export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {
const { id } = await params;
const order = await getOrder(id);
if (!order) notFound();
return (
<main>
<h1>Order {order.id}</h1>
<p>
{order.boxes} boxes of {order.tea}
</p>
</main>
);
}
if (!order) notFound();. It stops the page and shows the nearest not-found.tsx. Without it, line 10 would read order.id of undefined, and the page would throw an ordinary error.
In a new project, this page fails for Asha on /orders/1043 with an ordinary error instead of showing forbidden.tsx. What does next.config.ts need?
import { forbidden, notFound, unauthorized } from "next/navigation";
import { getCustomer, getOrder } from "../../_data/orders";
export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {
const customer = await getCustomer();
if (!customer) unauthorized();
const { id } = await params;
const order = await getOrder(id);
if (!order) notFound();
if (order.customer !== customer) forbidden();
return (
<main>
<h1>Order {order.id}</h1>
<p>
{order.boxes} boxes of {order.tea}
</p>
</main>
);
}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
experimental: {
authInterrupts: true,
},
};
export default nextConfig;
experimental: { authInterrupts: true } in next.config.ts. Without it, next start printed: forbidden() is experimental and only allowed to be enabled when experimental.authInterrupts is enabled.
Nobody is signed in, and this page sits next to a loading.tsx. Which status does /orders/1042 answer with?
import { forbidden, notFound, unauthorized } from "next/navigation";
import { getCustomer, getOrder } from "../../_data/orders";
export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {
const customer = await getCustomer();
if (!customer) unauthorized();
const { id } = await params;
const order = await getOrder(id);
if (!order) notFound();
if (order.customer !== customer) forbidden();
return (
<main>
<h1>Order {order.id}</h1>
<p>
{order.boxes} boxes of {order.tea}
</p>
</main>
);
}
200, with the noindex tag. The layout and Loading your order… left first, with the status. When line 6 called unauthorized(), Next.js could only add <meta name="robots" content="noindex">.
There's no sale, so getSale() returns null. Which status does /sale answer with?
import { notFound } from "next/navigation";
import { getSale } from "../_data/sale";
export default async function SalePage() {
const sale = await getSale();
if (!sale) notFound();
return (
<main>
<h1>{sale.title}</h1>
<p>{sale.discount}% off every tea</p>
</main>
);
}
404. The page runs during next build, before any response exists, and the build saved the not-found result: the recording lists ○ /sale and answered 404, with the noindex tag.
Check your understanding
Question 1 of 7What happens when the order page calls
notFound()?Choose an answer to see the explanation.
Question 2 of 7The order's folder has no
not-found.tsx, butapp/not-found.tsxexists. What does/orders/9999show?Choose an answer to see the explanation.
Question 3 of 7Asha is signed in and opens Ben's order. Which line of this page stops it?
Read the code, then predictTSXimport { forbidden, notFound, unauthorized } from "next/navigation"; import { getCustomer, getOrder } from "../../_data/orders"; export default async function OrderPage({ params }: PageProps<"/orders/[id]">) { const customer = await getCustomer(); if (!customer) unauthorized(); const { id } = await params; const order = await getOrder(id); if (!order) notFound(); if (order.customer !== customer) forbidden(); return ( <main> <h1>Order {order.id}</h1> <p> {order.boxes} boxes of {order.tea} </p> </main> ); }Choose an answer to see the explanation.
Question 4 of 7What does a Next.js 16.4 project need before it can call
forbidden()orunauthorized()?Choose an answer to see the explanation.
Question 5 of 7The order page has a
loading.tsx, and nobody is signed in. Which status did/orders/1042answer with?Choose an answer to see the explanation.
Question 6 of 7There's no sale, so this page calls
notFound()duringnext build. Which status does/saleanswer with?Read the code, then predictTSXimport { notFound } from "next/navigation"; import { getSale } from "../_data/sale"; export default async function SalePage() { const sale = await getSale(); if (!sale) notFound(); return ( <main> <h1>{sale.title}</h1> <p>{sale.discount}% off every tea</p> </main> ); }Choose an answer to see the explanation.
Question 7 of 7Why does Next.js add
<meta name="robots" content="noindex">to a not-found page that answered 200?Choose an answer to see the explanation.
Key takeaways
notFound()stops a page whose thing doesn't exist, and Next.js shows the nearestnot-found.tsx, inside the layouts. It throws, so nothing after it runs.- Without a
not-found.tsxin the folder, the next one up shows;app/not-found.tsxalso answers addresses with no route. With none, Next.js uses its own. unauthorized()means sign in first (401);forbidden()means signed in, and still not allowed (403). They showunauthorized.tsxandforbidden.tsx.- In Next.js 16.4.3,
forbidden()andunauthorized()are experimental: turn onexperimental.authInterruptsinnext.config.ts. - The status code goes out with the first part of the response. A check during the build gets the real status; a check after the first part was streamed keeps
200and gets a noindex tag.
Remember the one-liner.
Missing, sign in, or not yours: notFound(), unauthorized(), forbidden(), and the status is only real if nothing was sent yet.
Coming next: Redirects. It shows every way to send a visitor somewhere else, with redirect(), in next.config.ts and in proxy.ts.