1. Route Handlers Basics
In the App Router, HTTP APIs are defined via route.ts (or route.js) files placed under the app/api/ directory. Each route file can export async functions named after HTTP verbs, called Route Handlers (replacing pages/api routes from Pages Router).
- Supported:
GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS. - First parameter is the Web standard
Request; in Next.js, the enhancedNextRequestis commonly used for parsing URLs, cookies, etc. - Return value can be
Response,NextResponse, or the shorthandResponse.json().
GET: Return JSON
// app/api/hello/route.ts
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello from Route Handler", ok: true });
}
POST: Read request body
// app/api/echo/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ received: body }, { status: 201 });
}
NextRequest extends Request, additionally providing nextUrl, cookies, etc. NextResponse can construct JSON, redirects, and modify headers/cookies, integrating well with middleware.
2. Dynamic API Routes
Similar to page routes, use folder name [id] to define dynamic segments. For example, app/api/users/[id]/route.ts corresponds to path /api/users/:id.
In Next.js 15, the second parameter's params in Route Handlers is a Promise, requiring await before reading dynamic values:
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
type Ctx = { params: Promise<{ id: string }> };
// 内存示例(生产环境请换数据库)
const users = new Map<string, { id: string; name: string }>([
["1", { id: "1", name: "Ada" }],
]);
export async function GET(_request: NextRequest, context: Ctx) {
const { id } = await context.params;
const user = users.get(id);
if (!user) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(user);
}
export async function PUT(request: NextRequest, context: Ctx) {
const { id } = await context.params;
const body = await request.json();
const name = typeof body.name === "string" ? body.name : undefined;
if (!name) {
return NextResponse.json({ error: "name required" }, { status: 400 });
}
const updated = { id, name };
users.set(id, updated);
return NextResponse.json(updated);
}
export async function DELETE(_request: NextRequest, context: Ctx) {
const { id } = await context.params;
const existed = users.delete(id);
if (!existed) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return new NextResponse(null, { status: 204 });
}
The example above demonstrates querying by id (GET), full update (PUT), and deletion (DELETE). PATCH can follow the same pattern — read partial fields from request.json() and merge the update.
3. Request & Response Handling
URL Query Parameters
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q") ?? "";
const page = request.nextUrl.searchParams.get("page") ?? "1";
return NextResponse.json({ q, page });
}
Request Body
JSON uses await request.json(); forms can use request.formData(); raw text can use request.text().
Request Headers
export async function GET(request: NextRequest) {
const auth = request.headers.get("authorization");
const ua = request.headers.get("user-agent");
return NextResponse.json({ auth, ua });
}
Response Headers & Status Codes
import { NextResponse } from "next/server";
export async function GET() {
const res = NextResponse.json({ ok: true }, { status: 200 });
res.headers.set("X-Custom-Header", "demo");
return res;
}
Setting Cookies
import { NextResponse } from "next/server";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set("session", "abc123", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return res;
}
Read cookies with request.cookies.get("name") (NextRequest). Common status codes: 200 success, 201 created, 204 no content, 400 client error, 401 unauthorized, 404 not found, 500 server error.
4. Middleware
Create middleware.ts in the project root (if using src/, place it at src/middleware.ts). Middleware executes before matched requests reach pages or Route Handlers, and can be used for authentication, rewrites, logging, A/B testing, etc.
- Use
export const config = { matcher: [...] }to limit paths, avoiding unnecessary execution on static assets. - Return
NextResponse.next()to pass through; returnNextResponse.redirect()to redirect.
Auth: Check token, redirect if not logged in
// middleware.ts(与 app/ 同级,或在 src/ 下)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value;
const isAuthPage = request.nextUrl.pathname.startsWith("/login");
if (!token && !isAuthPage) {
const login = new URL("/login", request.url);
login.searchParams.set("from", request.nextUrl.pathname);
return NextResponse.redirect(login);
}
if (token && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/login"],
};
Logging Middleware
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const t = Date.now();
const res = NextResponse.next();
res.headers.set("X-Request-Id", crypto.randomUUID());
console.log(
`[mw] ${request.method} ${request.nextUrl.pathname} +${Date.now() - t}ms`,
);
return res;
}
export const config = {
matcher: ["/api/:path*"],
};
💡 Key Point
Middleware runs on the Edge Runtime by default: fast startup, geo-distribution friendly, but available Node APIs and npm packages are limited (e.g., you can't freely use Node-only native modules). When full Node runtime logic is needed, put heavy logic in Route Handlers, Server Actions, or separate backend services.
5. CORS Configuration
When browsers make cross-origin API requests, the server must return correct CORS headers. Route Handlers don't have Express's cors middleware, so you need to manually set response headers and handle OPTIONS preflight requests separately.
// lib/cors.ts
import { NextRequest, NextResponse } from "next/server";
const ALLOW_ORIGIN = process.env.CORS_ORIGIN ?? "*";
const ALLOW_METHODS = "GET, POST, PUT, PATCH, DELETE, OPTIONS";
const ALLOW_HEADERS = "Content-Type, Authorization";
export function withCors(res: NextResponse) {
res.headers.set("Access-Control-Allow-Origin", ALLOW_ORIGIN);
res.headers.set("Access-Control-Allow-Methods", ALLOW_METHODS);
res.headers.set("Access-Control-Allow-Headers", ALLOW_HEADERS);
return res;
}
export function jsonWithCors(data: unknown, init?: ResponseInit) {
return withCors(NextResponse.json(data, init));
}
export function handleCorsPreflight(_request: NextRequest) {
return withCors(new NextResponse(null, { status: 204 }));
}
// app/api/public/route.ts
import { NextRequest } from "next/server";
import { jsonWithCors, handleCorsPreflight } from "@/lib/cors";
export function OPTIONS(request: NextRequest) {
return handleCorsPreflight(request);
}
export async function GET(request: NextRequest) {
return jsonWithCors({ message: "cors ok" });
}
In production, set Access-Control-Allow-Origin to a specific frontend domain to avoid conflicts between wildcard * and credential-carrying scenarios (cookies cannot be used with *).
6. Error Handling & Validation
Use try/catch for async parsing and business logic, returning a unified JSON structure to clients, avoiding stack trace leaks in production.
Unified Error Response
import { NextResponse } from "next/server";
type ErrBody = { error: { code: string; message: string } };
export function jsonError(code: string, message: string, status: number) {
const body: ErrBody = { error: { code, message } };
return NextResponse.json(body, { status });
}
export async function POST(request: Request) {
try {
const data = await request.json();
return NextResponse.json({ data });
} catch {
return jsonError("BAD_JSON", "Invalid JSON body", 400);
}
}
Request Body Validation with Zod
Install: npm i zod
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const CreateUser = z.object({
email: z.string().email(),
name: z.string().min(1).max(64),
});
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: { code: "BAD_JSON", message: "Invalid JSON" } },
{ status: 400 },
);
}
const parsed = CreateUser.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: {
code: "VALIDATION_ERROR",
issues: parsed.error.flatten(),
},
},
{ status: 422 },
);
}
return NextResponse.json({ user: parsed.data }, { status: 201 });
}
7. Complete RESTful API Example
Below demonstrates a posts resource with listing (GET, with pagination), creation (POST), and per-id query, update, and deletion using in-memory storage. Shared storage is in lib/posts-store.ts, referenced by two Route Handlers; replace with a database layer in production.
lib/posts-store.ts
export type Post = {
id: string;
title: string;
body: string;
createdAt: string;
};
export const posts = new Map<string, Post>();
export function newPostId() {
return crypto.randomUUID();
}
app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { newPostId, posts, type Post } from "@/lib/posts-store";
export async function GET(request: NextRequest) {
const page = Math.max(1, Number(request.nextUrl.searchParams.get("page") ?? "1"));
const pageSize = Math.min(
50,
Math.max(1, Number(request.nextUrl.searchParams.get("pageSize") ?? "10")),
);
const all = Array.from(posts.values()).sort(
(a, b) => b.createdAt.localeCompare(a.createdAt),
);
const start = (page - 1) * pageSize;
const slice = all.slice(start, start + pageSize);
return NextResponse.json({
data: slice,
meta: { page, pageSize, total: all.length },
});
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const title = typeof body.title === "string" ? body.title.trim() : "";
const text = typeof body.body === "string" ? body.body : "";
if (!title) {
return NextResponse.json(
{ error: { code: "VALIDATION_ERROR", message: "title required" } },
{ status: 400 },
);
}
const id = newPostId();
const post: Post = {
id,
title,
body: text,
createdAt: new Date().toISOString(),
};
posts.set(id, post);
return NextResponse.json(post, { status: 201 });
} catch {
return NextResponse.json(
{ error: { code: "BAD_JSON", message: "Invalid JSON" } },
{ status: 400 },
);
}
}
app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { posts } from "@/lib/posts-store";
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, context: Ctx) {
const { id } = await context.params;
const post = posts.get(id);
if (!post) {
return NextResponse.json(
{ error: { code: "NOT_FOUND", message: "post not found" } },
{ status: 404 },
);
}
return NextResponse.json(post);
}
export async function PUT(request: NextRequest, context: Ctx) {
const { id } = await context.params;
const prev = posts.get(id);
if (!prev) {
return NextResponse.json(
{ error: { code: "NOT_FOUND", message: "post not found" } },
{ status: 404 },
);
}
try {
const body = await request.json();
const title =
typeof body.title === "string" ? body.title.trim() : prev.title;
const text = typeof body.body === "string" ? body.body : prev.body;
const next = { ...prev, title, body: text };
posts.set(id, next);
return NextResponse.json(next);
} catch {
return NextResponse.json(
{ error: { code: "BAD_JSON", message: "Invalid JSON" } },
{ status: 400 },
);
}
}
export async function DELETE(_request: NextRequest, context: Ctx) {
const { id } = await context.params;
if (!posts.has(id)) {
return NextResponse.json(
{ error: { code: "NOT_FOUND", message: "post not found" } },
{ status: 404 },
);
}
posts.delete(id);
return new NextResponse(null, { status: 204 });
}
Next.js Route Handlers vs Express.js
- Route definition: Next uses file paths to map HTTP paths; Express uses
app.get/post/...chained registration. - Deployment model: Route Handlers share the same repo and build with the frontend, deployable to serverless/edge platforms like Vercel; Express typically runs as a long-running Node process.
- Middleware: Next
middleware.tsis more global, Edge-based; Express middleware stack offers finer granularity, running on Node. - Use cases: Route Handlers work well for lightweight BFF, auth proxies, and webhooks; for heavy TCP/WebSocket customization or existing Express ecosystems, keep using Express or coexist.
8. Chapter Summary
Route Handlers
Export HTTP method functions in app/api/.../route.ts; use NextRequest / NextResponse to handle requests and responses.
Dynamic Routes
In Next.js 15, context.params is a Promise — always await before using.
Middleware
Root-level middleware.ts; matcher controls scope; be aware of Edge Runtime API limitations.
CORS
Manually set response headers and handle OPTIONS preflight; tighten Allow-Origin in production.
Errors & Validation
Wrap parsing logic with try/catch; unified error JSON; use Zod for request body validation.
REST Practices
Separate files for collections and resources; GET lists with pagination query params; creation returns 201; deletion can use 204.