Next.js API Routes & 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
Create a route.ts file (not page.tsx) in any app/ subdirectory. The file exports named async functions matching HTTP methods.
| 1 | // app/api/users/route.ts |
| 2 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | import { db } from "@/lib/database"; |
| 4 | |
| 5 | // GET /api/users |
| 6 | export async function GET(request: NextRequest) { |
| 7 | const users = await db.user.findMany(); |
| 8 | return NextResponse.json(users); |
| 9 | } |
| 10 | |
| 11 | // POST /api/users |
| 12 | export 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 | } |
| 1 | // app/api/users/[id]/route.ts |
| 2 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | import { db } from "@/lib/database"; |
| 4 | |
| 5 | // GET /api/users/123 |
| 6 | export 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 |
| 24 | export 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 |
| 40 | export 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 | } |
Next.js extends the standard Web Request and Response APIs with helper methods for common patterns.
| 1 | // app/api/search/route.ts |
| 2 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | |
| 4 | export 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 | } |
| 1 | // Response helpers |
| 2 | |
| 3 | import { NextResponse } from "next/server"; |
| 4 | |
| 5 | // JSON response |
| 6 | return NextResponse.json({ message: "Success" }); |
| 7 | return NextResponse.json({ error: "Not found" }, { status: 404 }); |
| 8 | |
| 9 | // Plain text response |
| 10 | return new NextResponse("Hello, World!", { |
| 11 | headers: { "Content-Type": "text/plain" }, |
| 12 | }); |
| 13 | |
| 14 | // Redirect |
| 15 | return NextResponse.redirect(new URL("/login", request.url)); |
| 16 | return NextResponse.redirect(new URL("/login", request.url), 301); |
| 17 | |
| 18 | // Set cookies |
| 19 | const response = NextResponse.json({ success: true }); |
| 20 | response.cookies.set("session", "abc123", { |
| 21 | httpOnly: true, |
| 22 | secure: true, |
| 23 | sameSite: "lax", |
| 24 | maxAge: 60 * 60 * 24 * 7, // 7 days |
| 25 | path: "/", |
| 26 | }); |
| 27 | response.cookies.set("theme", "dark"); |
| 28 | return response; |
| 29 | |
| 30 | // Delete cookies |
| 31 | const response = NextResponse.json({ success: true }); |
| 32 | response.cookies.delete("session"); |
| 33 | return response; |
| 1 | // Combining route params with search params |
| 2 | // URL: /api/products?category=electronics&sort=price |
| 3 | |
| 4 | // app/api/products/route.ts |
| 5 | import { NextRequest, NextResponse } from "next/server"; |
| 6 | |
| 7 | export 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 | } |
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.
| 1 | // lib/auth.ts โ Shared auth utilities |
| 2 | import { verify } from "jsonwebtoken"; |
| 3 | |
| 4 | const JWT_SECRET = process.env.JWT_SECRET!; |
| 5 | |
| 6 | export interface AuthUser { |
| 7 | id: string; |
| 8 | email: string; |
| 9 | role: "admin" | "user"; |
| 10 | } |
| 11 | |
| 12 | export function verifyToken(token: string): AuthUser | null { |
| 13 | try { |
| 14 | return verify(token, JWT_SECRET) as AuthUser; |
| 15 | } catch { |
| 16 | return null; |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | export 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 | } |
| 1 | // app/api/admin/route.ts |
| 2 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | import { verifyToken, extractToken } from "@/lib/auth"; |
| 4 | |
| 5 | export 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):
| 1 | // app/api/v1/data/route.ts |
| 2 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | import { db } from "@/lib/database"; |
| 4 | |
| 5 | async 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 | |
| 23 | export 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
Handle file uploads using the FormData API in Route Handlers.
| 1 | // app/api/upload/route.ts |
| 2 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | import { writeFile } from "fs/promises"; |
| 4 | import { join } from "path"; |
| 5 | |
| 6 | export 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
Route Handlers are ideal for receiving webhooks from external services. Always verify the webhook signature to prevent spoofing.
| 1 | // app/api/webhooks/stripe/route.ts |
| 2 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | import Stripe from "stripe"; |
| 4 | import { revalidatePath } from "next/cache"; |
| 5 | |
| 6 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); |
| 7 | |
| 8 | export 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
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.
| 1 | // app/api/chat/route.ts |
| 2 | import { NextRequest } from "next/server"; |
| 3 | |
| 4 | export 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:
| 1 | // app/api/events/route.ts |
| 2 | export 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 | } |
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.
| 1 | // app/api/edge-hello/route.ts |
| 2 | export const runtime = "edge"; // Run on edge |
| 3 | |
| 4 | export 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 | } |
| Feature | Node.js Runtime | Edge Runtime |
|---|---|---|
| Execution location | Your server / serverless | CDN edge nodes |
| Cold start | Slower | Near-instant |
| Node.js APIs | Full access | Limited subset |
| npm packages | All packages | Edge-compatible only |
| File system | Available | Not available |
| Best for | Complex logic, file I/O | Simple API, auth, geo-routing |
info
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.
| 1 | // lib/api-middleware.ts |
| 2 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | import { verifyToken } from "./auth"; |
| 4 | |
| 5 | export 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 | |
| 23 | export 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 | } |
| 1 | // app/api/protected/route.ts |
| 2 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | import { withAuth, withRateLimit } from "@/lib/api-middleware"; |
| 4 | |
| 5 | const 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 | |
| 15 | export { handler as GET, handler as POST }; |