Web development basics · Lesson 4 of 11 · about 35 minutes

Next.js: pages, routes and components

Lesson 3 ended with a menu that only exists if a script runs. This lesson rebuilds the bakery in Next.js 16, where folders become pages, pieces of the page become components, and the menu is built into the HTML before it leaves the server. With JavaScript turned off, the menu now shows and the sign-up works. Then the honest part: what runs on the server, what runs in the browser, the 194 KB of JavaScript the framework costs, and the one line, "use client", that decides which is which.

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 build decides 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.js is the page at /menu; app/menu/[slug]/page.js is 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 build decides 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:

TEXT
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:

JSON
{
  "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:

BASH
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:

FileWhat it isIn the bakery
app/.../page.jsA page; its folder path is its URLapp/menu/page.js is /menu
app/.../layout.jsA frame around every page below it; it stays in place when you move between themThe header and footer
[name] folderA dynamic segment: one page for many URLs[slug] is each bake
app/.../route.jsAn API endpoint instead of a pagePOST /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:

JS
import Link from "next/link";

export default function SiteHeader() {
  return (
    <header className="site-header">
      <Link href="/" className="logo">Juniper &amp; 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:

JS
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 the items prop out of the props object.
  • items.map(...) returns one <li> per bake. key gives 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:

JS
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:

JS
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>
  );
}
  • params holds the URL's dynamic parts: for /menu/dark-rye, slug is "dark-rye". In Next.js 15 and later, params is a promise, so it's awaited.
  • generateStaticParams lists every slug in advance, so the build can make each page ahead of time.
  • generateMetadata gives each bake's page its own title.
  • notFound() sends a real 404 for 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:

JS
"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>
    </>
  );
}
  • useState gives a component memory. status holds the current message; setStatus changes it, and React updates the page to match. There's no textContent or getElementById: you change the state, React changes the page.
  • onSubmit={onSubmit} attaches the handler, like addEventListener in Lesson 3.
  • disabled={sending} disables the button whenever sending is 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:

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, "&lt;")}</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.

What runs where, and what it sends. Select the diagram to enlarge it.

Building it

(Your machine)

BASH
npm run build
TEXT
▲ 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 /menu don'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): generateStaticParams listed three bakes, so the build made three pages.
  • ƒ Dynamic: /api/signup reads 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)

BASH
npm start

npm start serves the production build on port 3000. In another terminal, three checks with curl:

BASH
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.

TEXT
== 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, from notFound().
  • x-nextjs-cache: HIT and x-nextjs-prerender: 1 say this page came from the build, not from rendering it now. Cache-Control: s-maxage=31536000 tells shared caches, like a CDN, they may keep it for a year: Lesson 1's max-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:

TEXT
-- 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 a fetch for 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 Link on screen is prefetched, so it's ready when clicked. The home page's links (to /, /menu and 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?

BASH
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.

TEXT
== 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.json and a committed package-lock.json mean 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

NeedUse
A page at /pathapp/path/page.js
A frame around pageslayout.js
One page for many URLsA [slug] folder, plus generateStaticParams to build them ahead
A real 404notFound()
An API endpointroute.js exporting GET, POST...
Links between pagesLink from next/link
Titles and descriptionsmetadata or generateMetadata
State, events, browser-only codeA small component with "use client"
Secrets and data accessServer components and route handlers only
What each page becameThe 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

  1. Which file makes the page at /menu/dark-rye?

  2. Why does the menu now show with JavaScript off?

  3. What does 'use client' do?

  4. In the route table, what does ● mean for /menu/dark-rye?

  5. After clicking two links, why was there still only one 'document' request?

  6. 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.js file 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