Next.js API Routes
Targeting Next.js 16 (App Router — Route Handlers)
Syntax
Route Handler
// app/api/hello/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ message: 'Hello' })
}
// GET /api/hello → { "message": "Hello" }HTTP Methods
// app/api/users/route.ts
export async function GET() { /* list */ }
export async function POST() { /* create */ }
export async function PUT() { /* replace */ }
export async function PATCH() { /* update */ }
export async function DELETE() { /* remove */ }
export async function HEAD() { /* headers only */ }
export async function OPTIONS() { /* CORS preflight */ }Request Object
export async function POST(request: Request) {
request.method // 'POST'
request.url // full URL string
request.headers // Headers object
request.json() // parse JSON body
request.text() // raw text body
request.formData() // parse form data
request.arrayBuffer() // binary data
}NextRequest Extras
import { NextRequest } from 'next/server'
export async function GET(req: NextRequest) {
req.nextUrl.pathname // '/api/users'
req.nextUrl.searchParams.get('q') // query param
req.cookies.get('token') // cookie value
req.headers.get('x-forwarded-for') // client IP
}Response Helpers
import { NextResponse } from 'next/server'
NextResponse.json(data) // JSON
NextResponse.json(data, { status: 201 }) // with status
NextResponse.redirect(new URL('/login', req.url))
NextResponse.next() // continue (proxy)
// plain Response works too
new Response('OK', { status: 200 })
new Response(null, { status: 204 }) // no contentDynamic Params
// app/api/users/[id]/route.ts
interface Ctx {
params: Promise<{ id: string }>
}
export async function GET(req: Request, ctx: Ctx) {
const { id } = await ctx.params
return NextResponse.json({ id })
}
// GET /api/users/42 → { "id": "42" }Beginner Patterns
JSON Body
export async function POST(req: Request) {
const body = await req.json()
const user = await db.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}Query Params
import { NextRequest } from 'next/server'
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q') ?? ''
const page = Number(req.nextUrl.searchParams.get('page') ?? '1')
const results = await search(q, page)
return NextResponse.json(results)
}
// GET /api/search?q=hello&page=2Headers
import { headers } from 'next/headers'
export async function GET() {
const headerList = await headers()
const auth = headerList.get('authorization')
return NextResponse.json({ ok: true }, {
headers: { 'X-Custom': 'value' },
})
}Cookies
import { cookies } from 'next/headers'
export async function GET() {
const jar = await cookies()
const token = jar.get('token')?.value
return NextResponse.json({ token })
}
export async function POST() {
const jar = await cookies()
jar.set('token', 'abc123', { httpOnly: true })
jar.delete('old-cookie')
return NextResponse.json({ ok: true })
}Form Data
export async function POST(req: Request) {
const formData = await req.formData()
const name = formData.get('name') as string
const email = formData.get('email') as string
await saveContact({ name, email })
return NextResponse.json({ ok: true })
}Status Codes
export async function GET() {
// not found
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
)
}
export async function DELETE() {
await db.item.delete()
return new Response(null, { status: 204 }) // no content
}Intermediate Patterns
Error Handling
export async function GET(req: NextRequest, ctx: Ctx) {
try {
const { id } = await ctx.params
const user = await db.user.findUniqueOrThrow({
where: { id },
})
return NextResponse.json(user)
} catch (e: any) {
if (e?.code === 'P2025') {
return NextResponse.json(
{ error: 'Not found' }, { status: 404 }
)
}
return NextResponse.json(
{ error: 'Server error' }, { status: 500 }
)
}
}Zod Validation
import { z } from 'zod'
const CreateUser = z.object({
name: z.string().min(1),
email: z.string().email(),
})
export async function POST(req: Request) {
const result = CreateUser.safeParse(await req.json())
if (!result.success) {
return NextResponse.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
)
}
const user = await db.user.create({ data: result.data })
return NextResponse.json(user, { status: 201 })
}CORS
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
export async function OPTIONS() {
return new Response(null, { headers: corsHeaders })
}
export async function GET() {
return NextResponse.json({ ok: true }, {
headers: corsHeaders,
})
}Auth Guard
import { auth } from '@/lib/auth'
export async function GET() {
const session = await auth()
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const data = await getProtectedData(session.user.id)
return NextResponse.json(data)
}Catch-All Route
// app/api/[...path]/route.ts
interface Ctx {
params: Promise<{ path: string[] }>
}
export async function GET(req: Request, ctx: Ctx) {
const { path } = await ctx.params
// GET /api/a/b/c → path = ['a', 'b', 'c']
return NextResponse.json({ path })
}Streaming
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for (const chunk of ['Hello', ' ', 'World']) {
controller.enqueue(encoder.encode(chunk))
await new Promise((r) => setTimeout(r, 500))
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' },
})
}Advanced Patterns
File Upload
export async function POST(req: Request) {
const formData = await req.formData()
const file = formData.get('file') as File
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
await writeFile(`./uploads/${file.name}`, buffer)
return NextResponse.json({
name: file.name,
size: file.size,
})
}Server-Sent Events
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
let count = 0
const interval = setInterval(() => {
const data = `data: ${JSON.stringify({ count: count++ })}\n\n`
controller.enqueue(encoder.encode(data))
if (count > 10) {
clearInterval(interval)
controller.close()
}
}, 1000)
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}Webhook Handler
import crypto from 'crypto'
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get('x-webhook-signature')!
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(body)
.digest('hex')
if (sig !== expected) {
return NextResponse.json(
{ error: 'Invalid signature' }, { status: 401 }
)
}
const event = JSON.parse(body)
await processEvent(event)
return NextResponse.json({ received: true })
}Pagination
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl
const page = Number(searchParams.get('page') ?? '1')
const limit = Number(searchParams.get('limit') ?? '20')
const skip = (page - 1) * limit
const [items, total] = await Promise.all([
db.item.findMany({ skip, take: limit }),
db.item.count(),
])
return NextResponse.json({
items,
page,
totalPages: Math.ceil(total / limit),
})
}Route Segment Config
// app/api/feed/route.ts
// opt out of caching
export const dynamic = 'force-dynamic'
// revalidate every 60 seconds
export const revalidate = 60
// set max duration (seconds)
export const maxDuration = 30
// choose runtime
export const runtime = 'edge' // or 'nodejs'
export async function GET() {
return NextResponse.json(await getFeed())
}Proxy / Rewrite
// app/api/external/[...path]/route.ts
interface Ctx {
params: Promise<{ path: string[] }>
}
export async function GET(req: NextRequest, ctx: Ctx) {
const { path } = await ctx.params
const url = `https://api.example.com/${path.join('/')}`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
})
const data = await res.json()
return NextResponse.json(data)
}
// keeps API key server-sideLast updated on