Pages, Layouts, and Nested Routing
The idea in one sentence
Section titled “The idea in one sentence”In the App Router a folder is a route segment, page.tsx makes that segment publicly routable, and layout.tsx wraps the segment plus every child and stays mounted across navigation.
Folder = route segment
Section titled “Folder = route segment”Each folder under app/ maps to a URL segment. A segment only becomes a reachable URL when it contains a page.tsx.
app/ layout.tsx // root layout (required) page.tsx // route: / dashboard/ layout.tsx // wraps /dashboard and its children page.tsx // route: /dashboard settings/ page.tsx // route: /dashboard/settingsA page.tsx is a Server Component by default and renders the unique UI for its segment.
export default function DashboardPage() { return <h1>Dashboard</h1>}The required root layout
Section titled “The required root layout”Every App Router app needs one root layout. It is the only layout that renders the <html> and <body> tags, and it must render children.
// app/layout.tsx — the required root layoutexport default function RootLayout({ children,}: { children: React.ReactNode}) { return ( <html lang="en"> <body>{children}</body> </html> )}Nested layouts persist and compose
Section titled “Nested layouts persist and compose”A layout.tsx wraps its own segment and all descendants. Nested layouts compose from the outside in: root wraps dashboard wraps settings. Crucially a layout PERSISTS across navigation — when you move between sibling routes the shared layout does not re-render, so its state and scroll position are preserved.
// app/dashboard/layout.tsx — persists across navigationexport default function DashboardLayout({ children,}: { children: React.ReactNode}) { return ( <section> <nav>{/* stays mounted, keeps scroll + local state */}</nav> <main>{children}</main> </section> )}loading.tsx and error.tsx
Section titled “loading.tsx and error.tsx”Add loading.tsx and Next.js automatically wraps the segment in a Suspense boundary — the fallback shows instantly while the server work streams in.
// app/dashboard/loading.tsx — automatic Suspense boundaryexport default function Loading() { return <p>Loading dashboard…</p>}Add error.tsx and Next.js wraps the segment in an error boundary. It must be a Client Component. As of Next.js 16.3.6 it receives both a stable retry() function (re-fetches and re-renders the segment’s children — the officially recommended one since v16.3.0) and the older reset() (clears the boundary’s error state without repeating the failed fetch). AI-generated snippets trained on older examples almost always write reset only; both still work, but retry is the one Next.js’s own docs now lead with.
'use client' // error boundaries must be Client Components
export default function Error({ error, retry,}: { error: Error & { digest?: string } retry: () => void}) { return ( <div> <h2>Something went wrong</h2> <button onClick={() => retry()}>Try again</button> </div> )}The error boundary hierarchy: a layout’s own error escapes upward
Section titled “The error boundary hierarchy: a layout’s own error escapes upward”error.tsx wraps loading.tsx, not-found.tsx, page.tsx, and any nested layout.tsx inside the same segment — but it does not wrap the layout.tsx (or template.tsx) that lives in that same folder. If a layout’s own render throws, the error boundary sitting right next to it never sees it; the error keeps unwinding until it reaches the parent segment’s error.tsx.
// app/settings/layout.tsx — this layout's own render throwsexport default function SettingsLayout({ children,}: { children: React.ReactNode}) { throw new Error('layout boom') return <section>{children}</section>}// app/settings/error.tsx — same segment as the layout above: does NOT catch it'use client'
export default function SettingsError({ error, retry,}: { error: Error & { digest?: string } retry: () => void}) { return ( <div> <h2>SETTINGS ERROR (same segment)</h2> <button onClick={() => retry()}>retry</button> </div> )}// app/error.tsx — the PARENT segment's boundary: this is the one that actually catches it'use client'
export default function RootError({ error, retry,}: { error: Error & { digest?: string } retry: () => void}) { return ( <div> <h2>ROOT ERROR BOUNDARY</h2> <button onClick={() => retry()}>retry</button> </div> )}Built and started for real (next build && next start), then loaded in a browser, the page’s rendered text proves which boundary actually fired:
document.body.innerText ->"root-navROOT ERROR BOUNDARY
Minified React error #441 …
retry"SETTINGS ERROR (same segment) never appears — the layout that threw and the error boundary that would have wrapped it are siblings in the same folder, and error.tsx only wraps what’s below it, not the layout it sits next to.
global-error.tsx replaces the root layout entirely
Section titled “global-error.tsx replaces the root layout entirely”The same rule applies one level higher, with a twist: the root layout.tsx has no parent segment of its own, so nothing wraps it — not even the root’s own app/error.tsx. If the root layout itself throws, the only thing that can catch it is global-error.tsx, and because it replaces the root layout, it must render its own <html> and <body> tags.
'use client' // error boundaries must be Client Components
export default function GlobalError({ error, retry,}: { error: Error & { digest?: string } retry: () => void}) { return ( <html> <body> <h2>Something went seriously wrong</h2> <button onClick={() => retry()}>Try again</button> </body> </html> )}Proven the same way: with the root layout itself throwing, app/error.tsx’s text never renders — only global-error.tsx’s does:
document.body.innerText ->"GLOBAL ERROR (root layout itself crashed)retry"graph TD RL["app/layout.tsx (root layout)"] -->|"throws"| GE["global-error.tsx (only thing above root)"] SL["app/settings/layout.tsx (throws)"] -->|"NOT caught by"| SE["app/settings/error.tsx (same segment)"] SL -->|"caught by"| RE["app/error.tsx (parent segment)"] RE -->|"wraps"| SP["app/settings/page.tsx"]
Mental Model
Section titled “Mental Model”Read error.tsx as “the boundary for everything inside this folder” — not “the boundary for this folder’s own layout.” A layout and its same-folder error.tsx are siblings, not parent/child, so the layout’s own crash always has to travel up to whichever ancestor segment actually wraps it. The root layout is the extreme case: it has no ancestor at all inside the App Router tree, so only global-error.tsx — which stands entirely outside that tree and rebuilds <html>/<body> itself — can catch it.
Trade-offs
Section titled “Trade-offs”| File | What it wraps | Persists across navigation? |
|---|---|---|
layout.tsx | Its own segment + all descendants | Yes — does not re-render on sibling navigation |
loading.tsx | Wraps the segment in a Suspense boundary | Fallback shows per-navigation, not persistent |
error.tsx (same segment) | loading/not-found/page/nested layout files | Does not wrap the layout it sits beside |
error.tsx (parent segment) | Catches a child layout’s own render error | The only same-segment boundary at the root layout has no parent — hence global-error.tsx |
Common Misconceptions
Section titled “Common Misconceptions”- “
error.tsxcatches any error in its own folder, including the layout.” Proven false above: asettings/layout.tsxcrash is invisible tosettings/error.tsxand is only caught by the parent segment’s boundary. - “
reset()is the only properror.tsxgets.” As ofv16.3.0,retry()is the stable, recommended prop — it re-fetches and re-renders, wherereset()only clears the boundary’s local error state. - “The root layout is protected by
app/error.tsxlike everything else.” It isn’t — the root layout is the one place in the tree with no parent segment, so its own errors only reachglobal-error.tsx.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet adds loading.tsx/error.tsx/global-error.tsx, check for these before trusting it:
- Is
error.tsxmissing'use client'? It must be a Client Component — a Server Component version fails at build/runtime, not just lint. - Does the snippet assume
error.tsxwill catch a crash coming from the layout in the same folder? It won’t — that error escapes to the parent segment. - Is
global-error.tsxmissing its own<html>/<body>tags? It replaces the root layout entirely, so without them the page renders no document shell at all. - Does the code destructure
resetonly, with no mention ofretry? Not wrong, but it’s using the older API —retry()is what Next.js’s own current docs lead with. - Is
loading.tsxdoing real async work itself (instead of just rendering a fallback)? The automatic Suspense boundary is what streams in the segment below it —loading.tsxitself should stay a synchronous, instant fallback.
🐛 Spot the bug
export default function DashboardLayout({children,}: {children: React.ReactNode}) {const stats = fetchDashboardStats() // throws if the API is downreturn ( <section> <Header stats={stats} /> {children} </section>)}
// app/dashboard/error.tsx'use client'export default function DashboardError({error,reset,}: {error: Errorreset: () => void}) {return <button onClick={() => reset()}>Retry loading the dashboard</button>}Show the answer
The author put the error-prone data fetch directly in layout.tsx and assumed the error.tsx sitting in the same dashboard/ folder would catch a failure from it. It won’t: error.tsx wraps page.tsx, loading.tsx, not-found.tsx, and any nested layout underneath it, but not the layout it shares a folder with. Proven above with a real next build && next start: the crash surfaces at the parent segment’s boundary instead — in an app where dashboard/ sits directly under the root, that means app/error.tsx (or, for the root layout itself, global-error.tsx). The fix is either to move the data fetch into page.tsx (so a real child boundary wraps it), or to explicitly design for the parent boundary catching it.