API routes (route handlers) in Next.js
Route Handlers are route.ts files inside the app directory that export named HTTP method functions and act as server-side API endpoints in a Next.js app.
Theory
TL;DR
- A
route.tsfile exports named functions (GET,POST,DELETE, etc.) that map directly to HTTP methods - By convention they live under
app/api/, but any subfolder insideappworks - GET handlers that don't read the request get cached automatically; everything else runs dynamically
- Use Route Handlers for public APIs, webhooks, and external integrations
- Use Server Actions when the mutation comes from a form or button in your own UI
Quick example
// app/api/users/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET() {
const users = await db.user.findMany()
return NextResponse.json(users) // 200 by default
}
export async function POST(request: Request) {
const body = await request.json()
const user = await db.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}Two exported functions, two HTTP methods handled. That's the whole pattern.
Dynamic routes
Add [id] to the folder name and Next.js passes the segment as the second argument to the handler:
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({ where: { id: params.id } })
if (!user) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(user)
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.user.delete({ where: { id: params.id } })
return new Response(null, { status: 204 }) // no body on delete
}Reading request data
Route Handlers use the standard Web Request API. No Next.js-specific abstraction, just the platform:
// app/api/search/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q')
if (!query) {
return NextResponse.json({ error: 'Missing query param' }, { status: 400 })
}
const results = await db.post.findMany({
where: { title: { contains: query, mode: 'insensitive' } }
})
return NextResponse.json(results)
}For cookies and headers, use the helpers from next/headers:
import { cookies, headers } from 'next/headers'
export async function GET() {
const token = cookies().get('session')?.value
const userAgent = headers().get('user-agent')
return NextResponse.json({ token, userAgent })
}Caching behavior
Caching is where most developers get surprised. A GET handler with no request argument is treated as static and cached at build time. The moment you read anything from the request, Next.js makes the route dynamic.
// Cached - Next.js computes this at build time
export async function GET() {
const res = await fetch('https://api.example.com/posts')
return NextResponse.json(await res.json())
}
// Not cached - reads request data
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
// runs on every request
}Force or opt out of caching with route segment config:
export const dynamic = 'force-dynamic' // run on every request, no cache
export const revalidate = 300 // cache for 5 minutes, then re-fetchRoute Handlers vs Server Actions
| Route Handlers | Server Actions | |
|---|---|---|
| Purpose | API endpoints | UI mutations |
| HTTP methods | GET, POST, PUT, DELETE, PATCH... | POST only |
| Called from | Any HTTP client (fetch, curl, mobile apps) | React components only |
| Progressive enhancement | No | Yes (works with plain HTML forms) |
| When to pick | Public API, webhooks, third-party integrations | Form submissions, button clicks in your own UI |
If an external service needs to call your endpoint, use a Route Handler. If only your own UI triggers it, Server Actions remove a lot of boilerplate.
Common mistakes
1. Not awaiting params in Next.js 15
Starting with Next.js 15, params became a Promise. Accessing it directly returns undefined:
// Breaks in Next.js 15
export async function GET(
_: Request,
{ params }: { params: { id: string } }
) {
console.log(params.id) // undefined in Next.js 15
}
// Correct
export async function GET(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
console.log(id)
}2. Assuming GET is always dynamic
A GET handler that doesn't read request gets cached. If you return fresh database data each time but forget export const dynamic = 'force-dynamic', users see stale results after a deploy.
3. Putting route.ts and page.tsx in the same segment
app/users/page.tsx <- page
app/users/route.ts <- route handler in the same folder = conflictNext.js throws an error. Move the handler to app/api/users/route.ts.
4. Skipping error handling
// Bad - unhandled db error becomes a 500 with an empty body
export async function GET() {
const users = await db.user.findMany()
return NextResponse.json(users)
}
// Better
export async function GET() {
try {
const users = await db.user.findMany()
return NextResponse.json(users)
} catch {
return NextResponse.json({ error: 'Database error' }, { status: 500 })
}
}Real-world usage
- Public REST APIs consumed by mobile apps or third-party clients
- Webhook receivers: Stripe payments, GitHub events, Clerk auth events
- OAuth callback handlers
- File upload endpoints
- Cron job triggers called by external schedulers (Vercel Cron, GitHub Actions)
Follow-up questions
Q: Can you call a Route Handler from a Server Component?
A: You can, but it creates a pointless HTTP round-trip. Server Components already run on the server, so call the database or service directly instead.
Q: What happens if you export a function with an unrecognized method name from route.ts?
A: Next.js ignores it. Only GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS are wired to HTTP methods.
Q: How do you share auth logic across multiple Route Handlers without repeating it?
A: Two options: use middleware.ts to protect entire route groups before the request reaches the handler, or write a wrapper function that checks the session and wrap each individual handler with it.
Q: What is the difference between NextResponse and the native Response?
A: NextResponse extends the native Response with helpers like .json(), cookie manipulation, and redirects. For simple cases, new Response(body, { status: 200 }) works fine too.
Examples
Basic CRUD endpoint
// app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET() {
const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } })
return NextResponse.json(posts)
}
export async function POST(request: Request) {
const { title, content } = await request.json()
if (!title) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 })
}
const post = await db.post.create({ data: { title, content } })
return NextResponse.json(post, { status: 201 })
}GET returns posts sorted by date. POST validates input before writing to the database and returns 201 on success.
Webhook handler with signature verification
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const body = await request.text() // raw string, not JSON
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return new Response('Invalid signature', { status: 400 })
}
if (event.type === 'checkout.session.completed') {
// handle successful payment
}
return new Response(null, { status: 200 })
}Stripe sends raw bytes, so you must read the body with request.text(). If you call request.json() first, the signature check fails every time.
Protected endpoint with session validation
// app/api/profile/route.ts
import { cookies } from 'next/headers'
import { verifySession } from '@/lib/auth'
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET() {
const token = cookies().get('session')?.value
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const session = await verifySession(token)
if (!session) {
return NextResponse.json({ error: 'Invalid session' }, { status: 401 })
}
const user = await db.user.findUnique({ where: { id: session.userId } })
return NextResponse.json(user)
}Read the cookie, verify it, then fetch the user. Two gates before any data leaves the server.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.