|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/api-routes
$cat docs/next.js-api-routes-&-route-handlers.md
updated Recentlyยท40 min readยทpublished

Next.js API Routes & Route Handlers

โ—†Next.jsโ—†APIโ—†Route Handlersโ—†Intermediate to Advanced๐ŸŽฏFree Tools
What are Route Handlers?

Route Handlers let you create API endpoints inside the App Router. Instead of thepages/api/ directory used in the Pages Router, Route Handlers live alongside your pages in the app/ directory. They use a route.ts file and export named functions for each HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS).

Route Handlers run on the server by default. They can be deployed as serverless functions or to the edge runtime. Unlike Server Components, they return raw responses (JSON, streams, files) rather than rendered UI.

โ„น

info

Use Route Handlers when you need an API endpoint โ€” webhooks, authentication, file uploads, or serving data to external clients. For data fetching within your app, prefer Server Components directly.
Basic Setup

Create a route.ts file (not page.tsx) in any app/ subdirectory. The file exports named async functions matching HTTP methods.

app/api/users/route.ts
TSX
1// app/api/users/route.ts
2import { NextRequest, NextResponse } from "next/server";
3import { db } from "@/lib/database";
4
5// GET /api/users
6export async function GET(request: NextRequest) {
7 const users = await db.user.findMany();
8 return NextResponse.json(users);
9}
10
11// POST /api/users
12export async function POST(request: NextRequest) {
13 const body = await request.json();
14
15 // Validate input
16 if (!body.name || !body.email) {
17 return NextResponse.json(
18 { error: "Name and email are required" },
19 { status: 400 }
20 );
21 }
22
23 const user = await db.user.create({
24 data: { name: body.name, email: body.email },
25 });
26
27 return NextResponse.json(user, { status: 201 });
28}
app/api/users/[id]/route.ts
TSX
1// app/api/users/[id]/route.ts
2import { NextRequest, NextResponse } from "next/server";
3import { db } from "@/lib/database";
4
5// GET /api/users/123
6export async function GET(
7 request: NextRequest,
8 { params }: { params: Promise<{ id: string }> }
9) {
10 const { id } = await params;
11 const user = await db.user.findUnique({ where: { id } });
12
13 if (!user) {
14 return NextResponse.json(
15 { error: "User not found" },
16 { status: 404 }
17 );
18 }
19
20 return NextResponse.json(user);
21}
22
23// PUT /api/users/123
24export async function PUT(
25 request: NextRequest,
26 { params }: { params: Promise<{ id: string }> }
27) {
28 const { id } = await params;
29 const body = await request.json();
30
31 const user = await db.user.update({
32 where: { id },
33 data: body,
34 });
35
36 return NextResponse.json(user);
37}
38
39// DELETE /api/users/123
40export async function DELETE(
41 request: NextRequest,
42 { params }: { params: Promise<{ id: string }> }
43) {
44 const { id } = await params;
45 await db.user.delete({ where: { id } });
46 return new NextResponse(null, { status: 204 });
47}
Request & Response Helpers

Next.js extends the standard Web Request and Response APIs with helper methods for common patterns.

app/api/search/route.ts
TSX
1// app/api/search/route.ts
2import { NextRequest, NextResponse } from "next/server";
3
4export async function GET(request: NextRequest) {
5 // URL search params
6 const searchParams = request.nextUrl.searchParams;
7 const query = searchParams.get("q");
8 const page = parseInt(searchParams.get("page") ?? "1");
9 const limit = parseInt(searchParams.get("limit") ?? "10");
10
11 // Headers
12 const authHeader = request.headers.get("authorization");
13 const userAgent = request.headers.get("user-agent");
14
15 // Cookies
16 const session = request.cookies.get("session")?.value;
17
18 // Query database
19 const results = await searchProducts(query, { page, limit });
20
21 // Response with headers
22 return NextResponse.json(results, {
23 status: 200,
24 headers: {
25 "X-Total-Count": results.total.toString(),
26 "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
27 },
28 });
29}
response-helpers.tsx
TSX
1// Response helpers
2
3import { NextResponse } from "next/server";
4
5// JSON response
6return NextResponse.json({ message: "Success" });
7return NextResponse.json({ error: "Not found" }, { status: 404 });
8
9// Plain text response
10return new NextResponse("Hello, World!", {
11 headers: { "Content-Type": "text/plain" },
12});
13
14// Redirect
15return NextResponse.redirect(new URL("/login", request.url));
16return NextResponse.redirect(new URL("/login", request.url), 301);
17
18// Set cookies
19const response = NextResponse.json({ success: true });
20response.cookies.set("session", "abc123", {
21 httpOnly: true,
22 secure: true,
23 sameSite: "lax",
24 maxAge: 60 * 60 * 24 * 7, // 7 days
25 path: "/",
26});
27response.cookies.set("theme", "dark");
28return response;
29
30// Delete cookies
31const response = NextResponse.json({ success: true });
32response.cookies.delete("session");
33return response;
Query Parameters & Dynamic Segments
app/api/products/route.ts
TSX
1// Combining route params with search params
2// URL: /api/products?category=electronics&sort=price
3
4// app/api/products/route.ts
5import { NextRequest, NextResponse } from "next/server";
6
7export async function GET(request: NextRequest) {
8 const searchParams = request.nextUrl.searchParams;
9
10 const category = searchParams.get("category");
11 const sort = searchParams.get("sort") ?? "createdAt";
12 const order = searchParams.get("order") ?? "desc";
13 const minPrice = searchParams.get("minPrice");
14 const maxPrice = searchParams.get("maxPrice");
15
16 // Build filter dynamically
17 const where: Record<string, unknown> = {};
18 if (category) where.category = category;
19 if (minPrice || maxPrice) {
20 where.price = {
21 ...(minPrice && { gte: parseFloat(minPrice) }),
22 ...(maxPrice && { lte: parseFloat(maxPrice) }),
23 };
24 }
25
26 const products = await db.product.findMany({
27 where,
28 orderBy: { [sort]: order },
29 });
30
31 return NextResponse.json({
32 data: products,
33 total: products.length,
34 filters: { category, sort, order, minPrice, maxPrice },
35 });
36}
Authentication Patterns

Route Handlers are the right place to handle authentication for API endpoints. Here are common patterns for JWT verification, session management, and API key authentication.

lib/auth.ts
TSX
1// lib/auth.ts โ€” Shared auth utilities
2import { verify } from "jsonwebtoken";
3
4const JWT_SECRET = process.env.JWT_SECRET!;
5
6export interface AuthUser {
7 id: string;
8 email: string;
9 role: "admin" | "user";
10}
11
12export function verifyToken(token: string): AuthUser | null {
13 try {
14 return verify(token, JWT_SECRET) as AuthUser;
15 } catch {
16 return null;
17 }
18}
19
20export function extractToken(request: Request): string | null {
21 const authHeader = request.headers.get("authorization");
22 if (!authHeader?.startsWith("Bearer ")) return null;
23 return authHeader.slice(7);
24}
app/api/admin/route.ts
TSX
1// app/api/admin/route.ts
2import { NextRequest, NextResponse } from "next/server";
3import { verifyToken, extractToken } from "@/lib/auth";
4
5export async function GET(request: NextRequest) {
6 const token = extractToken(request);
7 if (!token) {
8 return NextResponse.json(
9 { error: "Missing authentication token" },
10 { status: 401 }
11 );
12 }
13
14 const user = verifyToken(token);
15 if (!user) {
16 return NextResponse.json(
17 { error: "Invalid or expired token" },
18 { status: 401 }
19 );
20 }
21
22 if (user.role !== "admin") {
23 return NextResponse.json(
24 { error: "Insufficient permissions" },
25 { status: 403 }
26 );
27 }
28
29 // User is authenticated and authorized
30 const data = await getAdminData();
31 return NextResponse.json(data);
32}

For API key authentication (common for external APIs):

app/api/v1/data/route.ts
TSX
1// app/api/v1/data/route.ts
2import { NextRequest, NextResponse } from "next/server";
3import { db } from "@/lib/database";
4
5async function authenticateApiKey(
6 request: NextRequest
7): Promise<{ valid: boolean; projectId?: string }> {
8 const apiKey = request.headers.get("x-api-key");
9 if (!apiKey) return { valid: false };
10
11 const key = await db.apiKey.findUnique({
12 where: { key: apiKey },
13 select: { projectId: true, expiresAt: true },
14 });
15
16 if (!key || (key.expiresAt && key.expiresAt < new Date())) {
17 return { valid: false };
18 }
19
20 return { valid: true, projectId: key.projectId };
21}
22
23export async function GET(request: NextRequest) {
24 const auth = await authenticateApiKey(request);
25 if (!auth.valid) {
26 return NextResponse.json(
27 { error: "Invalid API key" },
28 { status: 401 }
29 );
30 }
31
32 const data = await db.data.findMany({
33 where: { projectId: auth.projectId },
34 });
35
36 return NextResponse.json(data);
37}
โš 

