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>
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 Components (the default) run on the server. They:
await data directly in the component bodyuseState, useEffect, or any browser-only APIClient Components opt in with "use client" at the top of the file. They:
useState, useEffect, useRef, etc.)// 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>
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>
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
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>
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
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.tsxAutomatically 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.tsxA 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.tsxShown 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>
Use <Link> from next/link for client-side navigation between routes: