The three segment files
The App Router's loading.tsx, not-found.tsx, and error.tsx conventions, the three files you drop beside a page to wire its loading, missing, and error states as nested boundaries.
Picture a route that shows one invoice. The folder is app/invoices/[id]/, and the page reads a single row by its ID and renders the detail:
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const invoice = await getInvoice(id); return <InvoiceDetail invoice={invoice} />;}That is the happy path, and it is the only path this file handles. But a real request has four outcomes, each a different thing on the user’s screen:
- The query is in flight. For the 800ms it takes to come back, what fills the page?
- The query succeeds. The invoice renders. This is the path above, handled.
- The ID matches no invoice. Maybe it was deleted, maybe the link was wrong.
getInvoicereturnsnull. What then? - The query throws. The database is down, the connection timed out. What does the user see instead of a crash?
You already have the raw materials for all four. You met <Suspense> as the loading contract two lessons ago and watched it stream a page in the last one, and you saw notFound() named back when you learned routing. You could hand-wire a <Suspense> boundary and an Error Boundary around this page yourself. The App Router offers something easier: three sibling files you drop next to page.tsx, plus one function you call. Each file is shorthand for a primitive you can already name.
How segment files become boundaries
Section titled “How segment files become boundaries”All three files share one idea, and it works the same way in each.
A specially-named file in a route folder becomes a boundary that wraps that folder’s page.tsx and everything nested below. Because app/ maps the URL path onto folders, each folder is one route segment . Drop loading.tsx in app/invoices/, and its boundary covers /invoices and every route underneath: /invoices/123, /invoices/123/edit, all of it.
That coverage runs deep until a child segment supplies its own boundary. A loading.tsx inside app/invoices/[id]/ overrides the one in app/invoices/ for the [id] subtree, the way a more specific CSS rule beats a general one. The nearer file wins: a high boundary is inherited everywhere below it, and a lower one shadows it for its own branch.
Directoryapp
Directoryinvoices
- loading.tsx wraps everything under invoices/
- error.tsx
Directory[id]
- page.tsx
- loading.tsx overrides for the [id] subtree
- not-found.tsx
Where a file sits and what it covers is this one shared idea; what each file does is the next three.
Inheritance invites a mistake worth a rule: place one boundary per coherent visual surface, not one per folder. Many routes share the same loading shell and error panel. A reflexive loading.tsx in every folder leaves a pile of near-identical skeletons to maintain and gives the user a jarring cascade of fallbacks as they navigate deeper. Put the file where the experience actually changes, and let inheritance cover the rest.
loading.tsx: the segment’s Suspense fallback
Section titled “loading.tsx: the segment’s Suspense fallback”loading.tsx fills the in-flight gap, and there is one idea behind it: the file is the framework writing <Suspense fallback={<Loading />}> around your segment for you. You write the skeleton; Next.js wires the boundary.
The two tabs make that translation visible: the file you write, and the boundary Next.js produces from it.
export default function Loading() { return <InvoiceSkeleton />;}One default-exported component returns a skeleton that mirrors the resolved layout’s footprint, so nothing shifts when the content swaps in.
<Suspense fallback={<Loading />}> {/* layout → page.tsx for this segment */}</Suspense>The framework wraps your segment in the Suspense boundary from the first lesson of this chapter, with your Loading as its fallback.
Once you read the file as that Suspense boundary, the rest follows. Four specifics, tied to the invoice route:
Default export, no props. The framework finds these files by their default export, a deliberate exception to the course’s named-exports rule. It passes no props, since it decides when to show the skeleton and has nothing to hand it. A loading.tsx that reaches for params or searchParams is reaching for context it does not have.
It is a Server Component. No 'use client': the App Router defaults to the server, and a skeleton that renders static markup once, touching no state or browser APIs, gives no reason to opt out. If one piece needs animation, a shimmer or pulse, make that piece its own Client Component that the loading file renders, and keep the directive on the leaf that needs it rather than the whole skeleton.
Skeleton over spinner. A spinner says “something is happening.” A skeleton says “this is what is about to appear”: the same column widths, row count, and heights as the resolved invoice. Because the skeleton already reserves the exact space, nothing jumps when the real data swaps in, which is the difference between a surface that feels polished and one that shifts every time it loads.
Scope inherits as before. app/invoices/loading.tsx covers /invoices and every nested route without its own loading file, and a loading.tsx inside app/invoices/[id]/ overrides it for that subtree, following the inheritance model from the previous section.
not-found.tsx and the notFound() trigger
Section titled “not-found.tsx and the notFound() trigger”The next state is an ID that matches no invoice, so getInvoice(id) returns null. This is the pairing people most often get wrong, so treat the trigger and the file as two halves of one mechanism: neither works without the other.
Start with the trigger, which lives in the page:
import { notFound } from 'next/navigation';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const invoice = await getInvoice(id); if (!invoice) notFound(); return <InvoiceDetail invoice={invoice} />;}Await params. Route params arrive as a Promise in Next.js 16, so you await them before reading id. You met these async request APIs when you learned routing; here they feed the lookup.
import { notFound } from 'next/navigation';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const invoice = await getInvoice(id); if (!invoice) notFound(); return <InvoiceDetail invoice={invoice} />;}Fetch the row. getInvoice(id) returns the invoice or null, the read shape for a single record that may not exist. So invoice has the type Invoice | null.
import { notFound } from 'next/navigation';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const invoice = await getInvoice(id); if (!invoice) notFound(); return <InvoiceDetail invoice={invoice} />;}Guard on the miss. When the row is null, call notFound(). It returns no value to branch on; instead it short-circuits by throwing a special signal the framework catches. Execution stops here, and nothing below runs.
import { notFound } from 'next/navigation';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const invoice = await getInvoice(id); if (!invoice) notFound(); return <InvoiceDetail invoice={invoice} />;}Render the success case. notFound() is typed to return never, so TypeScript treats everything after it as unreachable on the null branch. If execution reached this line, invoice cannot be null: it has narrowed from Invoice | null to Invoice. No !, no cast.
That never return is what lets one call do two jobs: it ships the 404 and narrows the type below to a safe Invoice.
Now the file itself. app/invoices/[id]/not-found.tsx renders when notFound() fires inside that segment. Its advantage is co-location: sitting next to the dynamic invoice route, its UI can be specific, such as “This invoice doesn’t exist” with a link back to the list, instead of a generic site-wide “Page not found.”
import Link from 'next/link';
export default function NotFound() { return ( <div> <h2>Invoice not found</h2> <p>This invoice doesn’t exist, or you don’t have access to it.</p> <Link href="/invoices">Back to all invoices</Link> </div> );}Like loading.tsx, this is a default-exported Server Component that takes no props. It can be async if the 404 UI needs to fetch, but it cannot read params or searchParams. If the message needs the missing ID, fetch that context in page.tsx before calling notFound(); if it needs a client hook like usePathname, do that in a Client Component the file renders.
not-found.tsx gets shown in two distinct ways:
- You call
notFound()after a lookup. The framework walks up from the segment that threw and renders the nearestnot-found.tsx, here your co-located invoice 404. - The URL matches no route at all. Someone visits
/invioces(a typo). No segment matches, so there is nonotFound()to call, and the framework renders the rootapp/not-found.tsx. That root file is the catch-all for every unmatched URL, which is why the generic 404 lives there and the resource-specific one lives next to its route.
Two more things keep people out of trouble.
First, the status code is more subtle than “it returns a 404,” and the subtlety follows from streaming.
Second, and this trips up nearly everyone: not-found.tsx is not tied to a 404 from a fetch. If an API answers 404 Not Found, that HTTP status does nothing on its own and fires no boundary. You read the response, turn it into null or a throw, and call notFound() yourself. The file is wired to the notFound() function, never to a status code from your data layer.
Next.js also has an experimental global-not-found.js, behind a config flag, for apps with multiple root layouts; it is out of scope for this segment-level trio.
error.tsx: the segment’s Error Boundary
Section titled “error.tsx: the segment’s Error Boundary”The last state is the query throwing: the database is unreachable, a timeout fires, something downstream fails. error.tsx catches it.
Back in the first lesson of this chapter you learned that Suspense does not catch errors; that is the Error Boundary’s job. error.tsx is the framework wrapping a React Error Boundary around your segment. Any uncaught throw, in page.tsx, in a nested layout below it, or in any child, renders the error UI instead of crashing the whole tree. This is the file that fills the gap Suspense leaves open.
Here is the canonical shape, walked line by line:
'use client';
export default function Error({ error, unstable_retry,}: { error: Error & { digest?: string }; unstable_retry: () => void;}) { return ( <div> <h2>Couldn’t load this invoice</h2> {error.digest != null && <p>Reference: {error.digest}</p>} <button onClick={() => unstable_retry()}>Try again</button> </div> );}'use client' is mandatory. Error Boundaries are stateful class components under the hood (getDerivedStateFromError, componentDidCatch), and both state and that machinery are client-only. So unlike the other two files, this one cannot be a Server Component; omit the directive and the build fails with a clear error.
'use client';
export default function Error({ error, unstable_retry,}: { error: Error & { digest?: string }; unstable_retry: () => void;}) { return ( <div> <h2>Couldn’t load this invoice</h2> {error.digest != null && <p>Reference: {error.digest}</p>} <button onClick={() => unstable_retry()}>Try again</button> </div> );}The error prop. You receive the thrown error plus an optional digest string. The type is Error & { digest?: string }, verbatim, no any.
'use client';
export default function Error({ error, unstable_retry,}: { error: Error & { digest?: string }; unstable_retry: () => void;}) { return ( <div> <h2>Couldn’t load this invoice</h2> {error.digest != null && <p>Reference: {error.digest}</p>} <button onClick={() => unstable_retry()}>Try again</button> </div> );}Show the digest. For a throw in a Server Component, the real message and stack never cross the wire: you get a generic message and a digest, a hash that ties this failure to your server logs. The full error stays on the server so secrets never leak to the browser. Surfacing the digest gives the user something to paste into a support ticket and you something to grep for.
'use client';
export default function Error({ error, unstable_retry,}: { error: Error & { digest?: string }; unstable_retry: () => void;}) { return ( <div> <h2>Couldn’t load this invoice</h2> {error.digest != null && <p>Reference: {error.digest}</p>} <button onClick={() => unstable_retry()}>Try again</button> </div> );}The retry. unstable_retry() re-fetches and re-renders the segment. Wire it to “Try again”: most segment errors are failed data reads, so you want a fresh attempt, not a re-render of the same broken state.
One reason the message is generic: a raw error can carry a connection string, a file path, or an internal hostname, so Next.js strips Server Component errors before serializing them and keeps the real message and stack in your logs. Errors thrown in Client Components keep their original message, because there is no wire to cross.
The retry has an older sibling, and the difference decides whether it does anything useful:
<button onClick={() => unstable_retry()}>Try again</button>The one you want. It re-runs the segment from scratch, fresh data and fresh render. Since most segment errors are failed reads, only a retry that fetches again can succeed.
<button onClick={() => reset()}>Try again</button>The older, narrower option, a separate prop you destructure instead. It clears the error and re-renders, but does not re-fetch, so a failed Server Component read just reproduces the same error.
Default to unstable_retry(). One caveat: the unstable_ prefix is real, so the name may change in a future release; it is the documented default today, so reach for it and expect a possible rename on upgrade.
What error.tsx catches, and what it doesn’t
Section titled “What error.tsx catches, and what it doesn’t”An Error Boundary catches throws that bubble up to it from below. Scope follows from that, and one piece of it surprises everyone.
error.tsx catches a throw in page.tsx, in any layout nested below it, and in any child. It does not catch a throw in the layout it sits beside, because that layout is the boundary’s parent: the boundary lives inside the layout’s subtree, not around it. If the layout throws while rendering, it throws before the boundary exists to catch it.
layout.tsx (this segment) A throw here escapes — it happens before the boundary exists.
error.tsx boundary throws here bubble up to error.tsx
The same logic runs up the tree: a throw in the root layout escapes app/error.tsx and falls through to the browser’s default error screen. Catching it takes a different file, global-error.tsx, the subject of the next lesson.
Verify the UX in a production build, never just dev
Section titled “Verify the UX in a production build, never just dev”In development, a throw brings up the Next.js error overlay first, with the full message and stack, and your error.tsx renders behind it. So in dev you are looking at the developer experience, not the user’s.
In production the overlay is gone, and only your error.tsx renders, with the generic message and digest exactly as the user sees them. So never sign off on error.tsx from dev alone: build and run the app locally first, so you check the real copy, digest, and layout instead of the overlay sitting on top.
What belongs in error.tsx
Section titled “What belongs in error.tsx”Keep the file small and single-purpose. It owns the failure UI: a message, the digest, a “Try again” button, and later a useEffect that reports the error to your monitoring service. What does not belong is business logic or any data fetching that could itself throw, since an error.tsx that throws while rendering leaves you with no fallback at all.
How the three files wrap one segment
Section titled “How the three files wrap one segment”You have met all three files, each tied to a state on the screen. They are not three independent add-ons: the framework assembles them into three nested boundaries around your segment, in a fixed order, and that order has consequences.
Scrub through the build below. Each step adds one boundary and one state, in the order you learned them; the final frame is the whole wrapper the framework produces from your three files plus the page.
error.tsx error boundary loading.tsx Suspense not-found.tsx not-found boundary error.tsx error boundary loading.tsx Suspense not-found.tsx not-found boundary not-found.tsx → the innermost boundary. It catches a notFound() call from inside the segment.
error.tsx error boundary loading.tsx Suspense not-found.tsx not-found boundary loading.tsx → a Suspense boundary around that. It shows the
skeleton while the segment suspends.
error.tsx error boundary loading.tsx Suspense not-found.tsx not-found boundary error.tsx → the outermost boundary. It catches any throw from
everything inside — which is why an error while the skeleton is showing replaces the
skeleton, not the other way round.
Two things to carry out of that sequence.
First, you write none of that wrapper. From three small files plus your page, the framework produces the entire nested-boundary structure. You never type a <Suspense>, write an Error Boundary class, or wire a not-found catch.
Second, the nesting order decides behaviour. The error boundary is outermost, so a throw while the skeleton is showing replaces the skeleton with the error UI: the error wins over the loading state because it sits outside it. The not-found boundary is innermost, inside Suspense, so a notFound() is caught after the loading phase, close to the page. Read the layers from the outside in and you can predict what the user sees in any combination of states.
The finished app/invoices/[id]/ is four files, one per state.
Directoryapp
Directoryinvoices
Directory[id]
- page.tsx the populated state
- loading.tsx the in-flight state
- error.tsx the failed state
- not-found.tsx the missing state
Check your understanding
Section titled “Check your understanding”Two drills: which file ships which state, and the gotchas that trip people up.
First, the mapping. Drag each scenario into the file that handles it.
A request hits the invoice route. Drag each scenario into the file that ships that state. Drag each item into the bucket it belongs to, then press Check.
TypeError while rendering the invoice detail.getInvoice(id) returns null and the page calls notFound()./invioces and no route matches.Now the gotchas. Mark each true or false; the review explains every one.
Each claim is about the three segment files. Some are the exact traps this lesson flagged. Mark each statement True or False.
error.tsx must start with 'use client'.
error.tsx cannot be a Server Component. It is the one exception among the three files; loading.tsx and not-found.tsx stay on the server.error.tsx catches an error thrown by the layout file sitting next to it in the same segment.
global-error.tsx, the next lesson.loading.tsx needs 'use client' because it shows a loading state.
loading.tsx is a Server Component by default. If one piece needs animation, that piece is a Client Component the loading file renders; the file itself stays on the server.Calling reset() in error.tsx re-fetches the data and tries again.
reset() only clears the error state and re-renders — it does not re-fetch. For a Server Component data error, re-rendering with the same data reproduces the same error. unstable_retry() is the one that re-fetches and re-renders.A fetch that returns HTTP 404 automatically triggers not-found.tsx.
fetch is just a status code; nothing fires on its own. You read the response, turn it into null or a throw, and call notFound() yourself. The file is wired to the function, not to a status.A not-found.tsx always returns an HTTP 404 status.
<meta name="robots" content="noindex"> into that not-found page to keep it out of search results. The noindex meta, not the status code, is what protects SEO.Reveal card-by-card review
External resources
Section titled “External resources”The conceptual guide that frames expected errors versus uncaught exceptions, and where error.tsx and global-error.tsx fit.
The full prop table for error.tsx and global-error.tsx, including the unstable_retry API taught here.
The reference index for loading.js and not-found.js, plus every other special file in the App Router.
The primitive loading.tsx is sugar over — how the fallback shows and how Suspense pairs with an Error Boundary.