warning

Never store API keys, JWT secrets, or database credentials in your code. Always use environment variables and ensure Route Handlers are not included in the client bundle (they never are in the App Router, but it is worth being explicit).
File Uploads

Handle file uploads using the FormData API in Route Handlers.

app/api/upload/route.ts
TSX
1// app/api/upload/route.ts
2import { NextRequest, NextResponse } from "next/server";
3import { writeFile } from "fs/promises";
4import { join } from "path";
5
6export async function POST(request: NextRequest) {
7 const formData = await request.formData();
8 const file = formData.get("file") as File | null;
9
10 if (!file) {
11 return NextResponse.json(
12 { error: "No file provided" },
13 { status: 400 }
14 );
15 }
16
17 // Validate file type
18 const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
19 if (!allowedTypes.includes(file.type)) {
20 return NextResponse.json(
21 { error: "Invalid file type. Allowed: JPEG, PNG, WebP" },
22 { status: 400 }
23 );
24 }
25
26 // Validate file size (5MB max)
27 if (file.size > 5 * 1024 * 1024) {
28 return NextResponse.json(
29 { error: "File too large. Maximum size: 5MB" },
30 { status: 400 }
31 );
32 }
33
34 const bytes = await file.arrayBuffer();
35 const buffer = Buffer.from(bytes);
36
37 // Generate unique filename
38 const ext = file.name.split(".").pop();
39 const filename = `${crypto.randomUUID()}.${ext}`;
40 const filepath = join(process.cwd(), "public/uploads", filename);
41
42 await writeFile(filepath, buffer);
43
44 return NextResponse.json({
45 url: `/uploads/${filename}`,
46 filename,
47 size: file.size,
48 type: file.type,
49 }, { status: 201 });
50}
โ„น

info

For production file uploads, consider using a storage service like AWS S3, Cloudflare R2, or Vercel Blob instead of writing to the local filesystem. Serverless functions have ephemeral storage that does not persist across deployments.
Webhook Handling

Route Handlers are ideal for receiving webhooks from external services. Always verify the webhook signature to prevent spoofing.

app/api/webhooks/stripe/route.ts
TSX
1// app/api/webhooks/stripe/route.ts
2import { NextRequest, NextResponse } from "next/server";
3import Stripe from "stripe";
4import { revalidatePath } from "next/cache";
5
6const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
7
8export async function POST(request: NextRequest) {
9 const body = await request.text();
10 const signature = request.headers.get("stripe-signature")!;
11
12 let event: Stripe.Event;
13
14 try {
15 event = stripe.webhooks.constructEvent(
16 body,
17 signature,
18 process.env.STRIPE_WEBHOOK_SECRET!
19 );
20 } catch (err) {
21 console.error("Webhook signature verification failed:", err);
22 return NextResponse.json(
23 { error: "Invalid signature" },
24 { status: 400 }
25 );
26 }
27
28 switch (event.type) {
29 case "checkout.session.completed": {
30 const session = event.data.object as Stripe.Checkout.Session;
31 await fulfillOrder(session);
32 revalidatePath("/orders");
33 break;
34 }
35 case "invoice.payment_failed": {
36 const invoice = event.data.object as Stripe.Invoice;
37 await notifyUser(invoice.customer_email!);
38 break;
39 }
40 default:
41 console.log(`Unhandled event type: ${event.type}`);
42 }
43
44 return NextResponse.json({ received: true });
45}
โ„น

info

When using the App Router, reading the request body as request.text() is required for webhook signature verification because the raw body is needed. Do not use request.json() for webhooks that require signature verification.
Streaming Responses

Route Handlers can return streaming responses using the Web Streams API. This is useful for Server-Sent Events (SSE), large data exports, and AI streaming responses.

app/api/chat/route.ts
TSX
1// app/api/chat/route.ts
2import { NextRequest } from "next/server";
3
4export async function POST(request: NextRequest) {
5 const { message } = await request.json();
6
7 // Stream AI response
8 const encoder = new TextEncoder();
9
10 const stream = new ReadableStream({
11 async start(controller) {
12 const response = await fetch("https://api.openai.com/v1/chat/completions", {
13 method: "POST",
14 headers: {
15 "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
16 "Content-Type": "application/json",
17 },
18 body: JSON.stringify({
19 model: "gpt-4",
20 messages: [{ role: "user", content: message }],
21 stream: true,
22 }),
23 });
24
25 const reader = response.body!.getReader();
26 const decoder = new TextDecoder();
27
28 while (true) {
29 const { done, value } = await reader.read();
30 if (done) break;
31 controller.enqueue(value);
32 }
33
34 controller.close();
35 },
36 });
37
38 return new Response(stream, {
39 headers: {
40 "Content-Type": "text/event-stream",
41 "Cache-Control": "no-cache",
42 "Connection": "keep-alive",
43 },
44 });
45}

