AI Reading
Quick summary of this article
Build REST endpoints with Next.js 16 Route Handlers: GET, POST, PUT, DELETE, dynamic segments, async cookies and headers, consistent error shapes, webhooks and server-side API calls.
- A
route.tsfile exports HTTP methods (GET,POST, …) and replaces the Pages Router's API routes. cookies()andheaders()are async in Next.js 16 and must be awaited.- Those APIs live in
next/headers— a Route Handler can read them, but a cacheduse cachefunction cannot. - Wrap outbound API calls in one module with timeouts and typed responses, and never expose API keys through
NEXT_PUBLIC_variables.
Complete Next.js 16 Series — Module 8 of 12
Server Actions cover form mutations. Route Handlers cover everything else: mobile clients, webhooks, third-party callbacks, streaming endpoints and public APIs.
Anatomy of a Route Handler
// src/app/api/health/route.ts
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ status: "ok", time: new Date().toISOString() });
}
src/app/api/health/route.ts serves GET /api/health. You cannot have both route.ts and page.tsx in the same folder — that is a build error.
Supported exports: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
A Full CRUD Resource
// src/app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const createSchema = z.object({
name: z.string().min(2).max(120),
price: z.number().positive(),
});
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const take = Math.min(Number(searchParams.get("take") ?? 20), 100);
const cursor = searchParams.get("cursor") ?? undefined;
const products = await db.product.findMany({ take, cursor: cursor ? { id: cursor } : undefined });
return NextResponse.json({ data: products });
}
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: z.flattenError(parsed.error).fieldErrors },
{ status: 422 }
);
}
const product = await db.product.create({ data: parsed.data });
return NextResponse.json({ data: product }, { status: 201 });
}
And the dynamic segment version:
// src/app/api/products/[id]/route.ts
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params; // async in Next.js 16
const product = await db.product.findUnique({ where: { id } });
if (!product) {
return NextResponse.json({ error: "Product not found" }, { status: 404 });
}
return NextResponse.json({ data: product });
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.product.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
Reading Cookies and Headers
Both are async in Next.js 16:
import { cookies, headers } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const session = cookieStore.get("session")?.value;
const headerStore = await headers();
const userAgent = headerStore.get("user-agent") ?? "unknown";
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({ session, userAgent });
}
Setting a cookie in a Route Handler or Server Action:
const store = await cookies();
store.set("theme", "dark", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 365,
});
Calling
cookies()opts the route into dynamic rendering. Inside a function markeduse cacheit throws — cached functions may not read per-request data.
Consistent Error Responses
Pick one envelope and use it everywhere. Clients — and your own future self — will thank you.
// src/lib/api.ts
export type ApiError = {
error: string;
code?: string;
details?: unknown;
};
export function fail(message: string, status = 400, code?: string, details?: unknown) {
return Response.json({ error: message, code, details } satisfies ApiError, { status });
}
export function ok<T>(data: T, status = 200) {
return Response.json({ data }, { status });
}
Then a handler reads cleanly:
if (!session) return fail("Unauthorized", 401, "AUTH_REQUIRED");
if (!product) return fail("Not found", 404, "NOT_FOUND");
return ok(product);
Calling a Third-Party API (Server Side)
Never call a key-bearing API from a Client Component. Wrap it in a module that lives on the server:
// src/lib/weather.ts
import "server-only";
const KEY = process.env.WEATHER_API_KEY;
if (!KEY) throw new Error("WEATHER_API_KEY is not set");
export type Weather = { tempC: number; condition: string };
export async function getWeather(city: string): Promise<Weather | null> {
const res = await fetch(
`https://api.example.com/weather?city=${encodeURIComponent(city)}&key=${KEY}`,
{
next: { revalidate: 600, tags: [`weather-${city}`] },
signal: AbortSignal.timeout(8000),
}
);
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Weather API failed: ${res.status}`);
}
const json = (await res.json()) as { temp_c: number; condition: string };
return { tempC: json.temp_c, condition: json.condition };
}
import "server-only" makes the build fail loudly if this file is ever imported into a Client Component. That is a cheap guard with enormous value.
Proxying a Client Request Through a Route Handler
Sometimes the browser must call your server, and your server calls the API — to hide a key, add CORS headers, or normalise the response:
// src/app/api/weather/[city]/route.ts
import { getWeather } from "@/lib/weather";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ city: string }> }
) {
const { city } = await params;
const weather = await getWeather(city);
if (!weather) return Response.json({ error: "City not found" }, { status: 404 });
return Response.json(weather, {
headers: { "Cache-Control": "public, s-maxage=600, stale-while-revalidate=60" },
});
}
Webhooks
Webhook endpoints must be dynamic, must verify signatures, and must return quickly:
// src/app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import crypto from "node:crypto";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const signature = (await headers()).get("x-signature");
const raw = await request.text();
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(raw)
.digest("hex");
const valid =
signature &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return new Response("Invalid signature", { status: 401 });
const event = JSON.parse(raw) as { type: string; data: unknown };
switch (event.type) {
case "order.paid":
await handleOrderPaid(event.data);
break;
default:
break;
}
return new Response(null, { status: 200 });
}
Two non-negotiables: read the raw body (not request.json()) so the signature matches, and use timingSafeEqual rather than === to prevent timing attacks.
Streaming a Response
Route Handlers can stream, which suits AI endpoints and large exports:
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (const chunk of ["Hello", " from", " Next.js", " 16"]) {
controller.enqueue(encoder.encode(chunk));
await new Promise((r) => setTimeout(r, 300));
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
Caching a Route Handler
Route Handlers are dynamic when they read the request; otherwise they can be cached:
export const revalidate = 60; // ISR-style caching
export const dynamic = "force-static"; // never dynamic
For a JSON API consumed by your own app, revalidate plus Cache-Control headers is usually enough.
Module 8 Recap
route.tsexports HTTP method functions and replaces Pages Router API routes.params,cookies()andheaders()are async — always await.- Standardise on one response envelope with a typed
fail()/ok()helper. - Keep API keys server-side with
import "server-only"and non-NEXT_PUBLIC_env vars. - Verify webhook signatures over the raw body using
timingSafeEqual. - Return
204for deletes and correct status codes everywhere.
Next up — Module 9: a real database layer with Prisma 7 and PostgreSQL.
Continue the Course
- Course index: Next.js 16 Tutorial – all 12 modules
- Previous: Module 7: Forms & Server Actions
- Next: Module 9: Prisma 7 & PostgreSQL
