Dev Logs
/Next.js/ Chapter 3: Layouts & Pages
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
    • Plain English Explanation
    • Pages
    • What a page receives
    • Layouts
    • The root layout
    • Nesting
    • Why this matters: partial rendering
    • A concrete example
    • template.tsx — the layout that forgets
    • Metadata from layouts and pages
    • The one thing layouts cannot do
    • What to do instead
    • What a layout does receive
    • Common Pitfalls
    • . Forgetting to render children
    • . Adding 'use client' to the root layout
    • . Expecting a layout to re-run on navigation
    • . Trying to pass props through children
    • . Missing <html> / <body> after a refactor
    • . Reaching for template by default
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Predict the render tree
    • Problem 2: State survival
    • Problem 3: Fix the data flow
    • Problem 4: Metadata merge
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🏗️ 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.tsx is a frame. It wraps its children and stays mounted across navigation.
  • page.tsx is 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.

tsx
// app/page.tsx  →  /
export default function Page() {
  return <h1>Home</h1>
}
jsx
// app/page.js  →  /
export default function Page() {
  return <h1>Home</h1>
}

Add a folder, get a URL:

tsx
// app/about/page.tsx  →  /about
export default function Page() {
  return <h1>About us</h1>
}
jsx
// 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:

tsx
// 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>
  )
}
jsx
// 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:

tsx
// 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>
}
jsx
// 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

params and searchParams were 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.

tsx
// 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>
  )
}
jsx
// 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:

tsx
// 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:

tsx
// 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>
  )
}
jsx
// 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.

tsx
// 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>
  )
}
jsx
// 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:

  1. It is required. Next.js will not create it for you.
  2. It must render <html> and <body>.
  3. It is a Server Component and cannot be converted to a Client Component. (Put 'use client' in a component inside it instead.)
  4. Do not add <head> manually — use the metadata export.

⚠️ Changed in Next.js 16

Next.js used to force scroll-behavior: auto during navigations even if you'd set smooth globally, 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:

tsx
<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

tsx
// 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>
  )
}
jsx
// 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.

tsx
// app/dashboard/template.tsx
export default function Template({ children }: { children: React.ReactNode }) {
  return <div className="fade-in">{children}</div>
}
jsx
// app/dashboard/template.js
export default function Template({ children }) {
  return <div className="fade-in">{children}</div>
}
layouttemplate
Persists across navigation✅❌
State preserved✅❌
Effects re-run on nav❌✅
Use forshells, sidebars, headersenter 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.

tsx
// app/layout.tsx
export const metadata = {
  title: { default: 'Acme', template: '%s | Acme' },
  description: 'Default description',
}
tsx
// app/blog/page.tsx
export const metadata = {
  title: 'Blog',        // renders as "Blog | Acme" via the template
}

For dynamic titles, export generateMetadata instead:

tsx
// 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>
}
jsx
// 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.

tsx
// ❌ 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:

ts
// 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 } })
})
tsx
// 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>
  )
}
tsx
// 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:

tsx
// 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)
tsx
// 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:

tsx
// 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>
  )
}
jsx
// 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

tsx
// ❌ 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

tsx
// ❌ 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:

tsx
// 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>
  )
}
tsx
// 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

tsx
// 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

tsx
// ❌ 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 named template.tsx

Problem 3: Fix the data flow

A junior developer writes this and asks why user is undefined in the page:

tsx
// 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?

tsx
// 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

  1. page is the content, layout is the frame. A folder needs a page to be a route; a layout alone renders nothing.
  2. Layouts persist across navigation between siblings — state, scroll, and DOM survive. This partial rendering is the App Router's core UX win.
  3. The root layout is required, owns <html>/<body>, and must stay a Server Component. Push interactivity into a child.
  4. template is layout that remounts. Use it only when you need the reset, because it discards the benefit of persistence.
  5. 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.


PreviousChapter 2: Project Structure & ConfigurationNextChapter 4: Linking & Navigation

Open source, free forever. Built by iammhador.

Contribute on GitHub