Pages Router (Legacy)
The App Router is the future, but thousands of working Next.js sites were built with the older Pages Router, and many companies still maintain them. If you join a team or freelance on an existing project, there is a good chance you will open a pages folder. You need to read this code confidently, fix bugs in it and, eventually, migrate it.
In this lesson you will learn how the Pages Router maps files to URLs, what _app and _document do, the three data-fetching functions, API routes, and the next/router hook. The next lesson shows how to move to the App Router.
Files in pages become routes
In the Pages Router, every file in the pages folder is a route, and its default export is the page component:
pages/
_app.js wraps every page
_document.js custom <html> and <body>
index.js /
about.js /about
courses/
index.js /courses
[slug].js /courses/react, /courses/html
docs/[...parts].js /docs/a, /docs/a/b
api/
enquiries.js /api/enquiries (API route)Unlike the App Router, any file here becomes public, so components cannot be kept inside pages. Projects usually have a separate components folder.
There are no Server Components in the Pages Router. Every page is rendered to HTML on the server (or at build time) and then fully hydrated in the browser, so all of its JavaScript is sent to the client. The Pages Router uses the React version from your package.json.
_app.js and _document.js
_app.js wraps every page. It is where older apps import global CSS, add shared layout and context providers:
// pages/_app.js
import "../styles/globals.css";
import Header from "../components/Header";
export default function MyApp({ Component, pageProps }) {
return (
<>
<Header />
<Component {...pageProps} />
</>
);
}_document.js customises the outer <html> and <body>, for example to set lang. It renders only on the server and uses special components (Html, Head, Main, NextScript) from next/document. In the App Router, both files are replaced by the root layout.
Data fetching: three special functions
A page can export one of these async functions. Next.js runs it on the server and passes the result to the page as props:
| Function | When it runs | Use for |
|---|---|---|
getStaticProps | At build time (and in the background when revalidate is set) | Content that is the same for everyone: blog posts, course pages |
getStaticPaths | At build time, with getStaticProps, for dynamic routes | Listing which [slug] pages to prebuild |
getServerSideProps | On every request | Data that depends on the request: cookies, search filters |
// pages/courses/[slug].js
export async function getStaticPaths() {
const courses = await getCourses();
return {
paths: courses.map((c) => ({ params: { slug: c.slug } })),
fallback: "blocking", // render unknown slugs on first request
};
}
export async function getStaticProps({ params }) {
const course = await getCourse(params.slug);
if (!course) return { notFound: true };
return { props: { course }, revalidate: 3600 }; // ISR: rebuild at most every hour
}
export default function CoursePage({ course }) {
return <h1>{course.title}: ₹{course.fee}</h1>;
}fallback can be false (unknown slugs give 404), true (show a loading version first) or "blocking" (wait for the page to be built). Returning { notFound: true } gives a 404 and { redirect: { destination: "/", permanent: false } } redirects. Note that params here is a plain object, not a Promise.
getServerSideProps receives a context with params, query (the query string), req and res:
// pages/search.js
export async function getServerSideProps({ query }) {
const q = query.q ?? "";
const results = await searchCourses(q);
return { props: { q, results } };
}
export default function SearchPage({ q, results }) {
return (
<>
<h1>Results for "{q}"</h1>
<ul>{results.map((r) => <li key={r.slug}>{r.title}</li>)}</ul>
</>
);
}The pattern is always "a server function returns props, the page renders them". This runnable example imitates that flow with a tiny fake framework, so you can watch the order of events:
import { createRoot } from "react-dom/client";
const db = [
{ slug: "html", title: "HTML Basics" },
{ slug: "react", title: "React Basics" },
{ slug: "nextjs", title: "Next.js Basics" },
];
// What a Pages Router file exports
async function getServerSideProps({ query }) {
console.log("1. getServerSideProps runs on the server with query", JSON.stringify(query));
const results = db.filter((c) => c.title.toLowerCase().includes((query.q ?? "").toLowerCase()));
return { props: { q: query.q ?? "", results } };
}
function SearchPage({ q, results }) {
console.log("2. SearchPage renders with", results.length, "results");
return (
<>
<h1>Results for "{q}"</h1>
<ul>{results.map((r) => <li key={r.slug}>{r.title}</li>)}</ul>
</>
);
}
// What Next.js does for each request
const { props } = await getServerSideProps({ query: { q: "basics" } });
createRoot(document.getElementById("root")).render(<SearchPage {...props} />);1. getServerSideProps runs on the server with query {"q":"basics"}
2. SearchPage renders with 3 resultsAPI routes
Files in pages/api are API endpoints. Each exports one handler that receives Node.js-style req and res objects:
// pages/api/enquiries.js
export default async function handler(req, res) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({ error: "Method not allowed" });
}
const { name, phone } = req.body;
if (!name || !phone) return res.status(400).json({ error: "name and phone required" });
await saveEnquiry({ name, phone });
res.status(201).json({ ok: true });
}In the App Router, the same job is done by a route.js file with one export per method (see Route Handlers), and forms usually use Server Actions instead.
next/router and next/head
The Pages Router's useRouter comes from next/router and includes the query string and dynamic params in router.query:
import { useRouter } from "next/router";
import Head from "next/head";
export default function Filters() {
const router = useRouter();
const level = router.query.level ?? "all";
return (
<>
<Head><title>Courses: {level}</title></Head>
<button onClick={() => router.push({ pathname: "/courses", query: { level: "beginner" } })}>
Beginner
</button>
</>
);
}Page titles and meta tags are set with the <Head> component from next/head inside each page. <Link> from next/link works in both routers.
Common mistakes
- Mixing APIs.
getServerSidePropsdoes nothing in theappfolder, andnext/navigationhooks do not belong inpages. Check which folder you are in. - Putting helper components inside
pages. Every file there becomes a URL. Keep components outside. - Returning non-serializable props. Props from
getStaticPropsandgetServerSidePropsmust be JSON-serializable; convertDateobjects to strings. - Using
getServerSidePropsfor data that rarely changes. It runs on every request;getStaticPropswithrevalidateis much cheaper.
Key points
- In the Pages Router, every file in
pagesis a route;_app.jswraps pages and_document.jscontrols<html>and<body>. - There are no Server Components; whole pages are hydrated in the browser.
getStaticProps(with optionalrevalidate),getStaticPathsandgetServerSidePropsload data and return props.- API routes in
pages/apiexport onehandler(req, res). useRoutercomes fromnext/router, and<Head>fromnext/headsets metadata.
Exercise
Read this Pages Router page and answer: when does the data load, what happens for a slug not returned by getStaticPaths, and how often can the page update? Then write the URL that shows the second course.
export async function getStaticPaths() {
return { paths: [{ params: { id: "101" } }, { params: { id: "102" } }], fallback: false };
}
export async function getStaticProps({ params }) {
const res = await fetch(`https://api.example.com/batches/${params.id}`);
return { props: { batch: await res.json() }, revalidate: 600 };
}
export default function Batch({ batch }) { return <h1>{batch.name}</h1>; }Show solution
File: pages/batches/[id].js (assumed)
Data loads: at build time for ids 101 and 102, then again in the background at most every 600 seconds (10 minutes) when someone visits.
Unknown id (for example 103): fallback is false, so the visitor gets a 404 page.
Second course URL: /batches/102revalidate: 600 turns on Incremental Static Regeneration. Because fallback is false, only the two listed ids can ever be shown until the site is rebuilt.
Quick Quiz
1. Which Pages Router function runs on every request?
getServerSideProps runs per request; the other two run at build time (and during revalidation).
2. What replaces _app.js and _document.js in the App Router?
The root layout renders <html> and <body> and wraps all pages.
3. In getStaticProps, what does returning revalidate: 60 do?
It enables Incremental Static Regeneration for that page.
4. Where does the Pages Router's useRouter come from?
next/router is the Pages Router hook; the App Router uses next/navigation.
Learn Next.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
