Skip to main content

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.ts file exports named functions (GET, POST, DELETE, etc.) that map directly to HTTP methods
  • By convention they live under app/api/, but any subfolder inside app works
  • 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

tsx
// 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:

tsx
// 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:

tsx
// 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:

tsx
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.

tsx
// 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:

tsx
export const dynamic = 'force-dynamic' // run on every request, no cache export const revalidate = 300 // cache for 5 minutes, then re-fetch

Route Handlers vs Server Actions

Route HandlersServer Actions
PurposeAPI endpointsUI mutations
HTTP methodsGET, POST, PUT, DELETE, PATCH...POST only
Called fromAny HTTP client (fetch, curl, mobile apps)React components only
Progressive enhancementNoYes (works with plain HTML forms)
When to pickPublic API, webhooks, third-party integrationsForm 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:

tsx
// 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 = conflict

Next.js throws an error. Move the handler to app/api/users/route.ts.

4. Skipping error handling

tsx
// 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

tsx
// 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

tsx
// 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

tsx
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?