Pages Router basics
Work in an existing Pages Router app: routes from the pages folder, _app and _document, API routes, next/router, and both routers side by side.
- 01Find your way in a pages folderTell which file serves which address, what _app and _document do, and where an API route lives.
- 02Use the Pages Router's toolsNavigate with next/link, read the address with useRouter from next/router, and answer requests in pages/api.
- 03Know where the two routers meetTell App Router and Pages Router code apart, run both in one app, and know what breaks when they mix.
The tea shop, with a pages folder
Everything in this course so far used the app folder: the App Router, which came in Next.js 13. Before it, every Next.js app used the pages folder: the Pages Router. Next.js 16.4.3 still supports it, and many apps you'll join were built with it. You don't need all of it to work in one: you need to read the folder, know two special files, answer requests in its API routes, and use its router.
Here is the tea shop as a Pages Router app: a home page, a page for each tea, and an endpoint that lists the teas. It's a real Next.js project, made the way create-next-app makes a Pages Router app, built with next build and run with next start. Click through its files, then open the Steps tab: Chrome typed mint into the search box, clicked Mint tea, then Next tea.
pages/_app.tsximport "@/styles/globals.css";import type { AppProps } from "next/app";import Layout from "@/components/layout"; export default function App({ Component, pageProps }: AppProps) { return ( <Layout> <Component {...pageProps} /> </Layout> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
$3 a box
Mint tea | Tea shop
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
pages folder, a file's path is its address: index.tsx is /, and [id] matches any one part, like mint. Files in api answer requests instead of showing pages. The two files that start with an underscore have no address.A theater's building is the same for every show. On its stage, the set stays up while the scenes change, and the actors keep their props. The box office is part of the theater too, but nobody watches a play there: you ask it a question, and it answers.
- In real life: The building: walls, doors and seats, there before any show
- In Next.js:
_document: the<html>and<body>, rendered on the server - In real life: The stage and the set, which stay up between scenes
- In Next.js:
_app: the layout every page shares, which stays between pages - In real life: Each scene, played on that stage
- In Next.js: Each file in
pages: one page - In real life: The box office round the side, which answers questions but shows no play
- In Next.js:
pages/api: endpoints that answer with data
In the Pages Router, each page file in pages is a route, named by its path. Special files whose names start with an underscore, like _app and _document, are not routes: _app wraps every page and stays between them, and _document renders the HTML around them on the server. Files in pages/api answer requests with data. There are no Server Components: each page's component also goes to the browser, and React hydrates the whole page. Only a page's data functions, like getStaticProps, and what they alone import are left out of the browser's code.
The pages folder: one file, one route
In the App Router, a folder is a part of the address and a page.tsx file makes it a page (File-system routing). In the pages folder, the file itself is the address:
pages/index.tsxis/. A file namedindexis its folder's own page, sopages/teas/index.tsxwould be/teas.pages/about.tsxwould be/about: any file name works, not onlyindex.pages/teas/[id].tsxis a dynamic route:[id]matches any one part of the address, like/teas/mint(Dynamic routes).pages/api/teas.tsis/api/teas, an endpoint, not a page.
Each page is a React component, exported by default:
import Head from "next/head";import { useRouter } from "next/router";import { teas } from "@/lib/teas"; export default function TeaPage() { const router = useRouter(); if (!router.isReady) return <p>Loading…</p>; const index = teas.findIndex((tea) => tea.id === router.query.id); if (index === -1) return <p>We don't sell that tea.</p>; const tea = teas[index]; const next = teas[(index + 1) % teas.length]; return ( <> <Head> <title>{`${tea.name} | Tea shop`}</title> </Head> <h1>{tea.name}</h1> <p>${tea.price} a box</p> <button onClick={() => router.push(`/teas/${next.id}`)}>Next tea</button> </> );} Line 2 imports useRouter from next/router, the Pages Router's router, and line 6 calls it. Line 8 reads the address's [id] part from router.query.id. Line 7 waits for router.isReady first, and the recording shows why. Open the Terminal tab: ○ /teas/[id], prerendered as static content. This page has no function that loads its data, so next build made one generic HTML output, reused for every id, before any id was known. In the Network tab, /teas/mint and /teas/oolong got the same HTML, Loading…, with 200. The id arrives in the browser: after hydration, isReady is true and Chrome showed Mint tea and $3 a box, or We don't sell that tea. for oolong.
That's the Pages Router's default for a page with no data function, and it's why its apps use getStaticProps and getServerSideProps: functions that load a page's data on the server, so the HTML holds it. Data fetching in the Pages Router teaches them.
The route table also lists /_app with no symbol, and ○ /404: Next.js's own page for addresses that don't exist, which /nowhere got, with 404. A pages/404.tsx would replace it.
_app and _document
Two files in pages start with an underscore. They have no address; they wrap every page. Each row compares where one job lives in the two routers:
| Job | App Router | Pages Router |
|---|---|---|
| The page for an address | app/teas/[id]/page.tsx | pages/teas/[id].tsx (a folder's own page is index.tsx) |
<html> and <body> | The root layout, app/layout.tsx | pages/_document.tsx, rendered only on the server |
| A layout every page shares | app/layout.tsx, which stays on screen between pages | pages/_app.tsx, which wraps every page |
| Global CSS | Imported in a layout | Imported in pages/_app.tsx |
| A page's title | export const metadata or generateMetadata | <Head> from next/head, inside the page |
| An endpoint for other programs | route.ts with a function for each method | pages/api/…, one handler(req, res) for every method |
| Reading the address | useRouter, usePathname, useParams from next/navigation | useRouter from next/router: query, isReady, push |
_app: around every page, on the server and in the browser
import "@/styles/globals.css";import type { AppProps } from "next/app";import Layout from "@/components/layout"; export default function App({ Component, pageProps }: AppProps) { return ( <Layout> <Component {...pageProps} /> </Layout> );} Next.js renders App for every page, and passes it the page as Component and that page's props as pageProps. Line 8 renders the page. Line 7 wraps it in Layout, the navigation with a search box. Line 1 imports the global CSS: in the Pages Router, _app is where global CSS goes (CSS in Next.js).
When a link changes the page, App stays, and only Component changes, so Layout's state stays too. In the recording's Steps, Chrome typed mint into the search box, then clicked Mint tea: the step made no page load, and the box still said mint. The last step opens /teas/oolong by its address, a full page load, and the box is empty again.
_document: the HTML around it, on the server only
import { Html, Head, Main, NextScript } from "next/document"; export default function Document() { return ( <Html lang="en"> <Head /> <body className="tea-shop"> <Main /> <NextScript /> </body> </Html> );} _document renders only on the server, when a page's HTML is made (for this shop's static pages, during the build), and never in the browser, so it can't handle a click. Line 5 is the <html> tag with its lang, and line 7 the <body> with a class. Main, on line 8, is where the page goes, and NextScript, on line 9, adds the scripts. Open the recording's Network tab and pick / to see what they made:
<html lang="en">and<body class="tea-shop">, from lines 5 and 7.<div id="__next">, fromMain: the layout and the page inside it, already in the HTML.<script id="__NEXT_DATA__" type="application/json">, fromNextScript, beside the page's other scripts (the Network tab leaves out every script's contents). It's JSON that the browser reads when the Pages Router starts: the page, its query and its props, in client/index.tsx. The data functions in the next lesson send their data to the browser there.
<Head /> on line 6 is next/document's, and it's only for what every page shares. A page's own title comes from Head in next/head, a different component: the tea page's line 15 sets Mint tea | Tea shop, which Chrome showed as the tab's title.
_document renders the HTML around everything, on the server only. _app wraps every page on the server and in the browser; when a link changes the page, it stays, so the search box keeps mint. Only the innermost box changes.API routes
Every file in pages/api is an API route: an endpoint that answers with data instead of a page. They are the Pages Router's counterpart to the App Router's Route Handlers, and the build marks them ƒ, server-rendered on demand.
import type { NextApiRequest, NextApiResponse } from "next";import { teas, type Tea } from "@/lib/teas"; export default function handler(req: NextApiRequest, res: NextApiResponse<Tea[] | { error: string }>) { if (req.method !== "GET") { res.setHeader("Allow", "GET"); return res.status(405).json({ error: "Only GET is allowed." }); } res.status(200).json(teas);} Line 4 is the one function the file exports: handler, with req, the request, and res, the response. They are Node.js's request and response objects, with a few helpers Next.js adds. Every method arrives here, so line 5 checks req.method. Anything but GET gets line 6's Allow: GET header and line 7's 405. Line 9 sends the teas with 200.
In the recording's Network tab, /api/teas answered 200 with the three teas as JSON. POST, from another program sent {"name":"Oolong"} and got 405, allow: GET and {"error":"Only GET is allowed."}.
Here is the same endpoint as an App Router Route Handler, to compare (not recorded here; the Route Handlers lesson records them):
import { teas } from "@/lib/teas";
// Only GET is exported, so Next.js answers a POST with 405 by itself.
export async function GET() {
return Response.json(teas);
}A Route Handler exports a function for each method, here only GET, and Next.js answers a POST with 405 by itself. It takes the web's Request and returns a Response, where an API route writes to res. Both are public: anyone can send them anything, so one that reads or changes private data checks who is asking first, as Authorization and the data access layer does.
Both routers in one app
A project can have both folders. That's how apps move to the App Router, one page at a time. This version of the shop adds an app folder with a root layout and one page, app/about/page.tsx, and an About link in the shop's layout.
app/about/page.tsximport Link from "next/link"; export default function AboutPage() { return ( <main> <h1>About the shop</h1> <p>Three teas, packed by hand.</p> <Link href="/">Back to the teas</Link> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack) Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... We detected TypeScript in your project and reconfigured your tsconfig.json file for you. The following suggested values were added to your tsconfig.json. These values can be changed to fit your project's needs: - plugins was updated to add { name: 'next' } Finished TypeScript ... Collecting page data ...✓ Generating static pages (6/6) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ○ /about Route (pages)┌ ○ /├ /_app├ ƒ /api/teas└ ○ /teas/[id] ○ (Static) prerendered as static contentƒ (Dynamic) server-rendered on demandThe build prints two route tables: Route (app) with ○ /about, and Route (pages) with ○ / and the rest. Each router serves its own addresses.
Side by side, each router serves its own addresses, and next build prints a table for each: Route (app) and Route (pages). When both folders serve /about, the build stops with App Router and Pages Router both match path: /about. The app folder doesn't win; nothing does until one of the files is gone.
Crossing between them is a full page load. In the side-by-side version, Chrome typed mint into the search box on /, then clicked About:
app/about/page.tsximport Link from "next/link"; export default function AboutPage() { return ( <main> <h1>About the shop</h1> <p>Three teas, packed by hand.</p> <Link href="/">Back to the teas</Link> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Three teas, packed by hand.
Back to the teas- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (2), without the files it loaded
GET/about200a whole pageGET/200text/html- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /about
The click made a document request for /about: a whole new page, the way a plain <a> works. The two routers don't share a client-side router, so _app, its layout and the search box's mint are gone; /about has the App Router's root layout instead.
create-next-app 16.4.3 turns on cacheComponents for an App Router project, but writes only reactStrictMode: true for a Pages Router one. With only cacheComponents: true added to that file, the recorded build of the shop still worked. It warned that cacheComponents is on without a partialPrefetching option, printed Cache Components enabled, then the same route table, and / and /teas/mint sent the same pages. Cache Components changes how App Router routes are rendered: "use cache", cacheLife and the static shell all work on Server Components, which the pages folder doesn't have.
Reading a Pages Router codebase
In an app you've just joined, the first job is telling which router a file belongs to. A few things give it away at once: the folder, the file names, and the imports. Sort these lines and files:
pages/teas/index.tsxapp/teas/page.tsximport { useRouter } from "next/router""use client"at the top of a fileexport default function handler(req, res)export async function GET(request: Request)pages/_document.tsx<Head><title>Mint tea</title></Head>fromnext/headexport const metadata = { title: "Tea shop" }
Sort each file or line into the router it belongs to.
Then find the four places that hold an app's shape: pages/_app.tsx for what wraps every page, pages/_document.tsx for the HTML around it, pages/api for the endpoints, and next.config for the settings.
Common mix-ups
- "The Pages Router is gone in Next.js 16." No. Next.js 16.4.3 builds and runs it; the docs keep a section for it.
- "
_documentis a layout, like_app." No. It renders on the server only, for the HTML around the app. A layout with state goes in_app. - "Pages Router pages are Server Components." No. The Pages Router has none: each page's component goes to the browser too, and React hydrates the whole page.
- "
router.queryhas the id in the first HTML." Not for a page with no data function. Its HTML was made during the build, before any id existed; the recorded/teas/mintsaidLoading…. - "Any
useRouterworks anywhere." No.next/router's only works under the Pages Router;next/navigation's has noqueryorisReady. - "When both folders have a page for one address,
appwins." No. The build stops until one is removed.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
These are the shop's files. Which addresses does next build list, besides /404?
components/
layout.tsx
lib/
teas.ts
pages/
api/
teas.ts
teas/
[id].tsx
_app.tsx
_document.tsx
index.tsx
styles/
globals.css/, /api/teas and /teas/[id]. _app.tsx and _document.tsx have no address; the build lists /_app without a symbol. The recorded build printed ○ /, ƒ /api/teas and ○ /teas/[id], and Next.js's own ○ /404.
A visitor whose browser hasn't run the JavaScript yet asks for /teas/mint. What does the HTML show where the tea will be?
import Head from "next/head";
import { useRouter } from "next/router";
import { teas } from "@/lib/teas";
export default function TeaPage() {
const router = useRouter();
if (!router.isReady) return <p>Loading…</p>;
const index = teas.findIndex((tea) => tea.id === router.query.id);
if (index === -1) return <p>We don't sell that tea.</p>;
const tea = teas[index];
const next = teas[(index + 1) % teas.length];
return (
<>
<Head>
<title>{`${tea.name} | Tea shop`}</title>
</Head>
<h1>{tea.name}</h1>
<p>${tea.price} a box</p>
<button onClick={() => router.push(`/teas/${next.id}`)}>Next tea</button>
</>
);
}
Loading…. The page has no data function, so the build made one generic HTML output, reused for every id, while router.isReady was false. The recorded HTML for /teas/mint held Loading…; Chrome showed Mint tea after hydration.
Write an About page with the heading About the shop. Which file is it, and which line does the route table gain?
export default function About() {
return (
<>
<h1>About the shop</h1>
<p>Three teas, packed by hand.</p>
</>
);
}
pages/about.tsx. The recorded build listed ○ /about between ○ /404 and ƒ /api/teas, and /about showed the heading inside _app's layout.
Write pages/api/teas/[id].ts: it answers with one tea as JSON, or with an error when the shop doesn't sell it. Which status does /api/teas/oolong get?
import type { NextApiRequest, NextApiResponse } from "next";
import { teas, type Tea } from "@/lib/teas";
export default function handler(req: NextApiRequest, res: NextApiResponse<Tea | { error: string }>) {
const tea = teas.find((item) => item.id === req.query.id);
if (!tea) return res.status(404).json({ error: "We don't sell that tea." });
res.status(200).json(tea);
}
404. The recorded /api/teas/mint answered 200 with {"id":"mint","name":"Mint tea","price":3}, and /api/teas/oolong answered 404 with the error.
A teammate added this button to the App Router's About page, and the build fails with NextRouter was not mounted. Which package should line 3 import useRouter from?
"use client";
import { useRouter } from "next/router";
export default function BackButton() {
const router = useRouter();
return <button onClick={() => router.back()}>Back</button>;
}
"use client";
import { useRouter } from "next/navigation";
export default function BackButton() {
const router = useRouter();
return <button onClick={() => router.back()}>Back</button>;
}
next/navigation. The file is in app, and next/router's router only exists under the Pages Router, so the recorded build failed while rendering /about. With the import changed, it built and listed ○ /about. back() is on both routers.
The shop already has app/about/page.tsx. Someone adds this file as pages/about.tsx. Which page does /about show?
export default function About() {
return <h1>About the shop</h1>;
}
Neither: the build fails with App Router and Pages Router both match path: /about. Remove one of the two files; during a move, remove the pages one once the app one is ready.
Check your understanding
For each question, ask which folder the file is in, and where its code runs.
Question 1 of 7Which file is the page for
/teasin the Pages Router?Choose an answer to see the explanation.
Question 2 of 7What is
pages/_document.tsxfor?Choose an answer to see the explanation.
Question 3 of 7The tea page reads
router.query.idfromnext/router. What did the HTML for/teas/minthold?Choose an answer to see the explanation.
Question 4 of 7How does
pages/api/teas.tsanswer a POST?Choose an answer to see the explanation.
Question 5 of 7This Client Component is in
app/about/back-button.tsx. What happens when you build?Read the code, then predictTSX"use client"; import { useRouter } from "next/router"; export default function BackButton() { const router = useRouter(); return <button onClick={() => router.back()}>Back</button>; }Choose an answer to see the explanation.
Question 6 of 7
pages/about.tsxandapp/about/page.tsxboth exist. What does/aboutshow?Choose an answer to see the explanation.
Question 7 of 7A link on a Pages Router page goes to
/about, an App Router page. What happens on click?Choose an answer to see the explanation.
Key takeaways
- In
pages, a file's path is its address:index.tsxis its folder's page, and[id].tsxmatches any one part. _appwraps every page and stays between pages: global CSS and the shared layout go there._documentrenders<html>and<body>on the server only.- Files in
pages/apiare API routes: onehandler(req, res)for every method, the Pages Router's counterpart to the App Router's Route Handlers. useRouterfromnext/routerbelongs to pages; the App Router's is innext/navigation.- Both folders can live in one app. A link between them loads the whole page, and one address in both stops the build.
- A page with no data function is made once at build time; its address's parts arrive in the browser.
Remember the one-liner.
In the Pages Router, the page file is the route, _app wraps every page, _document wraps the HTML, and pages/api answers with data.
Coming next: Data fetching in the Pages Router. getStaticProps, getStaticPaths and getServerSideProps put the tea into the HTML, and each maps to something in the App Router.