#How the Pages Router works
AddedBefore Next.js 13 there was one router: every file in pages/ is a route, and the file exports a React component that renders that page. pages/about.tsx is /about, pages/posts/[slug].tsx is /posts/anything, pages/index.tsx is /. There are no layouts, no Server Components and no 'use client': every component in a Pages Router app is an ordinary React component that is rendered to HTML on the server (or at build time) and then hydrated in the browser with all of its JavaScript. In App Router terms, everything is a Client Component.
Data comes in through exactly one door. A page may export one data function: getStaticProps (run at build time), getServerSideProps (run on every request) or the older getInitialProps. Whatever it returns becomes the page component's props. Nothing below the page can fetch on the server: a component deep in the tree gets data only as props passed down from the page, or by fetching in the browser after hydration. Two special files wrap every page: pages/_document.tsx is the HTML skeleton (<html>, <head>, <body>), rendered only on the server; pages/_app.tsx is the component that wraps every page and stays mounted when you navigate, so it is where shared layout, global CSS and providers go.
A mental model that holds up: the Pages Router is a single-page app whose screens each come with an optional server-side props recipe. _document is the envelope (server only, written once per HTML response), _app is the picture frame that stays on the wall, and each page is a picture you swap in and out of the frame. On the first visit the server runs the page's recipe and sends finished HTML with the props embedded as JSON. After that, next/link navigations never fetch HTML again: the browser downloads the next page's JavaScript and, if that page has a data function, a small JSON file with its props, and swaps the picture.
File in pages/ | URL | Notes |
|---|---|---|
index.tsx | / | blog/index.tsx is /blog |
about.tsx | /about | |
posts/[slug].tsx | /posts/hello | router.query.slug / params.slug is "hello" |
docs/[...path].tsx | /docs/a/b | path is ["a", "b"]; /docs alone is a 404 |
shop/[[...path]].tsx | /shop, /shop/a/b | optional catch-all: path is undefined for /shop |
api/todos.ts | /api/todos | an API Route, not a page (see API Routes) |
_app.tsx, _document.tsx, _error.tsx, 404.tsx, 500.tsx | none | special files; the underscore ones are never routes |
The example is a small shop. _document sets the <html lang> and a body class, _app wraps every page in a Layout with a navigation bar and a click counter (the counter is there to prove the layout survives navigation), and there are three pages: two with no data function and one with getServerSideProps.
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en-GB">
<Head />
<body className="shop">
<Main />
<NextScript />
</body>
</Html>
);
}import type { AppProps } from "next/app";
import Layout from "../components/Layout";
import "../styles/globals.css";
export default function App({ Component, pageProps }: AppProps) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}- On every full page load the server renders
Documentonce to produce the outer HTML.<Html>,<Head />,<Main />and<NextScript />come fromnext/documentand are all required:<Main />is where the React app's HTML goes,<NextScript />adds the page's scripts and the__NEXT_DATA__JSON.Documentnever runs in the browser, so anonClickhere would do nothing. - Inside
<Main />, Next.js rendersAppwith two props:Component, the page module that matches the URL (for/that'spages/index.tsx), andpageProps, whatever that page's data function returned ({}when it has none). Appwraps the page inLayoutand spreadspagePropsonto it. If you forget{...pageProps}, every page silently gets no props: the data functions still run, but their result never reaches the component.- On a client-side navigation only
ComponentandpagePropschange.AppandLayoutare the same component types in the same position, so React keeps them mounted and their state survives.Documentisn't involved at all: there is no new HTML. - The global stylesheet is imported in
_appbecause that's the one module every page loads. It's the Pages Router's rule for global CSS (see the gotcha below for when it's enforced).
The layout, the three pages and the stylesheet:
import Link from "next/link";
import { useState, type ReactNode } from "react";
export default function Layout({ children }: { children: ReactNode }) {
const [clicks, setClicks] = useState(0);
return (
<>
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/posts/hello">A post</Link>
<button onClick={() => setClicks((c) => c + 1)}>Layout clicks: {clicks}</button>
</nav>
<main>{children}</main>
</>
);
}import Head from "next/head";
export default function Home() {
return (
<>
<Head>
<title>Shop</title>
</Head>
<h1>Home</h1>
</>
);
}export default function About() {
return <h1>About us</h1>;
}import type { GetServerSideProps } from "next";
type Props = { slug: string; renderedAt: string };
export const getServerSideProps: GetServerSideProps<Props> = async ({ params }) => {
return { props: { slug: String(params?.slug), renderedAt: new Date().toISOString() } };
};
export default function Post({ slug, renderedAt }: Props) {
return (
<article>
<h1>Post: {slug}</h1>
<p>Rendered at {renderedAt}</p>
</article>
);
}body { font-family: system-ui, sans-serif; margin: 2rem; }
nav { display: flex; gap: 1rem; align-items: center; }Layoutis an ordinary React component withuseState. In the Pages Router that needs no'use client': every component is shipped to the browser and hydrated.pages/index.tsxandpages/about.tsxexport no data function, so Next.js can render them once at build time.indexusesnext/head(not theHeadfromnext/document) to set a page-specific<title>.pages/posts/[slug].tsxexportsgetServerSideProps, so it can't be prerendered: it runs on every request.params.slugcomes from the file name's[slug]segment.- The build output below confirms it:
/and/aboutare○(static),/posts/[slug]isƒ(rendered per request).
Route (app)
─ ○ /_not-found
Route (pages)
┌ ○ /
├ /_app
├ ○ /about
└ ƒ /posts/[slug]
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demandTwo things in that table are worth reading closely. The /_app row has no symbol because _app isn't a page you can visit; it's listed because its JavaScript is shared by every page. And there's a Route (app) section with /_not-found although this app has no app/ pages: the site builds every example in a copy of a project created by create-next-app@16.4.3, which always has app/layout.tsx. That's a realistic setup (most Pages Router apps you meet mid-migration have both folders), but it changes a few details, and I'll point them out as they come up. When I deleted src/app from a copy and rebuilt a Pages-only app, the pages table listed a ○ /404 row instead, and the ● legend read "prerendered as static HTML (uses getStaticProps)" rather than "(uses generateStaticParams)".
next start -p 4513, then curl -sD - http://localhost:4513/ (the <head> scripts trimmed):
HTTP/1.1 200 OK
X-Powered-By: Next.js
ETag: "g6q82g33r31i1"
Content-Type: text/html; charset=utf-8
<!DOCTYPE html><html lang="en-GB"><head>…<title data-next-head="">Shop</title>…</head>
<body class="shop"><div id="__next"><nav><a href="/">Home</a><a href="/about">About</a>
<a href="/posts/hello">A post</a><button>Layout clicks: <!-- -->0</button></nav>
<main><h1>Home</h1></main></div>
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}},"page":"/","query":{},
"buildId":"N3PBxpd5cSbI0DL9VzFto","nextExport":true,"autoExport":true,"isFallback":false,"scriptLoader":[]}</script>
</body></html>lang="en-GB"andclass="shop"came from_document, and the<title>fromnext/headin the page. Everything React rendered sits inside<div id="__next">: that's what<Main />turns into, and it's the element the browser hydrates.- The nav, button and
<h1>are real HTML, so the page is readable before any JavaScript runs. The button says0because that's the initial state on the server. __NEXT_DATA__is how the server hands its data to the browser:pageProps(empty here), the page's route ("/"), the query and thebuildId. When the JavaScript loads, React hydrates#__nextusing exactly these props, so the first client render matches the HTML."autoExport":trueis Automatic Static Optimization saying "this HTML was made at build time". For thegetServerSidePropspage the same script says"gssp":trueand carries real props:curl -s localhost:4513/posts/helloshowed{"props":{"pageProps":{"slug":"hello","renderedAt":"2026-10-25T18:02:39.463Z"},"__N_SSP":true},"page":"/posts/[slug]","query":{"slug":"hello"},…,"gssp":true,…}.- Anything in
pagePropsis visible to anyone who views the source. Never return a secret, a full database row or another user's data from a data function "because the component only shows the name".
_app gives you one layout for the whole site. For different layouts per section (a dashboard with a sidebar, a marketing page without), the documented pattern is a static getLayout property on the page component that _app calls. Because pages that share a layout return the same component type in the same position, React keeps that layout mounted when you move between them.
import type { NextPage } from "next";
import type { AppProps } from "next/app";
import type { ReactElement, ReactNode } from "react";
export type NextPageWithLayout = NextPage & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & { Component: NextPageWithLayout };
export default function App({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout ?? ((page) => page);
return getLayout(<Component {...pageProps} />);
}import Link from "next/link";
import { useState, type ReactNode } from "react";
export default function DashboardLayout({ children }: { children: ReactNode }) {
const [opened, setOpened] = useState(0);
return (
<section>
<nav>
<Link href="/dashboard">Overview</Link> <Link href="/dashboard/settings">Settings</Link>{" "}
<Link href="/">Leave</Link>
</nav>
<button onClick={() => setOpened((n) => n + 1)}>Sidebar toggles: {opened}</button>
{children}
</section>
);
}import type { ReactElement } from "react";
import DashboardLayout from "../../components/DashboardLayout";
import type { NextPageWithLayout } from "../_app";
const Overview: NextPageWithLayout = () => <h1>Overview</h1>;
Overview.getLayout = (page: ReactElement) => <DashboardLayout>{page}</DashboardLayout>;
export default Overview;import type { ReactElement } from "react";
import DashboardLayout from "../../components/DashboardLayout";
import type { NextPageWithLayout } from "../_app";
const Settings: NextPageWithLayout = () => <h1>Settings</h1>;
Settings.getLayout = (page: ReactElement) => <DashboardLayout>{page}</DashboardLayout>;
export default Settings;import Link from "next/link";
export default function Home() {
return (
<>
<h1>Home</h1>
<Link href="/dashboard">Open the dashboard</Link>
</>
);
}Route (app)
─ ○ /_not-found
Route (pages)
┌ ○ /
├ /_app
├ ○ /dashboard
└ ○ /dashboard/settings
○ (Static) prerendered as static content/dashboard h1 "Overview" button "Sidebar toggles: 2" (clicked twice)
→ /dashboard/settings h1 "Settings" button "Sidebar toggles: 2"
→ / h1 "Home" (no dashboard layout)
→ /dashboard h1 "Overview" button "Sidebar toggles: 0"NextPageWithLayoutisNextPageplus an optionalgetLayout.AppPropsWithLayouttells TypeScript thatComponentmay carry it._appasks the page for its layout function and falls back to "no layout" ((page) => page), then calls it with the rendered page. The layout wraps the page instead of_appdoing it.- Overview and Settings both return
<DashboardLayout>{page}</DashboardLayout>. Moving between them, React sees aDashboardLayoutat the root both times, keeps it, and only swaps its child: the counter stays at2. - Home has no
getLayout, so going to/unmountsDashboardLayout. Coming back mounts a fresh one withopenedback at0. That's reconciliation, not a Next.js feature. - The App Router replaces this whole pattern with nested
layout.tsxfiles, which persist the same way without any wiring (see Pages and layouts).
Navigation uses next/link and the useRouter hook from next/router (the App Router's hook comes from next/navigation and is a different object). From the docs, the parts you'll see most:
next/router | What it is |
|---|---|
router.pathname | the page file's route, e.g. /posts/[slug] |
router.query | path params and query string merged, e.g. { slug: "hello", tab: "items" }; {} during prerendering of a static page until isReady |
router.asPath | the URL as shown in the browser, e.g. /posts/hello?tab=items |
router.isReady | true once query has been filled in on the client |
router.isFallback | true while a fallback: true page is showing its fallback (see getStaticProps and getStaticPaths) |
router.push(url, as?, { shallow, scroll }) | client-side navigation; shallow: true changes the URL of the current page without re-running its data function |
router.replace, router.back, router.reload, router.prefetch | as their names say; prefetch works only in production |
router.events.on("routeChangeStart" | "routeChangeComplete" | "routeChangeError", …) | navigation events, often used in _app for progress bars and analytics |
By default a <Link> in the viewport is prefetched in production. The docs are specific about what that includes: for statically generated pages, the page's JavaScript and its JSON data; for getServerSideProps pages, the data is fetched only when the link is clicked. That matches the navigation above, where the post's JSON was requested once, after the click.
This app's pages have no data functions, except /blog, which has getStaticProps. Then someone adds getInitialProps to _app to read a theme cookie. What does next build print for /, /about and /blog?
import App, { type AppContext, type AppProps } from "next/app";
type Extra = { theme: string };
export default function MyApp({ Component, pageProps, theme }: AppProps & Extra) {
return (
<div data-theme={theme}>
<Component {...pageProps} />
</div>
);
}
MyApp.getInitialProps = async (context: AppContext) => {
const appProps = await App.getInitialProps(context);
const cookie = context.ctx.req?.headers.cookie ?? "";
return { ...appProps, theme: cookie.includes("theme=dark") ? "dark" : "light" };
};export default function Home() {
return <h1>Home</h1>;
}export default function About() {
return <h1>About us</h1>;
}export async function getStaticProps() {
return { props: { posts: ["First post", "Second post"] } };
}
export default function Blog({ posts }: { posts: string[] }) {
return <ul>{posts.map((p) => <li key={p}>{p}</li>)}</ul>;
}Show answer
/ and /about become ƒ (rendered per request); /blog stays ●.
Route (app)
─ ○ /_not-found
Route (pages)
┌ ƒ /
├ /_app
├ ƒ /about
└ ● /blog
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demandgetInitialPropson_appis a blocking data requirement for every page, because_apprenders around every page. Automatic Static Optimization only applies to pages with no blocking data requirement, so it switches off./and/aboutwere○without this change. Now Next.js must runMyApp.getInitialPropsfor each request (it readsctx.req, the incoming request), so they are rendered on demand:ƒ./bloghasgetStaticProps, so it is still generated at build time and stays●. The docs spell out this exception:getInitialPropsin_appdisables the optimisation "for pages without getStaticProps".App.getInitialProps(context)must be called and spread in, otherwise the page's own data (pageProps) is lost.- The lesson for code reviews: an innocent-looking
_app.getInitialProps(often added for auth or theming) makes a whole site server-rendered and costs you the CDN. Read the cookie on the client, or move that page togetServerSidePropsdeliberately.