← Back to Index

Chapter 6: API Routes

Route Handlers, Middleware & RESTful API

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).

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.

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.ts is 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.