Overview
Next.js 14/15 App Router API routes use the Route Handler pattern (route.ts files). Middleware runs before routes for auth, rate limiting, and redirects. Edge Runtime for latency-sensitive paths.
Route Handler Structure
// src/app/api/projects/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const CreateProjectSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
});
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") ?? "active";
try {
const projects = await db.from("projects").select().eq("status", status);
return NextResponse.json({ projects });
} catch (error) {
console.error("[GET /api/projects]", error);
return NextResponse.json(
{ error: "Failed to fetch projects" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = CreateProjectSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 422 }
);
}
const project = await db.from("projects").insert(parsed.data).select().single();
return NextResponse.json({ project }, { status: 201 });
}
Dynamic Route Segments
// src/app/api/projects/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const project = await db.from("projects").select().eq("id", id).single();
if (!project) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ project });
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
// ... update logic
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.from("projects").delete().eq("id", id);
return new NextResponse(null, { status: 204 });
}
Middleware
// middleware.ts (project root)
import { NextRequest, NextResponse } from "next/server";
export const config = {
matcher: ["/api/:path*", "/dashboard/:path*"],
};
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Auth check
const token = request.cookies.get("session-token")?.value;
if (pathname.startsWith("/dashboard") && !token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// CORS for API routes
if (pathname.startsWith("/api/")) {
const response = NextResponse.next();
const origin = request.headers.get("origin") ?? "";
const allowed = ["https://yourdomain.com", "http://localhost:3000"];
if (allowed.includes(origin)) {
response.headers.set("Access-Control-Allow-Origin", origin);
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
// Handle preflight
if (request.method === "OPTIONS") {
return new NextResponse(null, { status: 204, headers: response.headers });
}
return response;
}
return NextResponse.next();
}
Rate Limiting (Edge-Compatible)
// lib/rate-limit.ts
import { NextRequest, NextResponse } from "next/server";
const requestCounts = new Map<string, { count: number; reset: number }>();
export function rateLimit(
request: NextRequest,
limit = 100,
windowMs = 60_000
): NextResponse | null {
const ip = request.ip ?? request.headers.get("x-forwarded-for") ?? "unknown";
const now = Date.now();
const entry = requestCounts.get(ip);
if (!entry || now > entry.reset) {
requestCounts.set(ip, { count: 1, reset: now + windowMs });
return null;
}
if (entry.count >= limit) {
return NextResponse.json(
{ error: "Rate limit exceeded" },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil((entry.reset - now) / 1000)),
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": "0",
},
}
);
}
entry.count++;
return null;
}
Edge Runtime
// src/app/api/geo/route.ts
export const runtime = "edge"; // runs in V8 isolates globally
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const country = request.geo?.country ?? "US";
const city = request.geo?.city ?? "Unknown";
return NextResponse.json({ country, city });
}
Edge restrictions: no Node.js APIs (no fs, crypto, path). Use Web APIs only.
Streaming Response
export const runtime = "edge";
export async function POST(request: NextRequest) {
const { prompt } = await request.json();
const stream = new TransformStream();
const writer = stream.writable.getWriter();
const encoder = new TextEncoder();
// Start async work
(async () => {
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": process.env.ANTHROPIC_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "claude-opus-4-6", max_tokens: 2048, stream: true }),
});
const reader = response.body!.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writer.write(value);
}
await writer.close();
})();
return new Response(stream.readable, {
headers: { "Content-Type": "text/event-stream" },
});
}
Auth Helper Pattern
// lib/auth.ts
import { cookies } from "next/headers";
export async function getAuthenticatedUser() {
const cookieStore = await cookies();
const token = cookieStore.get("session-token")?.value;
if (!token) return null;
// verify token...
return user;
}
// In route handler
export async function GET() {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// ...
}
Error Response Conventions
// Consistent error shape across all routes
function errorResponse(message: string, status: number, details?: unknown) {
return NextResponse.json(
{ error: message, ...(details ? { details } : {}) },
{ status }
);
}
// Use in routes
return errorResponse("Not found", 404);
return errorResponse("Validation failed", 422, parsed.error.flatten());
return errorResponse("Internal server error", 500);
Clerk Auth Integration
import { auth } from "@clerk/nextjs/server";
export async function GET() {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// userId is the Clerk user ID — use as foreign key in DB
}
Note: @clerk/nextjs middleware auto-blocks API routes. Remove if routes don't need auth.