Server-Sent Events (SSE) pattern:

app/api/events/route.ts
TSX
1// app/api/events/route.ts
2export async function GET() {
3 const encoder = new TextEncoder();
4
5 const stream = new ReadableStream({
6 start(controller) {
7 // Send events every second
8 let count = 0;
9 const interval = setInterval(() => {
10 const data = JSON.stringify({ time: new Date().toISOString(), count });
11 controller.enqueue(`data: ${data}
12
13`);
14 count++;
15 if (count > 10) {
16 clearInterval(interval);
17 controller.close();
18 }
19 }, 1000);
20 },
21 });
22
23 return new Response(stream, {
24 headers: {
25 "Content-Type": "text/event-stream",
26 "Cache-Control": "no-cache",
27 },
28 });
29}
Edge Runtime

By default, Route Handlers run in the Node.js runtime. You can opt into the Edge runtime for lower latency (runs on CDN edge nodes) but with limited APIs.

app/api/edge-hello/route.ts
TSX
1// app/api/edge-hello/route.ts
2export const runtime = "edge"; // Run on edge
3
4export async function GET(request: Request) {
5 // Edge runtime uses Web APIs
6 const url = new URL(request.url);
7 const name = url.searchParams.get("name") ?? "World";
8
9 return new Response(`Hello, ${name}!`, {
10 headers: { "Content-Type": "text/plain" },
11 });
12}
FeatureNode.js RuntimeEdge Runtime
Execution locationYour server / serverlessCDN edge nodes
Cold startSlowerNear-instant
Node.js APIsFull accessLimited subset
npm packagesAll packagesEdge-compatible only
File systemAvailableNot available
Best forComplex logic, file I/OSimple API, auth, geo-routing
โ„น

info

Use Edge runtime for lightweight API routes that need low latency โ€” authentication checks, geo-based routing, A/B testing. Use Node.js runtime for routes that require file system access, complex npm packages, or database drivers that are not edge-compatible.
Route-Level Middleware

While global middleware lives at the project root, you can add route-specific logic directly in Route Handlers. For shared middleware across multiple routes, create a helper function.

lib/api-middleware.ts
TSX
1// lib/api-middleware.ts
2import { NextRequest, NextResponse } from "next/server";
3import { verifyToken } from "./auth";
4
5export function withAuth(
6 handler: (request: NextRequest, user: AuthUser) => Promise<NextResponse>
7) {
8 return async (request: NextRequest) => {
9 const token = request.headers.get("authorization")?.slice(7);
10 if (!token) {
11 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
12 }
13
14 const user = verifyToken(token);
15 if (!user) {
16 return NextResponse.json({ error: "Invalid token" }, { status: 401 });
17 }
18
19 return handler(request, user);
20 };
21}
22
23export function withRateLimit(
24 handler: (request: NextRequest) => Promise<NextResponse>,
25 { limit = 100, windowMs = 60000 } = {}
26) {
27 const hits = new Map<string, { count: number; resetAt: number }>();
28
29 return async (request: NextRequest) => {
30 const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
31 const now = Date.now();
32 const entry = hits.get(ip);
33
34 if (entry && entry.resetAt > now) {
35 if (entry.count >= limit) {
36 return NextResponse.json(
37 { error: "Rate limit exceeded" },
38 {
39 status: 429,
40 headers: { "Retry-After": Math.ceil((entry.resetAt - now) / 1000).toString() },
41 }
42 );
43 }
44 entry.count++;
45 } else {
46 hits.set(ip, { count: 1, resetAt: now + windowMs });
47 }
48
49 return handler(request);
50 };
51}
app/api/protected/route.ts
TSX
1// app/api/protected/route.ts
2import { NextRequest, NextResponse } from "next/server";
3import { withAuth, withRateLimit } from "@/lib/api-middleware";
4
5const handler = withRateLimit(
6 withAuth(async (request, user) => {
7 return NextResponse.json({
8 message: `Hello, ${user.email}`,
9 role: user.role,
10 });
11 }),
12 { limit: 50, windowMs: 60000 }
13);
14
15export { handler as GET, handler as POST };
$Blueprint โ€” Engineering DocumentationยทSection ID: NEXTJS-05ยทRevision: 1.0