cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Find your way in a pages folderTell which file serves which address, what _app and _document do, and where an API route lives.
  • 02
    Use the Pages Router's toolsNavigate with next/link, read the address with useRouter from next/router, and answer requests in pages/api.
  • 03
    Know 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.

The tea shop with a pages folderNext.js
components/lib/pages/api/teas/styles/
pages/_app.tsx
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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/mintMint tea | Tea shop
Teasmint
Mint tea

$3 a box

Next tea

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

An editor window for the pages folder lists index.tsx, teas/[id].tsx, api/teas.ts, _app.tsx and _document.tsx. Arrows go from the first three to their addresses: slash, slash teas slash mint, and slash api slash teas. _app.tsx has no address and wraps every page. _document.tsx has no address and is the HTML around it.pagesindex.tsxteas/[id].tsxapi/teas.ts_app.tsx_document.tsx//teas/mint/api/teasno address: wraps every pageno address: the HTML around it
In the 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.
Real-life analogyA theater

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
The main idea

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.tsx is /. A file named index is its folder's own page, so pages/teas/index.tsx would be /teas.
  • pages/about.tsx would be /about: any file name works, not only index.
  • pages/teas/[id].tsx is a dynamic route: [id] matches any one part of the address, like /teas/mint (Dynamic routes).
  • pages/api/teas.ts is /api/teas, an endpoint, not a page.

Each page is a React component, exported by default:

pages/teas/[id].tsx: one tea, found from the addressTSX
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:

Where each job lives
JobApp RouterPages Router
The page for an addressapp/teas/[id]/page.tsxpages/teas/[id].tsx (a folder's own page is index.tsx)
<html> and <body>The root layout, app/layout.tsxpages/_document.tsx, rendered only on the server
A layout every page sharesapp/layout.tsx, which stays on screen between pagespages/_app.tsx, which wraps every page
Global CSSImported in a layoutImported in pages/_app.tsx
A page's titleexport const metadata or generateMetadata<Head> from next/head, inside the page
An endpoint for other programsroute.ts with a function for each methodpages/api/…, one handler(req, res) for every method
Reading the addressuseRouter, usePathname, useParams from next/navigationuseRouter from next/router: query, isReady, push

_app: around every page, on the server and in the browser

pages/_app.tsx: global CSS and the shared layoutTSX
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

pages/_document.tsx: the html and body tagsTSX
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">, from Main: the layout and the page inside it, already in the HTML.
  • <script id="__NEXT_DATA__" type="application/json">, from NextScript, 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.

Three boxes, one inside the other. The outer window, _document, holds html lang en and body class tea-shop, and is rendered on the server only. Inside it, the _app window holds the Layout: the Teas link and the search box, which keeps what you type between pages. Inside that, the page: Mint tea, $3 a box, and a Next tea button. The page changes when you navigate; _app stays._document<html lang="en"> <body class="tea-shop">_appLayout: Teas [ mint ]pageMint teaNext teaserver onlyserver and browser;stays between pageschanges on eachnavigation
_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.

pages/api/teas.ts: the teas, as JSONTypeScript
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):

app/api/teas/route.ts: the same endpoint in the App RouterTypeScript
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.

Playground: what if both folders serve /about?
The tea shop, with an app folder tooNext.js
app/about/components/lib/pages/api/teas/styles/
app/about/page.tsx
import 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>  );}
Recorded from Next.js 16.4.3
$ 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 demand
Try it yourself
Side by side

The build prints two route tables: Route (app) with ○ /about, and Route (pages) with ○ / and the rest. Each router serves its own addresses.

Both versions were built with next build from Next.js 16.4.3. The second adds pages/about.tsx. Reset goes back to the first version.

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:

From a Pages Router page to an App Router pageNext.js
app/about/components/lib/pages/api/teas/styles/
app/about/page.tsx
import 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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/aboutTea shop
About the shop

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

  1. GET/about200a whole page
  2. GET/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.

Left, an app folder with about/page.tsx, serving slash about. Right, a pages folder with index.tsx and teas/[id].tsx, serving slash and slash teas slash mint. A dashed line between them says: a link between them is a full page load. Below: pages/about.tsx and app/about/page.tsx, the same address in both folders, stop the build.appabout/page.tsx → /aboutpagesindex.tsx → /teas/[id].tsx → /teas/minta link between them: a full page loadpages/about.tsxapp/about/page.tsxsame address in both: the build stops
Both folders can live in one project, each serving its own addresses, and the build prints a route table for each. A link from one router's page to the other's loads the whole page. One address in both folders stops the build.
Cache Components and the pages folder

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:

App Router or Pages Router?
  • pages/teas/index.tsx
  • app/teas/page.tsx
  • import { useRouter } from "next/router"
  • "use client" at the top of a file
  • export default function handler(req, res)
  • export async function GET(request: Request)
  • pages/_document.tsx
  • <Head><title>Mint tea</title></Head> from next/head
  • export const metadata = { title: "Tea shop" }
Try it yourself
0 of 9 correct

Sort each file or line into the router it belongs to.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.
  • "_document is 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.query has 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/mint said Loading….
  • "Any useRouter works anywhere." No. next/router's only works under the Pages Router; next/navigation's has no query or isReady.
  • "When both folders have a page for one address, app wins." 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.

Exercise 1 · Warm-upRead the folder

These are the shop's files. Which addresses does next build list, besides /404?

Starter codeText
components/
  layout.tsx
lib/
  teas.ts
pages/
  api/
    teas.ts
  teas/
    [id].tsx
  _app.tsx
  _document.tsx
  index.tsx
styles/
  globals.css

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upBefore the JavaScript runs

    A visitor whose browser hasn't run the JavaScript yet asks for /teas/mint. What does the HTML show where the tea will be?

    Starter codeTSX
    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>
        </>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeAdd an About page

      Write an About page with the heading About the shop. Which file is it, and which line does the route table gain?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeAn endpoint for one tea

        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?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFix the Back button

          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?

          Starter codeTSX
          "use client";
          
          import { useRouter } from "next/router";
          
          export default function BackButton() {
            const router = useRouter();
            return <button onClick={() => router.back()}>Back</button>;
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeOne address, two folders

            The shop already has app/about/page.tsx. Someone adds this file as pages/about.tsx. Which page does /about show?

            Starter codeTSX
            export default function About() {
              return <h1>About the shop</h1>;
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which folder the file is in, and where its code runs.

              The Pages Router quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Which file is the page for /teas in the Pages Router?

                Choose an answer to see the explanation.

              2. Question 2 of 7What is pages/_document.tsx for?

                Choose an answer to see the explanation.

              3. Question 3 of 7The tea page reads router.query.id from next/router. What did the HTML for /teas/mint hold?

                Choose an answer to see the explanation.

              4. Question 4 of 7How does pages/api/teas.ts answer a POST?

                Choose an answer to see the explanation.

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

              6. Question 6 of 7pages/about.tsx and app/about/page.tsx both exist. What does /about show?

                Choose an answer to see the explanation.

              7. 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.tsx is its folder's page, and [id].tsx matches any one part.
              • _app wraps every page and stays between pages: global CSS and the shared layout go there. _document renders <html> and <body> on the server only.
              • Files in pages/api are API routes: one handler(req, res) for every method, the Pages Router's counterpart to the App Router's Route Handlers.
              • useRouter from next/router belongs to pages; the App Router's is in next/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.

              CompleteFrontend Clear concepts. Working examples.