🏗️ Chapter 3: Layouts & Pages
The two files that build every screen in your app, how nesting makes navigation feel instant, and the one thing layouts cannot do.
📖 Plain English Explanation
Think of a website as a set of picture frames stacked inside each other.
The outermost frame is your site — the <html> tag, your fonts, your analytics script. It never changes, no matter where the user goes.
Inside that might be a dashboard frame — a sidebar and a header. It stays put while the user clicks between "Overview", "Billing", and "Settings".
Inside that is the actual picture: the content of the current page. It swaps out every time the user navigates.
In Next.js:
layout.tsxis a frame. It wraps its children and stays mounted across navigation.page.tsxis the picture. It's the unique content of one URL, and it's replaced on every navigation.
That "stays mounted" part is not a small detail. It means a sidebar's scroll position survives navigation. A playing video doesn't restart. An open dropdown doesn't close. This is what makes an App Router app feel like a native app rather than a website.
📄 Pages
A page file exports a React component as its default export. That component is the route.
// app/page.tsx → /
export default function Page() {
return <h1>Home</h1>
}
// app/page.js → /
export default function Page() {
return <h1>Home</h1>
}
Add a folder, get a URL:
// app/about/page.tsx → /about
export default function Page() {
return <h1>About us</h1>
}
// app/about/page.js → /about
export default function Page() {
return <h1>About us</h1>
}
Pages are Server Components by default, which means they can be async and fetch data directly:
// app/blog/page.tsx
import { db } from '@/lib/db'
export default async function Page() {
const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } })
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</main>
)
}
// app/blog/page.js
import { db } from '@/lib/db'
export default async function Page() {
const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } })
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</main>
)
}
No getServerSideProps, no useEffect, no loading state. The await happens on the server and the browser receives finished HTML.
What a page receives
Pages get two props, and in Next.js 16 both are Promises:
// app/shop/[category]/page.tsx
export default async function Page(props: PageProps<'/shop/[category]'>) {
const { category } = await props.params // from the URL path
const { sort } = await props.searchParams // from the query string
return <h1>{category} sorted by {sort ?? 'newest'}</h1>
}
// app/shop/[category]/page.js
export default async function Page(props) {
const { category } = await props.params
const { sort } = await props.searchParams
return <h1>{category} sorted by {sort ?? 'newest'}</h1>
}
⚠️ Changed in Next.js 16
paramsandsearchParamswere synchronous objects through Next.js 14, became Promises with a compatibility shim in 15, and are now Promise-only. Synchronous access throws.tsx// ❌ Next.js 14 — no longer compiles export default function Page({ params }: { params: { slug: string } }) { return <h1>{params.slug}</h1> } // ✅ Next.js 16 export default async function Page(props: PageProps<'/blog/[slug]'>) { const { slug } = await props.params return <h1>{slug}</h1> }Codemod:
npx @next/codemod@canary next-async-request-api .
Chapter 5 covers params and searchParams in depth.
🖼️ Layouts
A layout wraps its segment and everything below it. It must accept and render a children prop.
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex">
<aside className="w-64 border-r">
<nav>{/* sidebar links */}</nav>
</aside>
<main className="flex-1 p-6">{children}</main>
</div>
)
}
// app/dashboard/layout.js
export default function DashboardLayout({ children }) {
return (
<div className="flex">
<aside className="w-64 border-r">
<nav>{/* sidebar links */}</nav>
</aside>
<main className="flex-1 p-6">{children}</main>
</div>
)
}
With next typegen, use the generated helper instead of hand-writing the prop type:
// app/dashboard/layout.tsx
export default function DashboardLayout(props: LayoutProps<'/dashboard'>) {
return (
<div className="flex">
<aside className="w-64 border-r">{/* sidebar */}</aside>
<main className="flex-1 p-6">{props.children}</main>
</div>
)
}
Layouts can be async and fetch data too:
// app/dashboard/layout.tsx
import { getCurrentUser } from '@/lib/auth'
export default async function DashboardLayout(props: LayoutProps<'/dashboard'>) {
const user = await getCurrentUser()
return (
<div className="flex">
<aside className="w-64 border-r">
<p>Signed in as {user.name}</p>
</aside>
<main className="flex-1 p-6">{props.children}</main>
</div>
)
}
// app/dashboard/layout.js
import { getCurrentUser } from '@/lib/auth'
export default async function DashboardLayout({ children }) {
const user = await getCurrentUser()
return (
<div className="flex">
<aside className="w-64 border-r">
<p>Signed in as {user.name}</p>
</aside>
<main className="flex-1 p-6">{children}</main>
</div>
)
}
🌳 The root layout
Every app has exactly one required file: app/layout.tsx. It is the only place <html> and <body> exist.
// app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: {
default: 'Acme',
template: '%s | Acme',
},
description: 'The best widgets on the internet',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
// app/layout.js
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: {
default: 'Acme',
template: '%s | Acme',
},
description: 'The best widgets on the internet',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
Rules for the root layout:
- It is required. Next.js will not create it for you.
- It must render
<html>and<body>. - It is a Server Component and cannot be converted to a Client Component. (Put
'use client'in a component inside it instead.) - Do not add
<head>manually — use themetadataexport.
⚠️ Changed in Next.js 16
Next.js used to force
scroll-behavior: autoduring navigations even if you'd setsmoothglobally, so page transitions jumped instantly. It no longer does. To get the old behavior back, opt in explicitly:tsx<html lang="en" data-scroll-behavior="smooth">
🪆 Nesting
Layouts nest automatically based on folder depth. Nothing to wire up.
app/
├── layout.tsx ← RootLayout
├── page.tsx
└── dashboard/
├── layout.tsx ← DashboardLayout
├── page.tsx
└── settings/
├── layout.tsx ← SettingsLayout
└── page.tsx
Visiting /dashboard/settings produces:
<RootLayout>
<DashboardLayout>
<SettingsLayout>
<SettingsPage />
</SettingsLayout>
</DashboardLayout>
</RootLayout>
Why this matters: partial rendering
When you navigate from /dashboard to /dashboard/settings:
RootLayout ── unchanged, not re-rendered
DashboardLayout ── unchanged, not re-rendered ← sidebar state survives
SettingsLayout ── mounts
SettingsPage ── mounts
Next.js only fetches and renders the segments that actually changed. The sidebar isn't re-fetched, its scroll position is preserved, and any useState inside it keeps its value.
Compare this to a traditional server-rendered app, where every navigation rebuilds the entire page from scratch.
A concrete example
// app/dashboard/layout.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false)
return (
<div className="flex">
<aside className={collapsed ? 'w-16' : 'w-64'}>
<button onClick={() => setCollapsed(!collapsed)}>Toggle</button>
<Link href="/dashboard">Overview</Link>
<Link href="/dashboard/billing">Billing</Link>
<Link href="/dashboard/settings">Settings</Link>
</aside>
<main className="flex-1">{children}</main>
</div>
)
}
// app/dashboard/layout.js
'use client'
import { useState } from 'react'
import Link from 'next/link'
export default function DashboardLayout({ children }) {
const [collapsed, setCollapsed] = useState(false)
return (
<div className="flex">
<aside className={collapsed ? 'w-16' : 'w-64'}>
<button onClick={() => setCollapsed(!collapsed)}>Toggle</button>
<Link href="/dashboard">Overview</Link>
<Link href="/dashboard/billing">Billing</Link>
<Link href="/dashboard/settings">Settings</Link>
</aside>
<main className="flex-1">{children}</main>
</div>
)
}
Collapse the sidebar, then click through all three links. It stays collapsed. That's partial rendering.
🔄 template.tsx — the layout that forgets
A template does the same job as a layout, with one difference: it creates a new instance on every navigation. State resets, effects re-run, the DOM is rebuilt.
// app/dashboard/template.tsx
export default function Template({ children }: { children: React.ReactNode }) {
return <div className="fade-in">{children}</div>
}
// app/dashboard/template.js
export default function Template({ children }) {
return <div className="fade-in">{children}</div>
}
layout | template | |
|---|---|---|
| Persists across navigation | ✅ | ❌ |
| State preserved | ✅ | ❌ |
| Effects re-run on nav | ❌ | ✅ |
| Use for | shells, sidebars, headers | enter animations, per-route resets |
If both exist in a segment, the template renders inside the layout:
Layout
└── Template ← remounts each nav
└── Page
Reach for template when you specifically need the reset — a page-enter animation, a per-page analytics useEffect, or a form that must clear when the route changes. Otherwise use layout.
🏷️ Metadata from layouts and pages
Both layouts and pages can export metadata. Values merge down the tree, with deeper segments overriding shallower ones.
// app/layout.tsx
export const metadata = {
title: { default: 'Acme', template: '%s | Acme' },
description: 'Default description',
}
// app/blog/page.tsx
export const metadata = {
title: 'Blog', // renders as "Blog | Acme" via the template
}
For dynamic titles, export generateMetadata instead:
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/posts'
export async function generateMetadata(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
}
}
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const post = await getPost(slug)
return <article>{post.body}</article>
}
// app/blog/[slug]/page.js
import { getPost } from '@/lib/posts'
export async function generateMetadata(props) {
const { slug } = await props.params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
}
}
export default async function Page(props) {
const { slug } = await props.params
const post = await getPost(slug)
return <article>{post.body}</article>
}
Calling getPost(slug) twice looks wasteful, but Next.js memoizes fetch calls within a single request, so identical requests hit the network once. For non-fetch data sources like a database client, wrap the function in React's cache() to get the same behavior. Chapter 11 covers this.
Full metadata coverage is in Chapter 19.
🚫 The one thing layouts cannot do
A layout cannot pass data to its page.
// ❌ This does not work — there is no such API
export default async function Layout({ children }) {
const user = await getUser()
return <div>{children /* how do I give `user` to the page? */}</div>
}
children is an already-rendered React element. You cannot inject props into it.
This is deliberate: it keeps each segment independently renderable, which is what makes partial rendering and streaming possible.
What to do instead
Fetch the data in both places. Deduplication makes this cheap:
// lib/user.ts
import { cache } from 'react'
import { db } from '@/lib/db'
export const getCurrentUser = cache(async () => {
const session = await getSession()
return db.user.findUnique({ where: { id: session.userId } })
})
// app/dashboard/layout.tsx
import { getCurrentUser } from '@/lib/user'
export default async function Layout({ children }: { children: React.ReactNode }) {
const user = await getCurrentUser() // hits the DB
return (
<div>
<header>{user.name}</header>
{children}
</div>
)
}
// app/dashboard/page.tsx
import { getCurrentUser } from '@/lib/user'
export default async function Page() {
const user = await getCurrentUser() // same request → cached, no second DB call
return <h1>Welcome back, {user.name}</h1>
}
Both call getCurrentUser(), but cache() ensures exactly one database query per request.
Alternatively, use React Context for client-side state that genuinely needs to be shared:
// app/dashboard/UserProvider.tsx
'use client'
import { createContext, useContext } from 'react'
const UserContext = createContext<User | null>(null)
export function UserProvider({ user, children }: { user: User; children: React.ReactNode }) {
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}
export const useUser = () => useContext(UserContext)
// app/dashboard/layout.tsx
import { getCurrentUser } from '@/lib/user'
import { UserProvider } from './UserProvider'
export default async function Layout({ children }: { children: React.ReactNode }) {
const user = await getCurrentUser()
return <UserProvider user={user}>{children}</UserProvider>
}
Now any Client Component below can call useUser().
🧭 What a layout does receive
Layouts get children and, for dynamic segments, params:
// app/shop/[category]/layout.tsx
export default async function Layout(props: LayoutProps<'/shop/[category]'>) {
const { category } = await props.params
return (
<div>
<h2>Browsing: {category}</h2>
{props.children}
</div>
)
}
// app/shop/[category]/layout.js
export default async function Layout({ children, params }) {
const { category } = await params
return (
<div>
<h2>Browsing: {category}</h2>
{children}
</div>
)
}
Layouts do not receive searchParams. Query strings change without re-rendering layouts (that's the whole point of layout persistence), so exposing them would be a lie. If a layout needs the query string, read it in a Client Component with useSearchParams().
⚠️ Common Pitfalls
1. Forgetting to render children
// ❌ the page never appears
export default function Layout({ children }) {
return <div className="wrapper" />
}
// ✅
export default function Layout({ children }) {
return <div className="wrapper">{children}</div>
}
Silent and maddening — you get a blank page with no error.
2. Adding 'use client' to the root layout
// ❌ app/layout.tsx
'use client'
export default function RootLayout({ children }) { ... }
This forces your entire app into the client bundle and breaks metadata exports. Fix: keep the root layout a Server Component and extract the interactive part:
// app/layout.tsx (Server Component)
import { ThemeProvider } from './ThemeProvider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
// app/ThemeProvider.tsx (Client Component)
'use client'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
// useState / useContext live here
return <>{children}</>
}
3. Expecting a layout to re-run on navigation
// app/dashboard/layout.tsx
export default async function Layout({ children }) {
const notifications = await getNotifications() // fetched once, then stale
return <div><Bell count={notifications.length} />{children}</div>
}
Navigating between dashboard pages will not re-run this. Fix: either fetch in the page instead, call refresh() from a Server Action after a mutation (Chapter 15), or use a template if you truly need a remount.
4. Trying to pass props through children
// ❌ not a thing
{React.cloneElement(children, { user })}
children from the router is not a plain element you can clone reliably. Fix: shared cache()d fetcher, or Context.
5. Missing <html> / <body> after a refactor
Moving the root layout into a route group is a common cause:
app/
├── (marketing)/layout.tsx ← has <html>/<body>
└── (app)/layout.tsx ← has <html>/<body>
That's valid — multiple root layouts — but only if app/layout.tsx does not exist. If both a root layout and group root layouts have <html>, you get nested <html> tags. Chapter 6 covers this.
6. Reaching for template by default
template throws away exactly the benefit you came to the App Router for. Use layout unless you have a specific reason to remount.
🎯 When & Why to Use
Use layout when:
✅ UI is shared across sibling routes (nav, sidebar, footer)
✅ You want state/scroll preserved across navigation
✅ You need data fetched once for a whole section
Use template when:
✅ You want an enter animation on every navigation
✅ A useEffect must re-fire per route (page-view analytics)
✅ A form or wizard must reset between routes
Use page when:
✅ It's the unique content of one URL
🏋️ Mini Practice Problems
Problem 1: Predict the render tree
app/
├── layout.tsx
├── (shop)/
│ ├── layout.tsx
│ └── products/
│ ├── layout.tsx
│ ├── template.tsx
│ └── [id]/page.tsx
Write out the component tree rendered for /products/42, in order from outermost to innermost.
Problem 2: State survival
Given the dashboard layout with the collapse toggle from earlier in this chapter, decide whether the sidebar stays collapsed for each navigation:
- A.
/dashboard→/dashboard/billing - B.
/dashboard/billing→/settings(outside the dashboard folder) - C. Browser refresh on
/dashboard/billing - D.
/dashboard→/dashboard/billing, if the file were namedtemplate.tsx
Problem 3: Fix the data flow
A junior developer writes this and asks why user is undefined in the page:
// app/account/layout.tsx
export default async function Layout({ children }) {
const user = await getUser()
return <div>{children}</div>
}
// app/account/page.tsx
export default function Page({ user }) {
return <h1>{user.name}</h1>
}
Rewrite both files correctly. Give two valid approaches.
Problem 4: Metadata merge
Given these three files, what <title> does /blog/hello render?
// app/layout.tsx
export const metadata = { title: { default: 'Acme', template: '%s | Acme' } }
// app/blog/layout.tsx
export const metadata = { title: 'Blog' }
// app/blog/[slug]/page.tsx
export async function generateMetadata(props) {
const { slug } = await props.params
return { title: slug }
}
💼 Interview Notes
Common Questions
Q: What is the difference between a layout and a page? A page is the unique UI for one route and is replaced on navigation. A layout wraps a segment and its children, persists across navigation between siblings, and preserves its state and DOM.
Q: What is partial rendering? When navigating, Next.js only re-fetches and re-renders the route segments that changed. Shared parent layouts stay mounted. This reduces payload size and preserves client state.
Q: When would you use template instead of layout?
When you need the wrapper to remount on every navigation — enter/exit animations, per-route useEffect side effects like analytics page views, or resetting form state between routes.
Q: Why can't a layout pass data to its page?
children is an already-constructed element, and each segment must be independently renderable for streaming and partial rendering to work. Share data through a cache()d fetcher (deduplicated per request) or React Context for client state.
Q: Why is the root layout required and why must it be a Server Component?
It owns <html> and <body>, which nothing else can render. It must stay on the server so the metadata API can inject <head> tags during server rendering, and so the whole app isn't pulled into the client bundle.
Q: Do layouts receive searchParams?
No — only params. Layouts don't re-render when the query string changes, so giving them searchParams would hand you stale data. Read the query string in a Client Component with useSearchParams().
🏢 Asked at Companies
- Vercel: "A user reports the sidebar scroll jumps to the top on every navigation. Where do you look first?"
- Linear: "How would you implement a page transition animation in the App Router?"
- Notion: "Explain how you'd share the current user between a layout and every page under it without prop drilling or a second database query."
- Figma: "Why does the App Router render layouts on the server by default, and what breaks if you make one a Client Component?"
📊 Visual Memory Aid
NAVIGATION: /dashboard → /dashboard/settings
┌──────────────────────────────────────┐
│ RootLayout ⏸ stays │
│ ┌──────────────────────────────────┐ │
│ │ DashboardLayout ⏸ stays │ │ ← sidebar state preserved
│ │ ┌──────────────────────────────┐ │ │
│ │ │ SettingsLayout 🔄 mounts │ │ │
│ │ │ ┌──────────────────────────┐ │ │ │
│ │ │ │ SettingsPage 🔄 mounts │ │ │ │ ← only this is fetched
│ │ │ └──────────────────────────┘ │ │ │
│ │ └──────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────┘
LAYOUT vs TEMPLATE
layout.tsx template.tsx
────────── ────────────
nav 1 → mount nav 1 → mount
nav 2 → (kept) nav 2 → unmount + mount
nav 3 → (kept) nav 3 → unmount + mount
state: preserved state: reset every time
DATA FLOW BETWEEN SEGMENTS
layout ──✗──► page (no prop passing)
layout ──✓──► page via cache()d fetcher (same request, one query)
layout ──✓──► page via Context (client state)
🎯 Key Takeaways
pageis the content,layoutis the frame. A folder needs apageto be a route; a layout alone renders nothing.- Layouts persist across navigation between siblings — state, scroll, and DOM survive. This partial rendering is the App Router's core UX win.
- The root layout is required, owns
<html>/<body>, and must stay a Server Component. Push interactivity into a child. templateislayoutthat remounts. Use it only when you need the reset, because it discards the benefit of persistence.- Layouts cannot pass data to pages. Fetch in both places behind a
cache()d function, or use Context — deduplication makes the "duplicate" call free.
Next Chapter: Linking & Navigation →
Practice: Build a /dashboard section with a layout containing a sidebar and three child pages. Add a useState counter to the sidebar, click it a few times, then navigate between all three pages and confirm the count survives. Then rename layout.tsx to template.tsx and watch it reset.