Week 11

App Router

Data Fetching and Mutations

Migrating from React Router

Going to production

Rendering Strategies

Practice

Assignment

Front end Track

What is the App Router?

Next.js is a React framework that adds routing, server-side rendering, and deployment infrastructure on top of React. Since Next.js 13, it ships with the App Router - a routing system built on React Server Components.

The key idea is simple: the folder structure inside app/ is your route map. You don't configure routes in a file. You create folders.

<aside> 💡

Next.js used to have a pages/ directory (the "Pages Router"). You may still see it in older projects. The Pages Router still works, but the App Router is the current default and what this module focuses on.

</aside>

Setting Up a Project

Run this in your terminal to create a new Next.js project with default settings:

npx create-next-app@latest my-app --yes

Then start the dev server:

cd my-app
npm run dev

Open http://localhost:3000 - you'll see the default Next.js welcome page.

<aside> ⌨️

Hands on: Open src/app/layout.tsx and src/app/page.tsx in your editor. Note the metadata export in layout.tsx and try to make changes to the title. Open the page.tsx and try making changes to the component.

</aside>

Server vs Client Components

Server Components (the default) run on the server. They:

Client Components opt in with "use client" at the top of the file. They:

// Server Component - no directive needed, this is the default
export default async function ProductsPage() {
  const products = await fetch('<https://api.example.com/products>').then(r => r.json())

  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}
// Client Component - must opt in with "use client"
"use client"

import { useState } from 'react'

export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false)

  return (
    <button onClick={() => setAdded(true)}>
      {added ? 'Added!' : 'Add to cart'}
    </button>
  )
}

A Server Component can import and render a Client Component. But a Client Component cannot import a Server Component - it runs in the browser where the server doesn't exist. You can pass Server Components to Client Components as children props, which is a common pattern for keeping interactivity at the leaves of the component tree.

<aside> ⌨️

Hands on: Create a file at src/app/test/page.tsx that exports a default async component. Add console.log('I run on the server') at the top of the component body, then return some JSX. Visit http://localhost:3000/test. Where does the log appear - in the browser console or the terminal? Now add "use client" at the top of the file and reload. Where does the log appear now?

</aside>

File-based Routing

In Next.js App Router, folders define routes. A folder becomes a URL segment. To make a folder an accessible route, put a page.tsx file inside it.

app/
├── page.tsx              → /
├── about/
│   └── page.tsx          → /about
├── blog/
│   └── page.tsx          → /blog
└── dashboard/
    ├── page.tsx          → /dashboard
    └── settings/
        └── page.tsx      → /dashboard/settings (nested route)

Note that a folder without a page.tsx doesn't create a route - you can use folders to organise files without them becoming URLs.

<aside> ⌨️

Hands on: Add an /about route to your project. Create src/app/about/page.tsx with a heading and a short paragraph. Navigate to http://localhost:3000/about - it should work immediately without restarting the dev server. Then try navigating to a path that doesn't exist, like /xyz. What does Next.js show?

</aside>

Dynamic Routes

Use square brackets to create a dynamic segment that matches any value:

app/
└── products/
    ├── page.tsx          → /products
    └── [id]/
        └── page.tsx      → /products/1, /products/abc, etc.

Access the dynamic value through params:

// app/products/[id]/page.tsx
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await fetch(`https://api.example.com/products/${id}`).then(r => r.json())

  return <h1>{product.name}</h1>
}

For multiple dynamic segments, use [...slug] (catch-all):

app/docs/[...slug]/page.tsx  → /docs/hyf, /docs/hyf/frontend

You can also make the catch all segments optional with [[...slug]] meaning it will match routes without parameters:

app/docs/[[...slug]]/page.tsx  → /docs, /docs/intro, /docs/hyf/frontend

Layouts

A layout.tsx file wraps the page.tsx in the same folder and all routes nested below it. Crucially, layouts stay mounted during navigation - they don't re-render when the user moves between child routes.

// app/layout.tsx - root layout, wraps every page
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <nav>
          <a href="/">Home</a>
          <a href="/products">Products</a>
        </nav>
        <main>{children}</main>
      </body>
    </html>
  )
}

You can have nested layouts - a layout in a subfolder wraps only the routes in that subfolder:

app/
├── layout.tsx            ← wraps every route
├── page.tsx
└── dashboard/
    ├── layout.tsx        ← wraps /dashboard and /dashboard/settings
    ├── page.tsx
    └── settings/
        └── page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard">
      <aside>
        <a href="/dashboard">Overview</a>
        <a href="/dashboard/settings">Settings</a>
      </aside>
      <section>{children}</section>
    </div>
  )
}

The root app/layout.tsx is required and must include the <html> and <body> tags. Next.js won't add these automatically.

<aside> ⌨️

Hands on: Create a src/app/blog/layout.tsx that renders a sidebar with fake links ("Latest Posts", "Categories"), and a src/app/blog/page.tsx with some content. Navigate to /blog - the sidebar should appear. Then add a src/app/blog/post/page.tsx and navigate to /blog/post - the sidebar should still be there, because it comes from the layout, not the page.

</aside>

Route Groups

Use parentheses in a folder name to create a route group - a folder that organises routes without affecting the URL:

app/
└── (marketing)/
    ├── about/
    │   └── page.tsx      → /about  (not /marketing/about)
    └── blog/
        └── page.tsx      → /blog

Route groups are useful for sharing a layout between some routes but not others:

app/
├── (auth)/
│   ├── layout.tsx        ← only wraps login and signup
│   ├── login/
│   │   └── page.tsx      → /login
│   └── signup/
│       └── page.tsx      → /signup
└── (app)/
    ├── layout.tsx        ← only wraps authenticated pages
    ├── dashboard/
    │   └── page.tsx      → /dashboard
    └── profile/
        └── page.tsx      → /profile

Special Files

Next.js treats a small set of filenames inside app/ specially. Everything else is just a regular module.

Filename Purpose
page.tsx The UI for a route - makes the folder a publicly accessible URL
layout.tsx Wraps page.tsx and persists across navigations
loading.tsx Shown while the page is loading (automatic Suspense fallback)
error.tsx Shown when the page throws an error (error boundary)
not-found.tsx Shown when notFound() is called
route.ts API endpoint (no UI)

loading.tsx

Automatically wraps the page.tsx in a <Suspense> boundary. While the page is loading (awaiting data, streaming), this UI is shown instead.

// app/products/loading.tsx
export default function Loading() {
  return <div>Loading products...</div>
}

error.tsx

A Client Component (must have "use client") that catches errors thrown anywhere inside the route segment.

// app/products/error.tsx
"use client"

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

<aside> ⚠️

error.tsx must be a Client Component. The reset function re-renders the segment - useful for transient errors like a network timeout.

</aside>

not-found.tsx

Shown when you call notFound() from next/navigation inside a route. Useful for 404 handling on dynamic routes:

// app/products/[id]/page.tsx
import { notFound } from 'next/navigation'

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await fetch(`.../${id}`).then(r => r.json())

  if (!product) notFound()

  return <h1>{product.name}</h1>
}

<aside> ⌨️

</aside>

Navigation

Use <Link> from next/link for client-side navigation between routes: