3. Route Handlers & API Routes
Next.js can be your backend. In the App Router these are Route Handlers; in the Pages Router they were API Routes.
1. Route Handlers (App Router)
A route.ts file exports functions named after HTTP methods.
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const users = await db.user.findMany();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const user = await db.user.create({ data: body });
return NextResponse.json(user, { status: 201 });
}Supported exports: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
Rule
route.ts and page.tsx cannot live in the same folder. Both claim the same URL.
2. Dynamic Routes and Params
// app/api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params; // async in Next 15+
const user = await db.user.findUnique({ where: { id } });
if (!user) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(user);
}3. Reading the Request
export async function GET(request: NextRequest) {
// Query string
const { searchParams } = new URL(request.url);
const page = Number(searchParams.get("page") ?? 1);
// Headers
const auth = request.headers.get("authorization");
// Cookies
const token = request.cookies.get("token")?.value;
// JSON body (POST/PUT)
// const body = await request.json();
// Form body
// const formData = await request.formData();
}4. Responses
// JSON
return NextResponse.json({ ok: true });
// Status code
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
// Custom headers
return NextResponse.json(data, {
headers: { "Cache-Control": "s-maxage=60, stale-while-revalidate=300" },
});
// Redirect
return NextResponse.redirect(new URL("/login", request.url));
// Set a cookie
const res = NextResponse.json({ ok: true });
res.cookies.set("token", jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
path: "/",
});
return res;
// Plain text / stream
return new Response("Hello", { headers: { "Content-Type": "text/plain" } });Security Note
Auth cookies must be httpOnly (JavaScript cannot read them — blocks XSS token theft), secure in production, and sameSite: "lax" or "strict" (blocks CSRF). Storing a JWT in localStorage is the classic interview trap answer — any XSS on your site steals it.
5. Validation
Never trust the request body.
import { z } from "zod";
const createUser = z.object({
email: z.string().email(),
name: z.string().min(2).max(50),
age: z.number().int().positive().optional(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = createUser.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", issues: parsed.error.flatten() },
{ status: 400 }
);
}
const user = await db.user.create({ data: parsed.data });
return NextResponse.json(user, { status: 201 });
}Zod also gives you the TypeScript type for free: type CreateUser = z.infer<typeof createUser>.
6. Error Handling Pattern
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const result = await createOrder(session.userId, body);
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error("[POST /api/orders]", error);
// Never leak internals to the client
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}Log the real error server-side; return a generic message to the client. Stack traces in an API response are an information-disclosure bug.
7. Caching Behaviour
Next.js 15 and Earlier
GET handlers were cached by default. You opted out:
export const dynamic = "force-dynamic";
export const revalidate = 60;Next.js 16
Route Handlers follow the same opt-in model as pages. Nothing is cached unless you say so, and with Cache Components enabled, GET handlers follow the same prerendering model as pages.
export async function GET() {
"use cache";
cacheLife("hours");
const data = await getExpensiveData();
return NextResponse.json(data);
}Interview Point
If someone asks "why is my API returning stale data" on Next 14/15, the answer is almost always the default GET cache. On Next 16 that class of bug is gone by design.
8. Runtime Options
export const runtime = "nodejs"; // default — full Node API, DB drivers
export const runtime = "edge"; // V8 isolate, faster cold start, limited APIs| Node.js runtime | Edge runtime | |
|---|---|---|
| Cold start | Slower | Very fast |
Node APIs (fs, crypto) | Full | Limited Web APIs only |
| Database drivers | All | Only HTTP-based (Neon, PlanetScale, Upstash) |
| Location | Region | Distributed globally |
| Best for | Real business logic, ORM queries | Auth checks, geo, redirects, simple JSON |
Note that proxy.ts (the Next 16 replacement for middleware) runs on the Node.js runtime.
9. CORS
export async function GET() {
return NextResponse.json(data, {
headers: {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}Access-Control-Allow-Origin: * plus credentials is invalid and rejected by browsers. Whitelist explicit origins for anything authenticated.
10. Webhooks
Two things interviewers look for: raw body and signature verification.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
export async function POST(request: NextRequest) {
const body = await request.text(); // raw text, NOT .json()
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed":
await fulfilOrder(event.data.object);
break;
}
return NextResponse.json({ received: true });
}Parsing to JSON first breaks signature verification, because the signature is computed over the exact raw bytes.
Also make the handler idempotent — providers retry, so the same event can arrive twice.
11. Streaming a Response
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of generateTokens()) {
controller.enqueue(encoder.encode(`data: ${chunk}\n\n`));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}The standard pattern for LLM token streaming and live progress updates.
12. Route Handlers vs Server Actions
Both let you run server code. Interviewers ask when to use which.
| Use a Server Action | Use a Route Handler |
|---|---|
| Form submission from your own app | A public REST API |
| Mutation tied to a UI component | A mobile app or third-party consumer |
| You want progressive enhancement | Webhooks |
You want revalidatePath right there | You need custom headers, status codes, streaming |
| Internal to this Next.js app | Anything called from outside |
One-Line Answer
"Server Actions for mutations inside my own app; Route Handlers when something outside the app needs to call it."
13. Pages Router API Routes (Legacy)
Still worth recognising, since plenty of production code runs on it.
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id } = req.query;
switch (req.method) {
case "GET": {
const user = await db.user.findUnique({ where: { id: String(id) } });
return res.status(200).json(user);
}
case "DELETE":
await db.user.delete({ where: { id: String(id) } });
return res.status(204).end();
default:
res.setHeader("Allow", ["GET", "DELETE"]);
return res.status(405).end(`Method ${req.method} not allowed`);
}
}Key Differences
| Pages API Routes | App Route Handlers | |
|---|---|---|
| File | pages/api/*.ts | app/**/route.ts |
| Signature | One handler(req, res) | One export per HTTP method |
| Objects | Node req / res | Web Request / Response |
| Method routing | Manual switch | Automatic |
| Body parsing | Automatic | Manual await request.json() |
14. Environment Variables
# .env.local
DATABASE_URL="postgres://…" # server only
STRIPE_SECRET_KEY="sk_live_…" # server only
NEXT_PUBLIC_API_URL="https://api.example.com" # exposed to the browserThe Rule
Only variables prefixed NEXT_PUBLIC_ are sent to the browser. They are inlined into the JavaScript bundle at build time and are permanently public.
// Server Component / Route Handler — safe
const key = process.env.STRIPE_SECRET_KEY;
// Client Component — undefined without the prefix, and NEVER put a secret here
const url = process.env.NEXT_PUBLIC_API_URL;Security Note
Putting an API key behind NEXT_PUBLIC_ does not protect it — it is in the shipped bundle, readable by anyone with DevTools. This is one of the most common real-world Next.js security mistakes, and a favourite interview question.
Note also that serverRuntimeConfig and publicRuntimeConfig were removed in Next.js 16 — use .env files.