App Router architecture: layouts that persist and files that mean things
Folders are routes, special files are UI: layout wraps and persists across navigation (it never re-renders, state survives), page is the leaf, loading is a Suspense boundary, error a client boundary. Route groups, parallel and intercepting routes shape the tree.
A SaaS team ships a dashboard: the sidebar lives in app/dashboard/layout.tsx and fetches the user’s subscription so it can show a “Free plan” badge. A customer upgrades on /dashboard/settings/billing, sees the success toast, clicks over to /dashboard/projects — and the sidebar still says “Free plan”. The team burns a day on it: they blame fetch caching, sprinkle cache: 'no-store' everywhere, even add a timestamp query param. Nothing helps, because nothing is being fetched. The layout never re-rendered. In the App Router a layout that wraps both routes is preserved across that navigation — React keeps the exact same component instance mounted, state and all. Only the segments below the navigation point render again. The fix was one line — call revalidatePath('/dashboard') in the upgrade action (or router.refresh()) — but you only find that line if you know which file conventions re-render on navigation and which deliberately don’t.
Special files: the contract of a route segment
The App Router is a file-system router where every folder under app/ is a route segment, and a fixed set of file names inside that folder carry meaning. page.tsx makes the segment publicly routable — no page, no URL. layout.tsx wraps everything below it: its own page plus every nested segment, receiving them as children. loading.tsx is sugar for an automatic Suspense boundary around the segment’s page: while the page’s server components are still fetching, Next streams the loading UI immediately. error.tsx is an error boundary for the segment — it must be a Client Component ('use client'), because catching render errors and offering a reset() retry is client-side React machinery. not-found.tsx renders for notFound() calls, and template.tsx is the rarely-needed twin of layout that remounts on every navigation instead of persisting.
app/
├─ layout.tsx ← root layout: <html>, <body>; wraps everything
├─ dashboard/
│ ├─ layout.tsx ← persists across all /dashboard/* navigation
│ ├─ loading.tsx ← Suspense fallback for the segment's page
│ ├─ error.tsx ← 'use client' error boundary with reset()
│ ├─ page.tsx ← /dashboard
│ └─ settings/
│ ├─ page.tsx ← /dashboard/settings
│ └─ billing/
│ └─ page.tsx ← /dashboard/settings/billingThe files nest in a fixed order — conceptually layout → template → error → loading → page — so the error boundary catches errors thrown by the page and its loading state, but not by its own layout. That last detail bites: an error thrown in layout.tsx is only caught by the error boundary of the segment above it. A root layout error needs global-error.tsx, which must even render its own <html> and <body> tags because it replaces the entire root.
Tradeoff: conventions over configuration means zero route-config files to drift out of sync, but it also means the behavior is invisible in your code. Nothing at the call site tells you loading.tsx wraps the page in Suspense — you have to know the contract. When you see two spinners flash in sequence on a page, the first question to ask is whether both a <Suspense> and a loading.tsx are wrapping the same segment.
Layouts persist; only what changed renders again
This is the load-bearing rule of the whole architecture. On a client-side navigation, Next.js performs partial rendering: only the route segments below the shared layout re-render on the server and stream down as a new React Server Component payload. Every layout shared between the old and new URL is preserved — the same component instance stays mounted, so its client-side state (a collapsed sidebar flag, a scroll position inside it, a playing video) survives the navigation untouched, and its server code does not run again.
Three consequences follow, and each one is a production bug waiting for teams that miss it. First, data fetched in a layout does not refresh on navigation — the Hook’s stale plan badge. If layout data must update after a mutation, the mutation has to say so: revalidatePath in a server action, or router.refresh() from the client, which re-fetches the current route’s server components without losing client state. Second, layouts cannot read searchParams — only pages get them, precisely because a persisted layout doesn’t re-render when only the query string changes, so fresh searchParams could never be delivered to it. Third, a layout cannot pass props to its page. They render in separate server passes and arrive as independent pieces of the payload; shared data means fetching in both places (deduplicated by request memoization — one actual fetch per request) rather than threading props.
Failure mode: when you spot stale layout data, the tempting fix is converting the layout to a Client Component with a useEffect fetch on pathname change. It works, and it quietly destroys the architecture: the layout’s subtree loses server rendering benefits, the fetch waterfall moves to the browser, and the sidebar now flashes empty on every cold load. The honest fixes are revalidatePath after the mutation that changed the data, or — if the layout genuinely must remount per navigation — template.tsx, which exists for exactly that.
▸Why this works
Why would a framework make not re-rendering the default? Because the alternative is worse at scale. If every navigation re-rendered the full tree from the root, every page transition would re-run the root layout’s data fetches, re-mount providers, drop any in-layout UI state, and ship the entire page’s RSC payload instead of a fragment. Persisting shared layouts is what makes App Router navigation cheap: the payload for a /dashboard/a to /dashboard/b transition contains only segment b. The price is the mental model shift this lesson is about — “my component didn’t run” stops being a bug and becomes the feature you design around.
A layout in app/dashboard/layout.tsx fetches the user's plan and renders a badge. The user upgrades via a server action on /dashboard/settings/billing, then navigates to /dashboard/projects. The badge still shows the old plan. Why?
Route groups, parallel routes, and intercepting routes
Three conventions extend the tree beyond plain nesting. A route group — a folder named in parentheses, like (marketing) — organizes files without affecting the URL: app/(marketing)/pricing/page.tsx serves /pricing. Its real power is giving different sections different layouts at the same URL depth: (marketing) and (app) can each have their own layout.tsx under the same root. The classic gotcha: two groups must never resolve to the same URL path ((a)/about and (b)/about both want /about), which is a hard build error — and navigating between groups that don’t share a layout causes a full page load, not a soft navigation.
Parallel routes render multiple independent subtrees in one layout: folders named @analytics and @team become slots passed to the layout as props alongside children. Each slot gets its own loading.tsx and error.tsx, so a slow analytics panel streams in on its own schedule and a crashing one shows its own error UI without taking down the page — independent streaming and isolated failure as a file convention. Each slot should provide a default.tsx fallback, or a hard refresh on a URL where the slot has no match will 404 the whole page.
Intercepting routes — the (.)photo naming convention — let one route render inside the current layout when reached by soft navigation, while keeping its own full page for direct loads. This is the modal pattern: clicking a photo in a feed opens /photo/42 as an overlay (intercepted, feed still visible, URL shareable); pasting /photo/42 into a new tab renders the standalone photo page. The interception applies only to client-side navigation — refresh the modal and you get the full page, which is the behavior you want and the thing to test before shipping.
// app/dashboard/layout.tsx — slots arrive as props, each streams independently
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode; // app/dashboard/@analytics
team: React.ReactNode; // app/dashboard/@team
}) {
return (
<section>
{children}
<div className="panels">
{analytics}
{team}
</div>
</section>
);
}Tradeoff: parallel and intercepting routes encode genuinely hard UI (independent panels, URL-addressable modals) in the file system, but the encoding is opaque — @, (.), (..) folders are unreadable to anyone who hasn’t memorized the convention, and debugging a missing default.tsx 404 from the file tree alone is miserable. Use them where the payoff is real (modals that must survive refresh, dashboards with independently failing panels); for a simple two-column page, plain components are clearer.
A feed uses an intercepting route so /photo/42 opens as a modal over the feed. A user opens the modal, copies the URL, and sends it to a colleague who pastes it into a fresh tab. What does the colleague see?
- 01What does each special file in a route segment do, and in what order do they nest?
- 02What exactly happens to layouts on a client-side navigation, and what three gotchas follow?
The App Router maps folders under app/ to route segments and gives a fixed set of file names contractual meaning: page makes a segment routable, layout wraps the page and everything below it, loading is an automatic Suspense boundary streamed while the page’s server components fetch, error is a client-side error boundary with a reset retry, not-found handles notFound(), and template is the remounting variant of layout. They nest as layout → template → error → loading → page, which means an error boundary catches its page’s failures but not its own layout’s — those escalate a level up, and a root layout failure needs global-error.tsx with its own html and body. The architectural keystone is partial rendering: on soft navigation, layouts shared between the old and new URL are preserved as the same mounted instances — client state survives, server code does not re-run — and only the segments below the navigation point render again and stream down. Three consequences follow: layout data does not refresh on navigation (the stale-badge bug; fix with revalidatePath in the mutating action or router.refresh, not by demoting the layout to a client component), layouts never receive searchParams, and a layout cannot pass props to its page — both fetch, and request memoization collapses the duplicate calls within a single request. Route groups in parentheses organize sections and grant different layouts at the same URL depth without affecting paths — but two groups must not claim the same URL, and crossing between groups is a hard navigation. Parallel routes (@slot folders) render independent subtrees with their own loading and error files — independent streaming, isolated failure, and a default.tsx requirement to avoid hard-refresh 404s. Intercepting routes ((.) prefix) render a route as an in-context modal on soft navigation while keeping a standalone page for direct loads — interception never applies to a hard load, which is exactly the shareable-URL behavior you want and the case to test. Now when you see a layout badge showing stale data after a mutation, you know exactly which layer to reach for: revalidatePath in the action, not a cache option on the fetch.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.