Lesson 53 of 81
Both pages/api and app's Route Handlers serve JSON endpoints, but almost nothing about how you write one carries over unchanged — file location, function signature, request parsing, and response shape all move at once.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
The Pages Router's API routes and the App Router's Route Handlers solve the same problem — "give this URL a real HTTP endpoint" — with unrelated APIs. Moving one file at a time from pages/api/*.ts to app/api/*/route.ts is a mechanical migration, but every piece of the mechanics changes together, so it's worth having the mapping in one place rather than relearning it file by file.
| | pages/api/*.ts | app/api/*/route.ts |
|---|---|---|
| File location | pages/api/posts.ts → /api/posts | app/api/posts/route.ts → /api/posts |
| Export shape | one default handler(req, res) | one named async function per method: GET, POST, ... |
| Request object | NextApiRequest (Node IncomingMessage + helpers) | NextRequest (extends the Web-standard Request) |
| Response object | NextApiResponse, mutated via method calls | a returned Response / NextResponse |
| Reading the body | req.body (pre-parsed for JSON/form bodies) | await request.json() / .formData() / .text() |
| Query params | req.query.slug | request.nextUrl.searchParams.get("slug") |
| Dynamic segments | pages/api/posts/[id].ts, req.query.id | app/api/posts/[id]/route.ts, await params in the function signature |
| Sending JSON | res.status(200).json(data) | Response.json(data, { status: 200 }) |
| Setting headers | res.setHeader("x-foo", "bar") | new Response(body, { headers: { "x-foo": "bar" } }) |
| Runtime opt-in | export const config = { runtime: "edge" } | export const runtime = "edge" |
// pages/api/posts.ts — Pages Router
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET") {
res.status(405).json({ error: "Method not allowed" });
return;
}
const page = Number(req.query.page ?? "1");
const posts = await db.post.findMany({ skip: (page - 1) * 10, take: 10 });
res.status(200).json({ posts, page });
}
// app/api/posts/route.ts — App Router
import type { NextRequest } from "next/server";
import { db } from "@/lib/db";
export async function GET(request: NextRequest) {
const page = Number(request.nextUrl.searchParams.get("page") ?? "1");
const posts = await db.post.findMany({ skip: (page - 1) * 10, take: 10 });
return Response.json({ posts, page });
}
Two changes disappear entirely, not just get rewritten: the req.method !== "GET" branch is gone because a Route Handler only runs its GET export for GET requests — there's no shared handler fielding every verb, so there's nothing left to branch on. And req.query.page — a plain string keyed off Node's parsed query object — becomes request.nextUrl.searchParams.get("page"), the standard URLSearchParams API, because NextRequest is built on the Web Request, not a Node IncomingMessage.
For a dynamic route, the shift is similar but touches how params arrive:
// pages/api/posts/[id].ts
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query; // string | string[] | undefined
const post = await db.post.findUnique({ where: { id: id as string } });
res.status(200).json(post);
}
// app/api/posts/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; // Promise, same as a page's dynamic segment
const post = await db.post.findUnique({ where: { id } });
return Response.json(post);
}
req.query.id in the Pages Router is typed loosely (string | string[] | undefined, because a catch-all segment can produce an array) and available synchronously. A Route Handler's params is always a Promise you await — the same async-params model the rest of the App Router uses for pages — and it's typed precisely to the segment's shape.
handler catching every HTTP method anymore. A GET export only ever receives GET requests; unhandled methods automatically get a 405 from the framework, which replaces the if (req.method !== ...) guard entirely rather than needing an equivalent.req.body was pre-parsed; request.json() is not, and it's async. Forgetting the await on request.json() is the single most common bug in this migration — you get a Promise object instead of the parsed body, and TypeScript won't always catch it if the surrounding code is loosely typed.Promise in a Route Handler, exactly like in a page. This isn't specific to Route Handlers — it's the same async-params behavior page.tsx also uses — but it's easy to forget when your muscle memory is pages/api's synchronous req.query.OPTIONS and CORS preflight are handled for you automatically based on whichever methods you've exported, so hand-rolled preflight logic from a Pages Router API route usually becomes dead code, not a straight port.route.ts and a page.tsx can't share a segment. If the old pages/api/posts.ts migrates to app/api/posts/route.ts while an unrelated app/posts/page.tsx already exists, that's fine — they're different segments — but two files can't both claim app/posts/route.ts and app/posts/page.tsx for the same path.The Route Handlers fundamentals lesson introduces the route.ts shape fresh — a file exporting GET/POST functions that work with the standard Request/Response — and contrasts it briefly with the older API routes to explain why the API looks the way it does. This lesson assumes that shape is already familiar and works the other direction: starting from working pages/api code and walking through what specifically has to change, field by field, to land on the App Router equivalent — the kind of task that shows up as a real migration ticket, not a from-scratch endpoint.