The three segment files
The App Router's loading.tsx, not-found.tsx, and error.tsx conventions, the three special files you drop beside a page so the framework wires its loading, missing, and error states as nested boundaries you would otherwise hand-build.
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 to a real route does not have one outcome, it has four, and each one is 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 to handle all four. You met <Suspense> as the loading contract two lessons ago, you watched it stream a page in the last one, and you saw notFound() named back when you learned routing. In principle 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, and this lesson keeps that mapping in view so none of the files feel like magic.
By the end, app/invoices/[id]/ will ship all four states with page.tsx and three small files beside it. You are assembling parts you already know rather than learning from scratch, which is why this should feel lighter than it looks.
The file beside the page
Section titled “The file beside the page”Before any single file, learn the one idea that makes all three click, because it works the same way in every one of them.
A specially-named file in a route folder becomes a boundary that wraps that segment’s page.tsx and everything nested below it. “That segment” means the folder the file sits in. The app/ directory maps the URL path onto folders, so 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 provides its own boundary. A loading.tsx inside app/invoices/[id]/ overrides the one in app/invoices/ for the [id] subtree. The nearer file wins, the way a more specific CSS rule beats a general one. So a boundary you place high is inherited by everything below it, and a boundary placed lower shadows the ancestor for its own branch.
This file tree shows both at once, a shared boundary up top and an override deeper down:
Directoryapp
Directoryinvoices
- loading.tsx wraps everything under invoices/
- error.tsx
Directory[id]
- page.tsx
- loading.tsx overrides for the [id] subtree
- not-found.tsx
Keep this model separate from the three files themselves. Where a file sits and what it covers is one idea, shared by all three, and it is the idea you just learned. What each file actually does is three more ideas, coming next. Keeping those apart is what stops the whole thing from feeling like four arbitrary conventions to memorise.
The inheritance model invites a common mistake, so it is worth a rule now: place one boundary file per coherent visual surface, not one per route folder. Many routes legitimately share the same loading shell and the same error panel. Dropping a loading.tsx into every folder by reflex leaves you with 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 genuinely changes, and let inheritance cover the rest.
loading.tsx: the segment’s Suspense fallback
Section titled “loading.tsx: the segment’s Suspense fallback”Start with the in-flight state, the 800ms gap. The file that fills it is loading.tsx, and there is one idea behind it: loading.tsx is the framework writing <Suspense fallback={<Loading />}> around your segment for you. You write the skeleton, and Next.js wires the boundary.
The two tabs below make that translation visible. The first is the file you actually write; the second is the boundary Next.js produces from it.
export default function Loading() { return <InvoiceSkeleton />;}You write one default-exported component that returns a skeleton, with no props and no boundary. The skeleton 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 exact <Suspense> boundary from the first lesson of this chapter, with your Loading as its fallback. You never write this wrapper, but it is precisely the Suspense pattern you already know.
Once you see the file as the Suspense pattern, everything else about loading.tsx follows from what you already know about Suspense. Here are a few specifics, tied to the invoice route.
Default export, no props. loading.tsx is default-exported, a deliberate exception to the course’s named-exports rule, because the framework finds these files by their default export. It also receives no props, since the framework decides when to show it and there is nothing to pass in. A loading.tsx that tries to read params or searchParams is reaching for context it does not have.
It is a Server Component. It needs no 'use client'. The default in the App Router is the server, and opting in to the client needs a reason that a loading skeleton does not have. The skeleton renders static markup once and never touches state or browser APIs. If a piece of it needs animation, a shimmer or a pulse, that animated piece becomes its own Client Component that the loading file renders, while the file itself stays on the server. Keep the directive on the leaf that genuinely needs it, not on 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, the same number of rows, the same heights as the resolved invoice. When the real data swaps in, nothing on the page jumps, because the skeleton already reserved the exact space. That is the difference between a surface that feels polished and one that shifts every time it loads.
Scope works exactly as you just learned. 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. Nothing new here: it is the inheritance model from the previous section, applied to this specific file.
There is one sharp edge worth knowing before it shows up in a build log.
not-found.tsx and the notFound() trigger
Section titled “not-found.tsx and the notFound() trigger”The next state is the ID matching no invoice, so getInvoice(id) comes back 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. Here is the shape for fetch-then-guard, walked line by line:
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 in the previous chapter; 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 legitimately not exist. At this point 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 does not return a value to branch on; instead it short-circuits by throwing a special signal the framework catches. Execution stops on this line, and nothing below it 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. Because notFound() never returns, TypeScript can reason that if execution reached this line, invoice was not null: it has narrowed Invoice | null down to Invoice. The narrowing is real, not a hope. notFound() is typed to return never, so TypeScript treats everything after it as unreachable on the null branch. No !, no cast.
That never return is what makes the guard do two jobs at once: it ships the 404, and it tells the type system the value below is safe. One call gives you both the runtime behaviour and the type narrowing.
Now the file itself. app/invoices/[id]/not-found.tsx is what renders when notFound() fires inside that segment. The advantage here is co-location: because this file sits 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.” The route that knows it is about invoices ships the 404 that knows it too.
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, to read a header or look up a suggestion, but it cannot read params or searchParams directly. If the message needs the missing ID, fetch that context in page.tsx before you call notFound(); if it needs a client hook like usePathname, do that work in a Client Component the file renders.
There are two distinct ways not-found.tsx gets shown, and the difference matters:
- You call
notFound()after a lookup. This is the case above. The framework renders the nearestnot-found.tsx, walking up from the segment that threw, which is your co-located invoice 404. - The URL matches no route at all. Someone visits
/invioces(a typo). No segment matches, so there is nonotFound()call to make, and the framework automatically renders the rootapp/not-found.tsx. That root file is the catch-all for every unmatched URL in the app, which is why a generic site-wide 404 belongs there and a resource-specific one belongs next to its route.
Two more things keep people out of trouble.
First, the status code, which is more subtle than “it returns a 404.” That subtlety follows directly from the streaming you learned last lesson.
Second, and this trips up nearly everyone the first time: not-found.tsx is not tied to a 404 from a fetch. If you call some API and it answers 404 Not Found, that HTTP status does nothing on its own, and no boundary fires. You read that response, turn it into null or a throw, and call notFound() yourself. The file is wired to the notFound() function, never to an HTTP status code that came back from somewhere inside your data layer.
There is one more name worth filing away. Next.js also has an experimental global-not-found.js, behind a config flag, for apps with multiple root layouts or a dynamic root segment. It is out of scope here, since this lesson covers the 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. The file that catches it is error.tsx.
Start with the primitive, the one you were promised back in the first lesson of this chapter. You learned there that Suspense does not catch errors; that is the Error Boundary’s job. error.tsx is the framework writing 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 file fills the gap Suspense deliberately left 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 class-component machinery under the hood (getDerivedStateFromError, componentDidCatch) and they hold state, both of which 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. That is why error.tsx is the exception here, not an arbitrary rule.
'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 shape 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 errors thrown 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. This is the security property from the previous chapter holding the line: the full error stays on the server so secrets never leak to the browser. Surfacing the digest in the UI gives the user something to paste into a support ticket and gives 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-runs the segment: it re-fetches and re-renders. Wire it to your “Try again” button, because most segment errors are failed data reads and you want a genuine fresh attempt, not a re-render of the same broken state.
Two of those steps deserve more than a step’s worth of space.
The digest, and why the message is generic. When a Server Component throws, the error is serialized to be sent to the browser, but Next.js strips it first. The user, and your error.tsx, see a flat “An error occurred in the Server Components render” plus a digest hash. The actual message and stack trace stay server-side, written to your logs. This is deliberate: a raw error message can carry a connection string, a file path, or an internal hostname, none of which you want to expose to whoever is poking at your app. The digest is the bridge across that gap. Print it in the UI, and a support ticket that says “Reference: 1a2b3c” lets you find the real stack in your logs in seconds. Errors thrown in Client Components keep their original message, because there is no wire to cross and so nothing to strip.
unstable_retry() versus reset(). There are two ways to retry, and the difference decides whether the retry does anything useful.
<button onClick={() => unstable_retry()}>Try again</button>This is the one you want. It re-runs the segment from scratch, fresh data and fresh render, by doing a router.refresh() and a reset() together inside a transition. Because most segment errors are failed reads, the only retry that can succeed is one that fetches again.
<button onClick={() => reset()}>Try again</button>The older, narrower option, a separate prop you destructure instead. It clears the error state and re-renders the segment, but it does not re-fetch. If the cause was a failed Server Component read, re-rendering with the same data just reproduces the same error, so for data failures reset() does nothing useful.
The choice between the two is the most common mistake with this file: reset() means “try rendering again with the same data,” while unstable_retry() means “go get fresh data, then try again.” For Server Component data errors, which is most of what you will hit, only the second one can recover. Default to unstable_retry().
One honest caveat: the unstable_ prefix is real. The API is still settling, and the name may change in a future release. But it is the documented default in the current version, and it does the thing you actually want, so reach for it and expect that a future upgrade may rename it.
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. So scope matters, 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 component. It does not catch a throw in the layout it sits beside. The reason is structural: that sibling layout is the boundary’s parent, so the boundary lives inside the layout’s subtree, not around it. If the layout itself throws while rendering, it throws before the boundary exists to catch it, and the error passes right by.
The diagram makes the asymmetry concrete. The error boundary wraps everything below the layout, but the layout sits outside 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
This is a real limitation with a real consequence. Follow it up one level: a throw in the root layout escapes app/error.tsx for exactly the same reason, and falls through to the browser’s default error screen, because there is no error.tsx above the root to catch it. The fix is a different file, global-error.tsx, and it is the entire subject of the next lesson. This is a deliberate handoff, not a gap left open.
Verify the UX in a production build, never just dev
Section titled “Verify the UX in a production build, never just dev”There is a trap here that has cost people an afternoon of “but it works on my machine.”
In development, when your segment throws, the Next.js error overlay comes up first with the full message, full stack, and the whole diagnostic. Your error.tsx renders behind it. So in dev you are looking at the developer experience, not the user experience.
In production, the overlay is gone. Only your error.tsx renders, with the generic message and the digest for a Server Component throw, exactly as the user will see it. The rule that follows is simple and worth keeping: never sign off on error.tsx from dev mode alone. Build the app and run it locally before you ship, so you are looking at what the user actually gets, the real copy, the real digest, and the real layout, rather than the dev overlay sitting on top of it.
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, once you wire up monitoring, a useEffect that reports the error to your error-tracking service. The full integration comes much later in the course; for now, just know the report belongs here. What does not belong is business logic, or any data fetching that could itself throw. An error.tsx that throws while rendering the error 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 motivated by a state on the screen. Now see how they compose, because they are not three independent gadgets bolted on side by side. 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, and 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.
There are two things to carry out of that sequence.
First, you write none of that wrapper. Three small files plus your page, and the framework produces the entire nested-boundary structure. You never type a <Suspense>, never write an Error Boundary class, never wire a not-found catch. The files are the configuration.
Second, the nesting order is not arbitrary; it decides behaviour. The error boundary is outermost, which means a throw that happens 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 exactly what the user sees in any combination of states.
So here is the image to leave with. The finished app/invoices/[id]/ is four files, and that is no coincidence: it is one file 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
Four states of a route, four files. When you look at any route from now on, ask the question this lesson trained: where are my four states, and which file ships each one?
Check your understanding
Section titled “Check your understanding”Two drills on the two things this lesson most wants to stick: which file ships which state, and the precise 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, the claims this lesson most wants you to get right. Mark each true or false; the review at the end 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.