By the end you'll know
- What a framework like Next.js adds, and what it costs
- How folders and files become pages and routes in the App Router
- How to split a page into components, and pass them data with props
- The difference between server components and client components, and how to tell which code reaches the browser
- What
next builddecides about each page, and how to read its output - How Next.js moves between pages without reloading them
Before you start
- You need: Lessons 1 to 3, and Node.js 20.9 or later. Some JavaScript helps; React is explained as it comes up.
- Version: Next.js 16.4.5 and React 19.3.1, the latest releases when this was written (November 2026). Next.js changes quickly; the ideas here have been stable since version 13, but check the details against the version you install.
- The example: Juniper & Rye, the made-up bakery. Every file, and every line of output below, is real.
The short version
- Folders are routes.
app/menu/page.jsis the page at/menu;app/menu/[slug]/page.jsis every bake's page. - Components are functions that return HTML-like JSX, and props are how you pass them data.
- Every component runs on the server by default. Its HTML is sent; its code isn't. The menu is now in the HTML, so it shows with JavaScript off.
"use client"marks the components that need the browser (state, clicks, form handling). Their code is sent too, and so is everything they import.next builddecides each page's fate: static, generated from data, or rendered on each request.- It costs JavaScript: 194 KB for the bakery's home page, almost all of it React and Next.js, against about 2 KB for Lesson 3's script. Worth it for a real site; not free.
Why a framework
Lesson 3's version 2 was careful and robust, and it still couldn't show the menu without JavaScript: the menu only existed after a script fetched it. Fixing that by hand means generating the HTML on a server, keeping a shared header and footer on every page, linking pages together, and doing all of it fast. That's what a framework like Next.js does, on top of React, a library for building pages out of components.
Next.js adds:
- Routing from folders: the structure of your files is the structure of your site.
- Rendering on the server: pages arrive as complete HTML, with their content in them.
- A build step that decides which pages can be made once, in advance, and which need a server on every request.
- Navigation without full reloads, and prefetching pages before they're clicked.
The bakery as a Next.js app
The whole app:
juniper-and-rye/
├── package.json
├── data/
│ └── menu.js today's bakes
├── public/
│ └── loaf.svg served as /loaf.svg
└── app/
├── layout.js the frame around every page
├── globals.css Lesson 2's styles
├── page.js /
├── menu/
│ ├── page.js /menu
│ └── [slug]/
│ └── page.js /menu/dark-rye, /menu/cardamom-bun, ...
├── api/
│ └── signup/
│ └── route.js POST /api/signup
└── components/
├── SiteHeader.js
├── MenuList.js
└── SignupForm.js
package.json pins exact versions, so the app builds the same way next year:
{
"name": "juniper-and-rye",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.4.5",
"react": "19.3.1",
"react-dom": "19.3.1"
}
}
(Your machine), in that folder:
npm install
npm run dev
npm install downloads Next.js and React into node_modules. npm run dev starts a development server at http://localhost:3000 that rebuilds as you edit. The App Router's rules, in one table:
| File | What it is | In the bakery |
|---|---|---|
app/.../page.js | A page; its folder path is its URL | app/menu/page.js is /menu |
app/.../layout.js | A frame around every page below it; it stays in place when you move between them | The header and footer |
[name] folder | A dynamic segment: one page for many URLs | [slug] is each bake |
app/.../route.js | An API endpoint instead of a page | POST /api/signup |
public/ | Files served as they are | /loaf.svg |
Components and props
A component is a function that returns JSX: HTML-like markup inside JavaScript. Here's the site header:
import Link from "next/link";
export default function SiteHeader() {
return (
<header className="site-header">
<Link href="/" className="logo">Juniper & Rye</Link>
<nav aria-label="Main">
<ul>
<li><Link href="/menu">Today's bakes</Link></li>
<li><Link href="/#signup">Bake list</Link></li>
</ul>
</nav>
</header>
);
}
JSX looks like Lesson 2's HTML, with a few differences: className instead of class, htmlFor instead of for, and { } to put JavaScript values inside markup. Link is Next.js's version of <a>: it renders a normal link, so it works without JavaScript, and with JavaScript it moves between pages without a full reload.
Props pass data into a component, the way arguments pass data into a function. MenuList takes a list of bakes and shows them:
import Link from "next/link";
export default function MenuList({ items }) {
return (
<ul className="menu">
{items.map((b) => (
<li key={b.slug}>
<Link href={`/menu/${b.slug}`} className="item">{b.name}</Link>
<span className="price">${b.price.toFixed(2)}</span>
</li>
))}
</ul>
);
}
{ items }takes theitemsprop out of the props object.items.map(...)returns one<li>per bake.keygives each a stable identity, so React can tell which item is which when the list changes.{b.name}puts text into the page, and React treats it as text, never HTML: Lesson 3's injection can't happen here.
The same component is used on two pages, the home page and /menu. Change it once, and both change.
The layout wraps every page:
import "./globals.css";
import SiteHeader from "./components/SiteHeader";
export const metadata = {
title: { default: "Juniper & Rye Bakery", template: "%s | Juniper & Rye" },
description: "Sourdough, rye and pastries, baked every morning on Alder Street.",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<SiteHeader />
<main>{children}</main>
<footer><address>12 Alder Street</address></footer>
</body>
</html>
);
}
children is whichever page is being shown. metadata sets the page's <title> and description; the template makes every page's title end with "| Juniper & Rye".
A page for every bake
The [slug] folder makes one page serve every bake:
import { notFound } from "next/navigation";
import { bakes, findBake } from "../../../data/menu";
export function generateStaticParams() {
return bakes.map((b) => ({ slug: b.slug }));
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const bake = findBake(slug);
return { title: bake ? bake.name : "Not found" };
}
export default async function BakePage({ params }) {
const { slug } = await params;
const bake = findBake(slug);
if (!bake) notFound();
return (
<article>
<h1>{bake.name}</h1>
<p className="price">${bake.price.toFixed(2)}</p>
<p>{bake.about}</p>
</article>
);
}
paramsholds the URL's dynamic parts: for/menu/dark-rye,slugis"dark-rye". In Next.js 15 and later,paramsis a promise, so it'sawaited.generateStaticParamslists every slug in advance, so the build can make each page ahead of time.generateMetadatagives each bake's page its own title.notFound()sends a real404for a bake that doesn't exist, instead of a broken page.
Server components and client components
Every component above runs on the server. It reads the menu file, produces HTML, and that HTML is what's sent. Its code stays on the server. These are server components, and they're the default.
Some things can only happen in the browser: reacting to a click, remembering what someone typed, disabling a button while a request is sent. A component that does those is a client component, marked by "use client" on its first line. The sign-up form is Lesson 3's version 2, rewritten as one:
"use client";
import { useState } from "react";
export default function SignupForm() {
const [status, setStatus] = useState("");
const [sending, setSending] = useState(false);
async function onSubmit(e) {
e.preventDefault();
setSending(true);
setStatus("Signing you up...");
try {
const res = await fetch("/api/signup", { method: "POST", body: new FormData(e.target) });
const body = await res.json();
setStatus(res.ok ? body.message : `Sorry: ${body.error}`);
if (res.ok) e.target.reset();
} catch {
setStatus("Sorry, that didn't go through. Please try again.");
} finally {
setSending(false);
}
}
return (
<>
<form action="/api/signup" method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email address</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<button type="submit" disabled={sending}>Sign up</button>
</form>
<p className="status" role="status">{status}</p>
</>
);
}
useStategives a component memory.statusholds the current message;setStatuschanges it, and React updates the page to match. There's notextContentorgetElementById: you change the state, React changes the page.onSubmit={onSubmit}attaches the handler, likeaddEventListenerin Lesson 3.disabled={sending}disables the button wheneversendingis true.action="/api/signup"is still there, so the form works with no JavaScript at all.
The form posts to a route handler, a server-side function in app/api/signup/route.js:
// POST /api/signup: a sign-up from the form, with or without JavaScript.
export async function POST(request) {
const form = await request.formData();
const email = String(form.get("email") || "");
if (!email.includes("@")) {
return Response.json({ error: "that doesn't look like an email address" }, { status: 400 });
}
const message = `Thanks, ${email} is on the bake list.`;
if ((request.headers.get("accept") || "").includes("text/html")) {
return new Response(`<!doctype html><title>Thanks</title><p>${message.replace(/</g, "<")}</p>`,
{ headers: { "Content-Type": "text/html" } });
}
return Response.json({ message }, { status: 201 });
}
It checks the email on the server (the browser's check is only a convenience, as Lesson 3 said), and answers with JSON for the script or a plain HTML page for a browser submitting the form without JavaScript. The replace escapes <, so an email address can't inject HTML into that page.
Building it
(Your machine)
npm run build
▲ Next.js 16.4.5 (Turbopack)
✓ Running next.config took 18ms
Creating an optimized production build ...
✓ Compiled successfully in 4.8s
Running TypeScript ...
Finished TypeScript in 2ms ...
Collecting page data using 1 worker ...
Generating static pages using 1 worker (0/8) ...
Generating static pages using 1 worker (2/8)
Generating static pages using 1 worker (4/8)
Generating static pages using 1 worker (6/8)
✓ Generating static pages using 1 worker (8/8) in 289ms
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/signup
├ ○ /menu
└ /menu/[slug]
├ ● /menu/country-sourdough
├ ● /menu/dark-rye
└ ● /menu/cardamom-bun
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
The route table is the build's decision about each page:
- ○ Static: the home page and
/menudon't depend on anything that changes per request, so their HTML was made once, now, during the build. Serving them is as cheap as serving a file. - ● SSG (static site generation):
generateStaticParamslisted three bakes, so the build made three pages. - ƒ Dynamic:
/api/signupreads each request, so it runs on every request.
Nothing told Next.js which was which; it worked it out from what each file does. Read this table after every build: a page you expected to be static showing up as ƒ means something in it (often reading cookies or headers) made it run on every request, which costs speed and money.
What it sends
(Your machine)
npm start
npm start serves the production build on port 3000. In another terminal, three checks with curl:
echo "== curl: is the menu in the HTML?"
curl -s http://127.0.0.1:3000/ | grep -o 'Country sourdough\|Dark rye\|Cardamom bun' | sort | uniq -c
echo "== curl: a bake that doesn't exist"
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/menu/rye-bread
echo "== curl: response headers for the home page"
curl -sI http://127.0.0.1:3000/ | grep -iE "^(HTTP|x-nextjs-cache|cache-control|x-nextjs-prerender|content-type)"
The first counts how often each bake's name appears in the home page's HTML; the second prints only the status code for a bake that doesn't exist; the third shows a few of the home page's headers.
== curl: is the menu in the HTML?
2 Cardamom bun
2 Country sourdough
2 Dark rye
== curl: a bake that doesn't exist
404
== curl: response headers for the home page
HTTP/1.1 200 OK
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-prerender: 1
Cache-Control: s-maxage=31536000
Content-Type: text/html; charset=utf-8
- The menu is in the HTML. In Lesson 3, curl saw "Loading today's bakes...". Now it sees every bake, twice: once in the visible HTML, and once in a block of data at the end of the page that React uses to take over the page in the browser (the React Server Components payload). That duplication is part of the price of the framework.
- An unknown bake gets a real
404, fromnotFound(). x-nextjs-cache: HITandx-nextjs-prerender: 1say this page came from the build, not from rendering it now.Cache-Control: s-maxage=31536000tells shared caches, like a CDN, they may keep it for a year: Lesson 1'smax-age, for caches between you and the visitor.
Then a browser test, the same idea as Lesson 3's, with JavaScript off and on. The full script is in the practice folder:
-- JavaScript off: home page
menu: ["Country sourdough $8.50","Dark rye $9.00","Cardamom bun $4.25"]
sign-up landed on /api/signup: "Thanks, ada@example.com is on the bake list."
-- JavaScript on: click "Today's bakes", then a bake
home page: 7 script files, 194 KB of JavaScript (uncompressed)
title now: "Dark rye | Juniper & Rye"
document /
fetch / (?_rsc=...)
fetch /menu (?_rsc=...)
fetch /menu/country-sourdough (?_rsc=...)
fetch /menu/dark-rye (?_rsc=...)
fetch /menu/cardamom-bun (?_rsc=...)
fetch / (?_rsc=...)
fetch /menu (?_rsc=...)
fetch /menu/country-sourdough (?_rsc=...)
fetch /menu/dark-rye (?_rsc=...)
fetch /menu/cardamom-bun (?_rsc=...)
-- sign-up with JavaScript
status: "Thanks, ada@example.com is on the bake list." | still on /
- With JavaScript off, everything works. The menu shows, because it's in the HTML; the sign-up works, because the form posts to the route handler and gets a thank-you page. That's Lesson 3's progressive enhancement, now for the whole page.
- With JavaScript on, there was exactly one page load, the first
document /. Clicking "Today's bakes" and then "Dark rye" didn't reload anything: each was afetchfor the new page's server-component data (?_rsc=), and React swapped in the new content while the header stayed put. The title still changed, to "Dark rye | Juniper & Rye". - Next.js fetched pages before they were clicked. Every
Linkon screen is prefetched, so it's ready when clicked. The home page's links (to/,/menuand each bake) were fetched as soon as it loaded, and again from the menu page. That's why clicks feel instant, and why a page full of links makes requests nobody asked for. - 194 KB of JavaScript for the home page, across seven files. Almost all of it is React and Next.js themselves; the bakery's own code is a sliver. Lesson 3's whole script was 2,383 bytes. On a real site, that cost is shared by every page and buys everything above. On a three-item page, it's mostly overhead.
And one last check: which text ended up in the JavaScript sent to browsers?
echo "== which JavaScript files sent to browsers contain each piece of text?"
for s in "Signing you up" "two-day sourdough" "Country sourdough"; do
echo " \"$s\": $(grep -rl "$s" .next/static/chunks | wc -l | tr -d ' ') file(s)"
done
.next/static/chunks holds every JavaScript file the build made for browsers. grep -rl lists the files containing each piece of text, wc -l counts them, and tr -d ' ' removes the spaces macOS puts in front of the count.
== which JavaScript files sent to browsers contain each piece of text?
"Signing you up": 1 file(s)
"two-day sourdough": 0 file(s)
"Country sourdough": 0 file(s)
The client component's message is in one file, because its code runs in the browser. The bake descriptions and names are in none: the menu data and the server components that read it never left the server. Only their output did, as HTML. That boundary is the most important thing to understand about Next.js today.
In real systems
- Keep
"use client"low in the tree. Put it on the small interactive piece (a button, a form), not on a whole page. Everything a client component imports becomes browser code. - Server components can hold secrets; client components can't. A database password or API key in a server component stays on the server. The same line in a client component, or in any file a client component imports, is sent to every visitor.
- Read the route table on every build. In CI, a page changing from ○ to ƒ is worth a look.
- Pin versions. Next.js ships often. Exact versions in
package.jsonand a committedpackage-lock.jsonmean the same build every time; upgrade on purpose, and read the release notes. - Measure the JavaScript. The browser's Network tab shows what each page costs. Lesson 9 measures what it does to real load times.
Spot the problem
Work out what's wrong before you read the explanation.
"It was just a toggle." A bakery's product page is a server component that imports data/menu.js, which, along with the menu, now also exports the supplier's wholesale prices and an API key for the ordering system. A developer adds a "Show ingredients" toggle to the page, which needs useState. React says hooks only work in client components, so they add "use client" to the top of the page file. Everything works, and the page ships. A week later, a competitor quotes the bakery's wholesale costs back to it. What's wrong? "use client" made the whole page, and everything it imports, browser code. The menu file came along, with the wholesale prices and the API key, bundled into JavaScript any visitor can read. Three fixes: move the toggle into its own small client component and keep the page a server component; keep secrets in server-only files, and mark them with the server-only package so the build fails if a client component imports them; and search the built chunks for things that mustn't be there, the same grep as above, as a check in CI. (We tested the mistake on the bakery: with "use client" added to the home page, the build's route table looked exactly the same, and the grep found "two-day sourdough" in a browser JavaScript file. Nothing else warns you.)
Keep this
| Need | Use |
|---|---|
A page at /path | app/path/page.js |
| A frame around pages | layout.js |
| One page for many URLs | A [slug] folder, plus generateStaticParams to build them ahead |
| A real 404 | notFound() |
| An API endpoint | route.js exporting GET, POST... |
| Links between pages | Link from next/link |
| Titles and descriptions | metadata or generateMetadata |
| State, events, browser-only code | A small component with "use client" |
| Secrets and data access | Server components and route handlers only |
| What each page became | The route table from next build |
Where you are now
You can build a site in Next.js from folders and components, pass data with props, give every bake its own page, send real 404s, and handle a form on the server. You can tell server components from client components, prove which code reaches the browser, and read what the build decided about each page. The next lesson does for words what this one did for bakes: content written in MDX files, the way the lessons on this site are.
Check yourself
Which file makes the page at /menu/dark-rye?
Why does the menu now show with JavaScript off?
What does 'use client' do?
In the route table, what does ● mean for /menu/dark-rye?
After clicking two links, why was there still only one 'document' request?
Why were the bake descriptions in no browser JavaScript file?
Key terms
- Framework: a structure for building apps that decides a lot for you; Next.js is a framework built on React.
- React: a library for building interfaces from components.
- Component: a function that returns JSX, describing a piece of the page.
- JSX: HTML-like markup inside JavaScript.
- Props: the data passed into a component.
- State (
useState): data a client component remembers between renders; changing it updates the page. - App Router: Next.js's system of folders and special files (
page.js,layout.js,route.js) that become routes. - Dynamic segment: a
[name]folder that matches many URLs. - Server component: the default; runs on the server and sends only its output.
- Client component: marked
"use client"; its code also runs in the browser. - Route handler: a
route.jsfile that answers HTTP requests, like an API. - Static, SSG and dynamic rendering: made once at build, made at build from a list, or made on each request.
- Prefetching: fetching a linked page before it's clicked.
Go deeper
- Next.js docs: layouts and pages (opens in a new tab), and server and client components (opens in a new tab).
- React docs: your first component (opens in a new tab), and state: a component's memory (opens in a new tab).
- Try: move
"use client"fromSignupForm.jstoapp/page.jsand rebuild. Run thegrepagain: how many files contain "two-day sourdough" now? - Try: add a fourth bake to
data/menu.jsand rebuild. What changes in the route table?