Skip to content
Chapter 29Lesson 2

Layouts and route groups

The Next.js App Router conventions that give pages a persistent shell: layout.tsx, nested layouts, template.tsx, and route groups.

Last lesson you saw that the file system under src/app/ is the route table: a folder is a URL segment, and a page.tsx is what renders at that URL. That gets you /dashboard and /dashboard/invoices. But every dashboard page shares the same chrome, a sidebar, a top bar, a notification bell, while /sign-in wants none of it, just a bare centered card.

Once you have more than one page, two questions follow. How do you share that sidebar across the whole dashboard so it stays put as you navigate, instead of rebuilding from scratch on every page? And how do you give /sign-in and /dashboard completely different chrome without leaking an /auth/ or /app/ prefix into their URLs?

Two file-system conventions answer them: layout.tsx for a shared shell, and route groups for sorting routes under different shells with no URL impact. Both extend last lesson’s contract, the file system describes the routes and the framework reads it. Everything here builds toward one structure, a web app split into an (auth) shell and an (app) shell, the shape you’ll reuse for every multi-page surface in the course.

layout.tsx, the shell nested routes render inside

Section titled “layout.tsx, the shell nested routes render inside”

A layout.tsx is the framework’s shared shell. Drop one into a folder, and every page.tsx at or below it renders inside it. Put your sidebar in src/app/dashboard/layout.tsx and it wraps /dashboard, /dashboard/invoices, and /dashboard/settings, every page in that subtree, written once.

The shape is small, but two facts trip people up the first time. Both show up in the root layout, the one file every Next.js app has: src/app/layout.tsx, which wraps your entire application.

import './globals.css';
import type { ReactNode } from 'react';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<a href="#main-content" className="sr-only focus:not-sr-only">
Skip to content
</a>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>
</html>
);
}

Like page.tsx, a layout is a framework file: the default export is sanctioned, and the framework finds it by position in the file tree, not by the function’s name. With no 'use client' directive, it’s a Server Component by default, the full Server/Client story is the next chapter.

import './globals.css';
import type { ReactNode } from 'react';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<a href="#main-content" className="sr-only focus:not-sr-only">
Skip to content
</a>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>
</html>
);
}

A layout takes a single prop, children, and the framework fills it; you never pass it yourself. Whatever route renders inside this layout arrives here as children. The instinct is to hand them in as you would to any other component, but here you receive them.

import './globals.css';
import type { ReactNode } from 'react';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<a href="#main-content" className="sr-only focus:not-sr-only">
Skip to content
</a>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>
</html>
);
}

Only the root layout returns <html> and <body>, because it owns the document shell. Every nested layout you write later returns a fragment or a <div> that lands inside this <body>. Adding a second <html> in a nested layout is a common early mistake.

import './globals.css';
import type { ReactNode } from 'react';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<a href="#main-content" className="sr-only focus:not-sr-only">
Skip to content
</a>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>
</html>
);
}

Anything you place around {children} becomes shared chrome, appearing on every page this layout wraps. Here that’s the skip link the accessibility baseline asks every layout to carry, paired with the <main id="main-content"> it jumps to. The page slots in exactly where {children} is written.

1 / 1

The lang attribute, page title, fonts, and metadata export are configured in a later chapter; today the root layout is just where the document shell lives.

src/app/dashboard/page.tsx
export default function DashboardPage() { return <h1>Dashboard</h1>; }
src/app/dashboard/layout.tsx
export default function DashboardLayout({ children }) { return <section>{children}</section>; }

The page that resolves at /dashboard is the children the framework passes into the layout above it. You wire none of this by hand; matching the folder positions is the whole wiring.

A word on the types. The right type for children is ReactNode , since a page might render an element, a string, a list, or anything else. Both pages and layouts are a Server Component unless a file opts out with a directive.

A layout doesn’t replace the layout above it; it nests inside it. The framework walks from the root down to the page you requested, and at each folder with a layout.tsx, wraps what it has built so far. Root layout, then dashboard layout, then page: each wraps the last, like nesting dolls with the page as the smallest doll in the center.

Scrub through the sequence below to watch a /dashboard request assemble from the inside out.

app/layout.tsx — <html> / <body>
dashboard/layout.tsx — sidebar
page.tsx Dashboard
Step 1 — the page alone. src/app/dashboard/page.tsx renders bare: just the leaf’s own UI, no shell around it yet.
app/layout.tsx — <html> / <body>
dashboard/layout.tsx — sidebar
page.tsx Dashboard
Step 2 — the dashboard layout wraps it. The sidebar chrome from dashboard/layout.tsx appears around the page; the page renders exactly where {children} sits.
app/layout.tsx — <html> / <body>
dashboard/layout.tsx — sidebar
page.tsx Dashboard
Step 3 — the root layout wraps that. app/layout.tsx adds the <html>/<body> document shell and any app-wide chrome around the whole thing. This is the final tree the browser receives.

The nesting in that diagram maps one-to-one to the folder nesting on disk. Three files, three rings:

  • Directorysrc/
    • Directoryapp/
      • layout.tsx the root shell — <html> / <body>
      • Directorydashboard/
        • layout.tsx the dashboard shell — sidebar
        • page.tsx the leaf

The framework composes those three files into this:

<RootLayout>
<DashboardLayout>
<DashboardPage />
</DashboardLayout>
</RootLayout>

RootLayout receives DashboardLayout as its children, and DashboardLayout receives DashboardPage as its children.

Nothing stops you from going deeper; each extra level stacks one more doll. Say the invoices section wants its own tab bar (Overview, Drafts, Paid) above every invoice screen. That’s a third layout, dashboard/invoices/layout.tsx, nested inside the dashboard layout, which is nested inside the root.

  • Directorysrc/
    • Directoryapp/
      • layout.tsx root — owns <html> / <body>
      • Directorydashboard/
        • layout.tsx app shell — sidebar + header
        • Directoryinvoices/
          • layout.tsx invoices shell — section tab bar
          • page.tsx the leaf

Each ring owns one job: the root owns the document shell, the dashboard layout owns the app chrome, the invoices layout owns the section’s tab bar, and the page owns the invoice. Every nested layout returns a fragment or a wrapper; only the root carries <html> and <body>.

What re-renders on navigation: the layout/page boundary

Section titled “What re-renders on navigation: the layout/page boundary”

Layouts stay mounted across navigations within their subtree. Only the page swaps.

Navigate from /dashboard to /dashboard/invoices and the framework does not rebuild the tree. It keeps RootLayout and DashboardLayout mounted and untouched, and swaps only the innermost piece, the page. If you came from React expecting navigation to re-render everything, this is the assumption to drop, and the consequences are large:

  • State in a Client Component inside a layout survives the navigation. A sidebar’s collapsed-or-expanded toggle, its scroll position, a corner podcast player that keeps playing as you move between pages: all of it persists, because the layout never unmounted.
  • State in the page does not survive. The page unmounts and a fresh one mounts in its place, so its local state resets.

That gives you a reflex you’ll use for the rest of the course: persistent UI lives in the layout; transient, per-page UI lives in the page. Anything that should outlive a navigation goes up into a layout; anything that should reset on a route change stays in the page.

The figure below simulates the boundary. The tree is a dashboard layout holding the sidebar’s open/closed state, wrapping a page. Click each trigger and watch which boxes light up.

What re-renders on a dashboard navigation

On the layout.tsx variant, navigating lights up only the page while both layouts hold steady. That single flash is why the sidebar’s scroll survives, why the corner player keeps playing, and why layouts exist at all. Flip to the React-only variant, where each page imports the shell, and the same navigation remounts that shell: root, dashboard, and page all flash, and the sidebar state is gone. That is the cost of building the shell per page.

The second trigger shows the other half. Toggling the sidebar re-renders DashboardLayout, which owns that state, and the page beneath it, but a later navigation still won’t touch that state. The layout keeps its state between page changes; the page keeps nothing.

The same property shapes where you fetch data. A layout doesn’t re-run on a page-only navigation, so a layout that fetches data fetches once and holds the result: ideal for data the whole subtree shares, wrong for data one deep page needs. Fetch a single page’s data up in a high layout and you block the entire subtree until it resolves, while navigating to a sibling re-fetches nothing. The rule, stated positively: fetch at the level that owns the data. Page-specific data is fetched in the page. A later chapter covers how a layout streams while that data loads.

Test the boundary on yourself. Sort each piece of state by what happens when you navigate from /dashboard to /dashboard/settings.

Sort each item by what happens when the user navigates from /dashboard to /dashboard/settings. Drag each item into the bucket it belongs to, then press Check.

Persists Lives in a layout — survives the navigation
Resets Lives in the page — unmounts and starts fresh
Whether the sidebar is collapsed or expanded (state in DashboardLayout)
The scroll position of the sidebar’s nav list (in DashboardLayout)
The <html lang="en"> attribute (in the root layout)
A corner audio player’s playback position (in the root layout)
Unsaved text typed into a form on the /dashboard page
Which row is selected in the /dashboard page’s table

template.tsx, a layout that remounts on every navigation

Section titled “template.tsx, a layout that remounts on every navigation”

The persisting layout is the default, and the default is what you want almost always. When you need the opposite, Next.js gives you template.tsx.

A template.tsx has the same shape as a layout.tsx: a default export, a children prop, a Server Component by default. The difference is behavior. The framework hands a template a fresh key on every navigation into its segment, so it remounts instead of persisting.

src/app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: ReactNode }) {
return <section className="p-6">{children}</section>;
}

Persists, the default. Mounts once and stays mounted across navigations in this subtree; Client Component state inside it survives. Reach for this unless you have a specific reason not to.

The cases where remounting is the call are few. Reach for template.tsx only on one of these triggers:

  • A child’s state should reset on every navigation. A “new record” form must come up blank each time you open a different record, not carrying the last record’s half-typed values.
  • A useEffect must re-synchronize on every navigation, such as a per-page analytics ping that should fire on each view, or an enter animation that should replay every time you arrive.
  • You want a Suspense fallback to reappear on each navigation. A boundary in a layout shows its fallback only on first load, because the layout persists; the same boundary in a template shows it on every navigation. Streaming details come in a later chapter, but the trigger is worth knowing now.

A few facts keep the model exact. A template renders between the layout and the page, as <Layout><Template key={...}>{children}</Template></Layout>, so within one segment it wraps the page, not the layout. The key is per-segment, so navigating inside a deeper segment won’t remount a template higher up, and a search-param change doesn’t remount it either. The two files can coexist in one folder, persisting the shell while remounting the part inside the template.

Route groups: organize siblings without a URL segment

Section titled “Route groups: organize siblings without a URL segment”

Now the second question from the start. The dashboard has its shell, but /sign-in wants a different one: a bare centered card, no sidebar. Grouping the auth pages in a folder shouldn’t push an /auth/ segment into the URL. You need a folder that organizes routes but adds nothing to the path.

That’s a route group. A folder whose name is wrapped in parentheses is invisible to the URL. Name a folder (app) and its segment vanishes from every path inside: src/app/(app)/dashboard/page.tsx serves /dashboard, not /app/dashboard. The parentheses tell the router this folder is for organization and should be skipped when building the URL.

This resembles the private _folder from last lesson, and they’re easy to confuse because both “disappear”, but they disappear in opposite ways. A _folder hides from routing entirely: nothing inside is routable, it produces no URL, and that’s where colocated components and helpers live. A (folder) is fully routable; its pages produce URLs, it just contributes zero segments to them. One is “not a route”; the other is “a route, minus its own segment.”

Route groups earn their place three ways, in order of how often they matter:

  1. Give sibling subtrees different shells, with no URL prefix. The (auth)/(app) split driving this lesson: each group gets its own layout.tsx, and neither name shows up in a URL.
  2. Opt a cluster of routes into a shared layout while leaving others out. Wrap the routes that should share a shell in a group with a layout; leave the rest outside it.
  3. Organize routes by domain or team, purely for the humans reading the repo, with no effect on the URL.

Here is the structure the lesson has been building toward: one app, two shells, no URL prefix on either.

  • Directorysrc/
    • Directoryapp/
      • layout.tsx the one root — <html> / <body>, app-wide providers
      • Directory(auth)/ route group — no URL segment
        • layout.tsx centered card, no chrome
        • Directorysign-in/
          • page.tsx /sign-in
        • Directorysign-up/
          • page.tsx /sign-up
      • Directory(app)/ route group — no URL segment
        • layout.tsx sidebar + header
        • Directory_components/
          • sidebar.tsx co-located with its layout (Principle #1)
        • Directorydashboard/
          • page.tsx /dashboard

The rule holds on every row: (auth) and (app) contribute nothing, so (app)/dashboard/page.tsx resolves to plain /dashboard and (auth)/sign-in/page.tsx to plain /sign-in. Two groups, two entirely different layouts, neither name in the address bar.

Notice sidebar.tsx inside (app)/_components/. That’s Architectural Principle #1, co-locate by feature, applied to a layout: the sidebar is used only by the (app) shell, so it lives beside that shell in a private folder rather than a top-level components/. It graduates to a shared components/ only the day a second group needs it.

Fill in the URLs below. Each (group) contributes nothing; each real segment contributes its name.

Fill in the URL each file resolves to. Each (group) contributes nothing; each real folder contributes its name. Pick the right option from each dropdown, then press Check.

src/app/(app)/dashboard/page.tsx → ___
src/app/(auth)/sign-up/page.tsx → ___
src/app/(marketing)/pricing/page.tsx → ___

One root layout, with nested per-group layouts

Section titled “One root layout, with nested per-group layouts”

The structure above made one decision worth stating: keep a single root. One src/app/layout.tsx owns <html>, <body>, and app-wide providers, and each group adds its own layout.tsx for its distinct chrome. Those group layouts are ordinary nested layouts one level below the root; they return a fragment or a wrapper, never <html>/<body>. One document shell at the top, many shells branching beneath it.

You can instead give each group its own <html>/<body> by deleting the top-level layout.tsx and making each group a root, the split you’d want between a marketing site and an app that share no chrome. You’ll rarely do this in a web app: navigating between two roots triggers a full page reload, since the browser document is replaced, and with no top-level layout the home route / must live inside one of the groups.

You’ve met four moves: a single layout.tsx, a nested layout.tsx, a template.tsx, and a route group. The default is the first one plus a route group; the rest are conditionals you reach for on a named trigger. Start from what you’re trying to do and let the walk narrow to a file.

Which routing file do you reach for?

Walk it against your own app whenever you’re unsure which file to add.

The Next.js documentation covers these conventions with the framework’s own framing and a few edge cases this lesson set aside.