Next.js Route Handlers — Building APIs with the App Router

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

Route Handlers are the App Router replacement for Pages Router API routes. Created by adding a route.ts file in any app directory folder, they support all HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) and run in either the Node.js or Edge Runtime. They are the right choice for webhooks, REST endpoints, and any API that cannot use Server Actions.

Why This Matters

Server Actions handle the majority of form mutations in Next.js apps, but Route Handlers are still essential for: external webhooks (Stripe, GitHub, Clerk), REST APIs consumed by mobile apps, endpoints that need specific HTTP status codes or headers, and file upload/download endpoints.

Route Handlers co-locate with your pages — app/api/posts/route.ts serves /api/posts. They share the same Edge/Node.js runtime and have full access to NextRequest and NextResponse APIs, making them more capable than Pages Router API routes.

Basic Route Handler

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
 
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') ?? '1')
  const limit = parseInt(searchParams.get('limit') ?? '10')
 
  const posts = await db.post.findMany({
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: 'desc' },
    select: { id: true, title: true, slug: true, excerpt: true, createdAt: true },
  })
 
  const total = await db.post.count()
 
  return NextResponse.json({
    posts,
    pagination: { page, limit, total, pages: Math.ceil(total / limit) },
  })
}
 
export async function POST(request: NextRequest) {
  const body = await request.json()
 
  if (!body.title || !body.content) {
    return NextResponse.json({ error: 'Title and content are required' }, { status: 400 })
  }
 
  const post = await db.post.create({
    data: { title: body.title, content: body.content, slug: slugify(body.title) },
  })
 
  return NextResponse.json(post, { status: 201 })
}

Dynamic Route Handlers

Handle routes with URL parameters:

// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
 
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const post = await db.post.findUnique({ where: { id } })
 
  if (!post) {
    return NextResponse.json({ error: 'Post not found' }, { status: 404 })
  }
 
  return NextResponse.json(post)
}
 
export async function PUT(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const body = await request.json()
 
  const updated = await db.post.update({
    where: { id },
    data: { title: body.title, content: body.content, updatedAt: new Date() },
  })
 
  return NextResponse.json(updated)
}
 
export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  await db.post.delete({ where: { id } })
  return new NextResponse(null, { status: 204 })
}

Authentication Middleware Pattern

Validate authentication inside route handlers:

// lib/api-auth.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
 
export async function withAuth(
  request: NextRequest,
  handler: (req: NextRequest, session: Session) => Promise<NextResponse>
) {
  const session = await auth()
 
  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  return handler(request, session)
}
// app/api/user/profile/route.ts
import { withAuth } from '@/lib/api-auth'
 
export function GET(request: NextRequest) {
  return withAuth(request, async (req, session) => {
    const user = await db.user.findUnique({ where: { id: session.user.id } })
    return NextResponse.json(user)
  })
}

Webhook Handler with Signature Verification

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
 
export async function POST(request: NextRequest) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!
 
  let event: Stripe.Event
 
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }
 
  switch (event.type) {
    case 'payment_intent.succeeded': {
      const paymentIntent = event.data.object as Stripe.PaymentIntent
      await db.order.update({
        where: { stripePaymentIntentId: paymentIntent.id },
        data: { status: 'paid' },
      })
      break
    }
    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      await db.user.update({
        where: { stripeCustomerId: subscription.customer as string },
        data: { plan: 'free' },
      })
      break
    }
  }
 
  return NextResponse.json({ received: true })
}

Streaming Responses

Stream data with ReadableStream:

// app/api/stream/route.ts
export async function GET() {
  const encoder = new TextEncoder()
 
  const stream = new ReadableStream({
    async start(controller) {
      const messages = ['Hello', ' from', ' streaming', ' API!']
 
      for (const msg of messages) {
        controller.enqueue(encoder.encode(`data: ${msg}\n\n`))
        await new Promise((r) => setTimeout(r, 500))
      }
 
      controller.close()
    },
  })
 
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  })
}

File Upload Handler

// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile } from 'fs/promises'
import path from 'path'
 
export async function POST(request: NextRequest) {
  const formData = await request.formData()
  const file = formData.get('file') as File
 
  if (!file) {
    return NextResponse.json({ error: 'No file provided' }, { status: 400 })
  }
 
  const maxSize = 5 * 1024 * 1024 // 5MB
  if (file.size > maxSize) {
    return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 413 })
  }
 
  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)
  const filename = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`
  const filepath = path.join(process.cwd(), 'public', 'uploads', filename)
 
  await writeFile(filepath, buffer)
 
  return NextResponse.json({ url: `/uploads/${filename}` })
}

Setting Cache Headers

Control caching for GET endpoints:

// app/api/config/route.ts
export async function GET() {
  const config = await db.config.findFirst()
 
  return NextResponse.json(config, {
    headers: {
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
  })
}
 
// Or use Next.js built-in caching
export const revalidate = 3600 // Cache GET responses for 1 hour

CORS Headers

Add CORS headers for cross-origin access:

// app/api/public/route.ts
import { NextRequest, NextResponse } from 'next/server'
 
const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
 
export function OPTIONS() {
  return new NextResponse(null, { status: 204, headers: corsHeaders })
}
 
export async function GET(request: NextRequest) {
  const data = await fetchPublicData()
  return NextResponse.json(data, { headers: corsHeaders })
}

Common Mistakes

  • Using Route Handlers for form submissions when Server Actions are simpler and more appropriate
  • Not handling the OPTIONS preflight request when serving to external clients
  • Reading request.body directly — use request.json(), request.text(), or request.formData()
  • Forgetting to await params in Next.js 15 — route params are Promises in the App Router
  • Not verifying webhook signatures — exposes your endpoints to spoofed events

Best Practices

  • Use Server Actions for internal form submissions; use Route Handlers for external APIs and webhooks
  • Always validate and sanitize request bodies before processing
  • Return appropriate HTTP status codes (201 for created, 204 for deleted, 400 for bad input, 401 for unauthorized)
  • Implement request signature verification for all webhook endpoints
  • Add rate limiting at the middleware level rather than inside individual route handlers

Key Takeaways

  • Route Handlers replace Pages Router API routes — created with route.ts in any app folder
  • Export named functions for each HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
  • Dynamic route params in Next.js 15 are Promises — await params before accessing properties
  • Route Handlers run in Node.js by default; add export const runtime = 'edge' for Edge Runtime
  • export const revalidate = N caches GET responses for N seconds — useful for public data APIs
  • Webhook handlers must use request.text() to read the raw body before signature verification
  • ReadableStream enables server-sent events (SSE) and streaming responses from Route Handlers
  • CORS requires an OPTIONS handler for preflight requests in addition to CORS headers on the main response

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading