Learn just enough of the Next.js Pages Router to read older projects, from pages/, _app and _document to getStaticProps, getServerSideProps and API routes.

Bishal Biswas
Self Learner
Two routers, one framework
Everything in this course uses the App Router: the app folder, Server Components, layouts, page.js files. But Next.js had a different router for its first six years, the Pages Router, built around a pages folder. It is still part of Next.js 16 and still supported, and a huge number of real projects, tutorials and Stack Overflow answers use it.
Sooner or later you will open a project, see a pages folder and functions called getServerSideProps, and wonder what is going on. This lesson gives you just enough to read that code, and to see how each idea maps to what you already know. Think of it as learning to read an older edition of a book: same story, different chapter names.
You will learn:
- how the
pagesfolder turns files into routes - what
_app.jsand_document.jsdo - the three data functions:
getStaticProps,getStaticPathsandgetServerSideProps - API routes in
pages/api - a translation table from Pages Router ideas to App Router ideas
How to tell which router a project uses
Look at the project root:
old-bookshop/
├── pages/ Pages Router
│ ├── _app.js
│ ├── _document.js
│ ├── index.js
│ └── api/
└── app/ App Router (may also be there, see below)
A project can have both folders. That is how teams move from one router to the other gradually, page by page. Each URL is served by one router only. If both folders claim the same path, the build stops:
Error: App Router and Pages Router both match path: /books/[id]
Next.js does not support having both App Router and Pages Router routes matching the same path. Please remove one of the conflicting routes.
Routing: every file is a page
In the Pages Router, the file itself is the route. There is no page.js inside folders:
| File | URL |
|---|---|
pages/index.js |
/ |
pages/about.js |
/about |
pages/books/index.js |
/books |
pages/books/[id].js |
/books/dune, /books/the-hobbit |
pages/api/books.js |
/api/books (an API route, not a page) |
Compare that with the App Router, where you write app/about/page.js and app/books/[id]/page.js. Dynamic segments use the same square brackets, as you learned in Dynamic Routes and params.
A page is just a React component exported as default:
// pages/about.js
export default function About() {
return <h1>About Book Nook</h1>;
}
Two big differences hide in that small file:
- There are no Server Components. Every page and every component in
pagesis shipped to the browser and hydrated, like a Client Component in the App Router. You never write'use client'there; it would mean nothing. - Components cannot be
async. You cannotawaitdata inside the component. Data comes from special functions instead (see below) and arrives as props.
Reading the URL: next/router
To read dynamic segments or query strings, Pages Router code uses useRouter from next/router:
// pages/books/[id].js
import { useRouter } from 'next/router';
export default function BookPage() {
const router = useRouter();
return <p>You are looking at {router.query.id}</p>;
}
router.query holds both the dynamic segment and the search params, mixed together. In the App Router, the matching tools are useRouter, usePathname, useSearchParams and useParams from next/navigation, and they keep params and search params separate.
Titles: next/head
There is no metadata export in the Pages Router. Pages set their <title> and <meta> tags with the Head component:
// pages/about.js
import Head from 'next/head';
export default function About() {
return (
<>
<Head>
<title>About | Book Nook</title>
<meta name="description" content="Who we are and why we love books." />
</Head>
<h1>About Book Nook</h1>
</>
);
}
In the App Router you use export const metadata or generateMetadata instead, from Page Titles, Descriptions and Metadata.
_app.js: the wrapper around every page
pages/_app.js is a component that wraps every page. Next.js passes it the current page as Component, and that page’s data as pageProps:
// pages/_app.js
import '@/styles/globals.css';
import Header from '@/components/Header';
export default function MyApp({ Component, pageProps }) {
return (
<>
<Header />
<Component {...pageProps} />
</>
);
}
- Global CSS is imported here, in old projects only here.
- Shared UI like a header lives here, because there are no nested layouts.
- Context providers (theme, cart) wrap
<Component />here.
When you navigate, Component changes to the new page, but MyApp stays mounted, so the header keeps its state. That is what the App Router’s root app/layout.js does for you now.
_document.js: the HTML shell
pages/_document.js controls the outer <html> and <body> tags. It only runs on the server:
// pages/_document.js
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
<Main />is where the app (_app.jsplus the page) is rendered.<NextScript />adds the JavaScript Next.js needs.- All four pieces are required. The
Headhere comes fromnext/documentand is not the same asnext/head: it is only for tags shared by every page. - No
onClickor state here: it never runs in the browser.
In the App Router, _app.js and _document.js are both replaced by one root layout that renders <html> and <body> itself (see Templates and the Root Layout in Depth).
Getting data, the Pages Router way
Since page components cannot fetch data themselves, a page file can export one special server function next to the component. Next.js runs it on the server and passes the result to the component as props. The function’s code is removed from the browser bundle, so it can talk to a database directly.
getStaticProps: data at build time
// pages/books/index.js
import Link from 'next/link';
import { getBooks } from '@/lib/books';
export async function getStaticProps() {
const books = await getBooks();
return {
props: { books },
revalidate: 3600, // optional: rebuild this page in the background at most once an hour
};
}
export default function BooksPage({ books }) {
return (
<ul>
{books.map((book) => (
<li key={book.id}>
<Link href={`/books/${book.id}`}>{book.title}</Link>
</li>
))}
</ul>
);
}
getBookshere stands for whatever data helper the old project has. (Book Nook’s ownlib/books.jsuses'use cache', which is an App Router feature.)getStaticPropsruns duringnext build. The page is saved as static HTML, like a prerendered page in the App Router.- It must return an object with
props(ornotFound: true, or aredirect). revalidateturns on ISR (Incremental Static Regeneration): after that many seconds, the next visit triggers a fresh rebuild in the background. In the App Router you get this with'use cache'andcacheLife, from Cache Lifetimes, Tags and Revalidation.- Everything in
propsis put into the HTML as JSON so the browser can hydrate the page. Never pass secrets through props.
getStaticPaths: which dynamic pages to build
A dynamic page like pages/books/[id].js that uses getStaticProps also needs getStaticPaths, to say which ids to build:
// pages/books/[id].js
import { getBook, getBooks } from '@/lib/books';
export async function getStaticPaths() {
const books = await getBooks();
return {
paths: books.map((book) => ({ params: { id: book.id } })),
fallback: 'blocking',
};
}
export async function getStaticProps({ params }) {
const book = await getBook(params.id);
if (!book) {
return { notFound: true };
}
return { props: { book }, revalidate: 3600 };
}
export default function BookPage({ book }) {
return (
<main>
<h1>{book.title}</h1>
<p>by {book.author}</p>
</main>
);
}
pathslists the pages to build ahead of time, each as{ params: { id } }.fallbackdecides what happens for ids not in the list:falsemeans 404,'blocking'means build it on the first visit, andtrueshows a loading version first.- Note that
paramshere is a plain object, not a Promise. Only the App Router made params async.
The App Router equivalent is generateStaticParams, which simply returns [{ id: 'dune' }, ...] (see Pre-rendering Dynamic Routes with generateStaticParams).
The build output of an older project shows these pages with a filled circle:
Route (pages) Revalidate Expire
└ /shop/[id] (321 ms) 1h 1y
└ ● /shop/dune (321 ms)
getServerSideProps: data on every request
When a page must be fresh for every visit, or depends on the visitor (cookies, headers), old code uses getServerSideProps:
// pages/cart.js
export async function getServerSideProps({ req, query }) {
const cartId = req.cookies.cartId ?? null;
const items = cartId ? await getCartItems(cartId) : [];
return {
props: { items, from: query.from ?? null },
};
}
export default function CartPage({ items }) {
return <p>You have {items.length} books in your cart.</p>;
}
- It runs on the server for every request, before the page renders.
- Its argument (the context) includes
params,query(search params),reqandres(the raw Node.js request and response), so it can read cookies and headers. - A page cannot export both
getServerSidePropsandgetStaticProps.
In the App Router, a component simply awaits cookies() from next/headers or searchParams, inside <Suspense>, as you did in Reading Cookies and Headers.
Good to know: You may also see
getInitialPropsin very old code (on pages or on_app.js). It is the oldest data method of all. The docs advise against it today; treat it likegetServerSidePropswhen reading.
API routes: pages/api
Files in pages/api are server-only endpoints. Each exports one default function that receives req and res:
// pages/api/books.js
import { getBooks } from '@/lib/books';
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const books = await getBooks();
res.status(200).json(books);
}
- One function handles every method. You check
req.methodyourself. req.query,req.cookiesandreq.bodyare parsed for you.- You answer with
res.status(...).json(...)instead of returning aResponse.
The App Router replacement is a Route Handler, app/api/books/route.js, with a separate exported function per method (export async function GET()) that returns Response.json(books). See Building APIs with Route Handlers.
The translation table
Keep this table nearby when you read an older project:
| Pages Router | App Router |
|---|---|
pages/about.js |
app/about/page.js |
pages/books/[id].js |
app/books/[id]/page.js |
pages/_app.js and pages/_document.js |
app/layout.js (root layout) |
Shared layout pattern in _app.js |
Nested layout.js files |
pages/404.js |
app/not-found.js |
pages/_error.js |
error.js files |
next/head |
metadata and generateMetadata |
useRouter from next/router |
useRouter, usePathname, useSearchParams, useParams from next/navigation |
getStaticProps |
An async Server Component (cached with 'use cache') |
getStaticProps with revalidate |
'use cache' with cacheLife |
getStaticPaths |
generateStaticParams |
getServerSideProps |
An async Server Component that reads request data inside <Suspense> |
pages/api/books.js |
app/api/books/route.js |
| Every component is hydrated | Server Components by default, 'use client' where needed |
Side by side: one book page, two routers
Pages Router:
// pages/books/[id].js
import Head from 'next/head';
import { getBook, getBooks } from '@/lib/books';
export async function getStaticPaths() {
const books = await getBooks();
return { paths: books.map((b) => ({ params: { id: b.id } })), fallback: 'blocking' };
}
export async function getStaticProps({ params }) {
const book = await getBook(params.id);
if (!book) return { notFound: true };
return { props: { book } };
}
export default function BookPage({ book }) {
return (
<>
<Head>
<title>{`${book.title} | Book Nook`}</title>
</Head>
<h1>{book.title}</h1>
</>
);
}
App Router (what you wrote in this course):
// app/books/[id]/page.js
import { notFound } from 'next/navigation';
import { getBook, getBooks } from '@/lib/books';
export async function generateStaticParams() {
const books = await getBooks();
return books.map((book) => ({ id: book.id }));
}
export async function generateMetadata({ params }) {
const { id } = await params;
const book = await getBook(id);
return { title: book?.title ?? 'Book not found' };
}
export default async function BookPage({ params }) {
const { id } = await params;
const book = await getBook(id);
if (!book) notFound();
return <h1>{book.title}</h1>;
}
The App Router version has no “props hand-off” step: the component fetches what it needs itself, and nothing about the data fetching is sent to the browser.
Should you convert an old project?
Not necessarily. The Pages Router works fine and is still maintained. If you do move, do it gradually: create an app folder next to pages, move one route at a time, and delete the old file as each new one goes live. Links between pages served by different routers do a full page load rather than a fast client-side navigation, which is fine during a migration. The official App Router migration guide covers every step.
Common mistakes
Using next/router in the app folder
Error: NextRouter was not mounted.
next/router only works in pages. In the app folder, import useRouter from next/navigation.
Exporting getServerSideProps from a component file
These functions only work in files inside pages. In components/BookCard.js they are just unused functions, and the component never gets its props. Fetch in the page and pass the data down.
Expecting params to be a Promise
In getStaticProps({ params }) and getServerSideProps({ params }), params is a plain object. params.id is correct there. Only App Router code needs await params.
Same route in both folders
Having pages/books/[id].js and app/books/[id]/page.js at the same time fails the build with “App Router and Pages Router both match path”. Delete the old file once the new one works.
Quick recap
- The Pages Router uses a
pagesfolder where every file is a route. It still works in Next.js 16 and can live next toapp. _app.jswraps every page (global CSS, shared header, providers) and_document.jsrenders the<html>shell. Both became the rootlayout.js.- Pages are hydrated in the browser and cannot be
async. Data arrives as props fromgetStaticProps(build time, optionalrevalidate),getStaticPaths(which dynamic pages to build) orgetServerSideProps(every request). paramsin these functions is a plain object, not a Promise.pages/api/*.jsexports onehandler(req, res)for all methods. The App Router usesroute.jswith one function per method.next/routerandnext/headbelong to the Pages Router. The App Router usesnext/navigationand the Metadata API.
// pages/books/[id].js: a Book Nook book page written for the Pages Router
import Head from 'next/head';
import { getBook, getBooks } from '@/lib/books';
// Which ids to build at "next build" (App Router: generateStaticParams)
export async function getStaticPaths() {
const books = await getBooks();
return {
paths: books.map((book) => ({ params: { id: book.id } })),
fallback: 'blocking', // unknown ids are built on their first visit
};
}
// Runs on the server only; the result becomes the page's props
export async function getStaticProps({ params }) {
const book = await getBook(params.id); // params is a plain object here
if (!book) {
return { notFound: true };
}
return { props: { book }, revalidate: 3600 }; // ISR: refresh at most hourly
}
// The page cannot be async: it just receives props
export default function BookPage({ book }) {
return (
<>
<Head>
<title>{`${book.title} | Book Nook`}</title>
</Head>
<h1>{book.title}</h1>
<p>by {book.author}</p>
</>
);
}
// --- next file: pages/api/books.js (App Router: app/api/books/route.js)
// export default async function handler(req, res) {
// if (req.method !== 'GET') return res.status(405).json({ error: 'Method not allowed' });
// res.status(200).json(await getBooks());
// }
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready