Back to Blog

Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards

Route Handlers in Next.js App Router provide a modern, standards-based approach to building API endpoints. Unlike the Pages Router's API Routes, Route Handlers embrace the Web Request/Response APIs directly, giving you full control over HTTP semantics while maintaining Next.js's optimization capabilities.

Route Handler Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                         Incoming Request                            │
└─────────────────────────────────────────────────────────────────────┘
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────────────┐
│                         Middleware Layer                            │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │  NextRequest (Extended Web Request)                          │   │
│  │  • cookies: RequestCookies                                   │   │
│  │  • nextUrl: NextURL (pathname, searchParams, basePath)       │   │
│  └─────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────────────┐
│                      Route Resolution                               │
│  app/                                                               │
│  ├── api/                                                           │
│  │   ├── route.ts          → /api                                   │
│  │   ├── users/                                                     │
│  │   │   ├── route.ts      → /api/users                             │
│  │   │   └── [id]/                                                  │
│  │   │       └── route.ts  → /api/users/:id                         │
│  │   └── [...slug]/                                                 │
│  │       └── route.ts      → /api/*                                 │
│  └── feed.xml/                                                      │
│      └── route.ts          → /feed.xml                              │
└─────────────────────────────────────────────────────────────────────┘
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    Route Handler Execution                          │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │  export async function GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS│   │
│  │    (request: NextRequest, context: RouteContext)             │   │
│  │                                                              │   │
│  │  Context:                                                    │   │
│  │    params: Promise<{ [key]: string }>                        │   │
│  └─────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────────────┐
│                       Response Generation                           │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │  Web Response / NextResponse                                 │   │
│  │  • Response.json()                                           │   │
│  │  • NextResponse.json({ data }, { status, headers })          │   │
│  │  • NextResponse.redirect(url)                                │   │
│  │  • NextResponse.rewrite(url)                                 │   │
│  │  • new Response(body, { status, headers })                   │   │
│  └─────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘

File Convention and HTTP Methods

Route Handlers are defined in route.ts (or route.js) files. Each file can export functions named after HTTP methods:

// app/api/users/route.ts

// Supported HTTP methods - each receives NextRequest
export async function GET(request: Request) {
  return Response.json({ users: [] })
}

export async function POST(request: Request) {
  const body = await request.json()
  return Response.json({ created: body }, { status: 201 })
}

export async function PUT(request: Request) {
  const body = await request.json()
  return Response.json({ updated: body })
}

export async function PATCH(request: Request) {
  const body = await request.json()
  return Response.json({ patched: body })
}

export async function DELETE(request: Request) {
  return new Response(null, { status: 204 })
}

export async function HEAD(request: Request) {
  return new Response(null, {
    headers: { 'X-Total-Count': '100' }
  })
}

// OPTIONS is auto-implemented if not defined
// Next.js sets Allow header based on exported methods
export async function OPTIONS(request: Request) {
  return new Response(null, {
    headers: {
      'Allow': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS'
    }
  })
}

Route Handler vs page.tsx Conflict

A route.ts file cannot coexist with a page.tsx in the same directory:

app/
├── api/
│   └── route.ts     ✓ Valid - no page.tsx conflict
├── users/
│   ├── page.tsx     ✗ Conflict - can't have both
│   └── route.ts     ✗ Conflict - can't have both
└── dashboard/
    ├── page.tsx     ✓ Valid - page for /dashboard
    └── api/
        └── route.ts ✓ Valid - API at /dashboard/api

NextRequest: Extended Web Request

NextRequest extends the standard Web Request API with Next.js-specific conveniences:

import { type NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  // ═══════════════════════════════════════════════════
  // nextUrl - Extended URL with Next.js properties
  // ═══════════════════════════════════════════════════
  const { nextUrl } = request

  // Pathname without query string
  console.log(nextUrl.pathname)        // '/api/users'

  // Query parameters as URLSearchParams
  const searchParams = nextUrl.searchParams
  const page = searchParams.get('page')        // '1'
  const limit = searchParams.get('limit')      // '10'
  const tags = searchParams.getAll('tag')      // ['react', 'next']

  // Base path if configured in next.config.js
  console.log(nextUrl.basePath)        // '/docs' or ''

  // Build ID for cache invalidation
  console.log(nextUrl.buildId)         // 'abc123' or undefined

  // ═══════════════════════════════════════════════════
  // cookies - Request cookie access
  // ═══════════════════════════════════════════════════
  const token = request.cookies.get('token')?.value
  const allCookies = request.cookies.getAll()
  const hasSession = request.cookies.has('session')

  // ═══════════════════════════════════════════════════
  // Standard Web Request properties
  // ═══════════════════════════════════════════════════
  console.log(request.method)          // 'GET'
  console.log(request.url)             // Full URL string
  console.log(request.headers)         // Headers object

  // Note: ip and geo were removed in Next.js 15
  // Use headers or third-party services for geolocation

  return Response.json({ pathname: nextUrl.pathname })
}

URL Query Parameter Patterns

// app/api/search/route.ts
import { type NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams

  // Single value
  const query = searchParams.get('q')

  // Multiple values (same key)
  // /api/search?category=electronics&category=books
  const categories = searchParams.getAll('category')

  // Check existence
  if (searchParams.has('sort')) {
    const sort = searchParams.get('sort')
  }

  // Iterate all params
  const params: Record<string, string[]> = {}
  searchParams.forEach((value, key) => {
    if (!params[key]) params[key] = []
    params[key].push(value)
  })

  // Convert to object (single values only)
  const paramsObject = Object.fromEntries(searchParams)

  return Response.json({ query, categories, params })
}

NextResponse: Extended Web Response

NextResponse provides static convenience methods for common response patterns:

import { NextResponse, type NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  // ═══════════════════════════════════════════════════
  // JSON responses
  // ═══════════════════════════════════════════════════

  // Simple JSON response
  return NextResponse.json({ data: 'value' })

  // With status and headers
  return NextResponse.json(
    { error: 'Not found' },
    {
      status: 404,
      headers: { 'X-Error-Code': 'RESOURCE_NOT_FOUND' }
    }
  )

  // ═══════════════════════════════════════════════════
  // Redirects
  // ═══════════════════════════════════════════════════

  // Redirect to absolute URL
  return NextResponse.redirect(new URL('/login', request.url))

  // Redirect with modified URL
  const loginUrl = new URL('/login', request.url)
  loginUrl.searchParams.set('from', request.nextUrl.pathname)
  return NextResponse.redirect(loginUrl)

  // Permanent redirect (308)
  return NextResponse.redirect(
    new URL('/new-path', request.url),
    { status: 308 }
  )

  // ═══════════════════════════════════════════════════
  // Rewrites (internal proxying)
  // ═══════════════════════════════════════════════════

  // Browser shows /about, server handles /proxy
  return NextResponse.rewrite(new URL('/proxy', request.url))

  // Proxy to external URL
  return NextResponse.rewrite(
    new URL('https://api.external.com/data')
  )

  // ═══════════════════════════════════════════════════
  // next() - Continue routing (primarily for middleware)
  // ═══════════════════════════════════════════════════

  return NextResponse.next()

  // Forward modified headers upstream
  const newHeaders = new Headers(request.headers)
  newHeaders.set('x-user-id', 'user_123')
  return NextResponse.next({
    request: { headers: newHeaders }
  })
}
import { NextResponse, type NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const response = NextResponse.json({ success: true })

  // Set a cookie
  response.cookies.set('token', 'abc123', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 1 week
    path: '/',
  })

  // Set multiple cookies
  response.cookies.set({
    name: 'session',
    value: 'session_id',
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    expires: new Date(Date.now() + 86400000),
  })

  // Delete a cookie
  response.cookies.delete('old_token')

  // Read cookies from response
  const token = response.cookies.get('token')
  const allCookies = response.cookies.getAll()

  return response
}

Dynamic Route Parameters

Dynamic segments in the route path are passed via the context parameter:

// app/api/users/[id]/route.ts
import { type NextRequest } from 'next/server'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  // params is a Promise in Next.js 15+
  const { id } = await params

  return Response.json({ userId: id })
}

// Multiple dynamic segments
// app/api/orgs/[orgId]/teams/[teamId]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ orgId: string; teamId: string }> }
) {
  const { orgId, teamId } = await params

  return Response.json({ orgId, teamId })
}

// Catch-all segments
// app/api/files/[...path]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params
  // /api/files/docs/images/logo.png → ['docs', 'images', 'logo.png']

  return Response.json({ path: path.join('/') })
}

// Optional catch-all
// app/api/[[...slug]]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ slug?: string[] }> }
) {
  const { slug } = await params
  // /api → { slug: undefined }
  // /api/a/b → { slug: ['a', 'b'] }

  return Response.json({ slug: slug || [] })
}

RouteContext Type Helper

Next.js provides a global RouteContext helper for strongly-typed route parameters:

// app/api/users/[id]/posts/[postId]/route.ts
import { type NextRequest } from 'next/server'

// RouteContext is globally available after type generation
// Run: next dev, next build, or next typegen
export async function GET(
  request: NextRequest,
  ctx: RouteContext<'/api/users/[id]/posts/[postId]'>
) {
  // ctx.params is Promise<{ id: string; postId: string }>
  const { id, postId } = await ctx.params

  return Response.json({ userId: id, postId })
}

Request Body Handling

JSON Bodies

// app/api/users/route.ts
import { type NextRequest } from 'next/server'

interface CreateUserBody {
  name: string
  email: string
  role?: 'admin' | 'user'
}

export async function POST(request: NextRequest) {
  try {
    const body: CreateUserBody = await request.json()

    // Validate required fields
    if (!body.name || !body.email) {
      return Response.json(
        { error: 'Name and email are required' },
        { status: 400 }
      )
    }

    // Process the request...
    const user = await createUser(body)

    return Response.json(user, { status: 201 })
  } catch (error) {
    // Handle JSON parse errors
    if (error instanceof SyntaxError) {
      return Response.json(
        { error: 'Invalid JSON body' },
        { status: 400 }
      )
    }
    throw error
  }
}

FormData Bodies

// app/api/upload/route.ts
import { type NextRequest } from 'next/server'
import { writeFile } from 'fs/promises'
import { join } from 'path'

export async function POST(request: NextRequest) {
  const formData = await request.formData()

  // Text fields
  const name = formData.get('name') as string
  const email = formData.get('email') as string

  // File upload
  const file = formData.get('avatar') as File | null

  if (!file) {
    return Response.json(
      { error: 'No file uploaded' },
      { status: 400 }
    )
  }

  // Validate file type
  if (!file.type.startsWith('image/')) {
    return Response.json(
      { error: 'Only images allowed' },
      { status: 400 }
    )
  }

  // Read file as buffer
  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)

  // Save to filesystem
  const uploadDir = join(process.cwd(), 'public', 'uploads')
  const filename = `${Date.now()}-${file.name}`
  await writeFile(join(uploadDir, filename), buffer)

  return Response.json({
    name,
    email,
    avatarUrl: `/uploads/${filename}`
  })
}

// Multiple files
export async function POST_MULTI(request: NextRequest) {
  const formData = await request.formData()

  // Get all files with same field name
  const files = formData.getAll('files') as File[]

  const uploadedFiles = await Promise.all(
    files.map(async (file) => {
      const bytes = await file.arrayBuffer()
      const buffer = Buffer.from(bytes)
      const filename = `${Date.now()}-${file.name}`

      await writeFile(
        join(process.cwd(), 'public', 'uploads', filename),
        buffer
      )

      return { name: file.name, url: `/uploads/${filename}` }
    })
  )

  return Response.json({ files: uploadedFiles })
}

Zod Validation with FormData

// app/api/contact/route.ts
import { type NextRequest } from 'next/server'
import { z } from 'zod'
import { zfd } from 'zod-form-data'

// Define schema using zod-form-data
const contactSchema = zfd.formData({
  name: zfd.text(z.string().min(2).max(100)),
  email: zfd.text(z.string().email()),
  message: zfd.text(z.string().min(10).max(1000)),
  priority: zfd.numeric(z.number().int().min(1).max(5).optional()),
  attachment: zfd.file(z.instanceof(File).optional()),
})

export async function POST(request: NextRequest) {
  const formData = await request.formData()

  const result = contactSchema.safeParse(formData)

  if (!result.success) {
    return Response.json(
      {
        error: 'Validation failed',
        details: result.error.flatten()
      },
      { status: 400 }
    )
  }

  const { name, email, message, priority, attachment } = result.data

  // Process validated data...

  return Response.json({ success: true })
}

Raw Body (for Webhooks)

// app/api/webhooks/stripe/route.ts
import { type NextRequest } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(request: NextRequest) {
  // Get raw body text for signature verification
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      webhookSecret
    )
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error'
    return Response.json(
      { error: `Webhook signature verification failed: ${message}` },
      { status: 400 }
    )
  }

  // Handle the event
  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object as Stripe.Checkout.Session
      await handleCheckoutComplete(session)
      break

    case 'invoice.paid':
      const invoice = event.data.object as Stripe.Invoice
      await handleInvoicePaid(invoice)
      break

    default:
      console.log(`Unhandled event type: ${event.type}`)
  }

  return Response.json({ received: true })
}

Headers and Cookies

Reading Headers

// app/api/route.ts
import { headers, cookies } from 'next/headers'
import { type NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  // Method 1: Using next/headers (recommended)
  const headersList = await headers()
  const userAgent = headersList.get('user-agent')
  const authorization = headersList.get('authorization')
  const contentType = headersList.get('content-type')

  // Method 2: From NextRequest directly
  const acceptLanguage = request.headers.get('accept-language')

  // Create new Headers from request
  const requestHeaders = new Headers(request.headers)

  // Iterate all headers
  const allHeaders: Record<string, string> = {}
  headersList.forEach((value, key) => {
    allHeaders[key] = value
  })

  return Response.json({ headers: allHeaders })
}

Reading Cookies

// app/api/auth/me/route.ts
import { cookies } from 'next/headers'
import { type NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  // Method 1: Using next/headers (recommended)
  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('session')?.value
  const allCookies = cookieStore.getAll()
  const hasToken = cookieStore.has('token')

  // Method 2: From NextRequest directly
  const token = request.cookies.get('token')
  const tokenValue = token?.value

  if (!sessionToken) {
    return Response.json(
      { error: 'Unauthorized' },
      { status: 401 }
    )
  }

  return Response.json({
    authenticated: true,
    session: sessionToken
  })
}

Setting Response Headers

// app/api/data/route.ts
export async function GET() {
  const data = await fetchData()

  return new Response(JSON.stringify(data), {
    status: 200,
    headers: {
      'Content-Type': 'application/json',
      'Cache-Control': 'public, max-age=60, s-maxage=300',
      'X-Response-Time': '42ms',
      'X-RateLimit-Limit': '100',
      'X-RateLimit-Remaining': '99',
    }
  })
}

Setting Cookies in Response

// app/api/auth/login/route.ts
import { cookies } from 'next/headers'

export async function POST(request: Request) {
  const { email, password } = await request.json()

  // Authenticate user...
  const session = await authenticate(email, password)

  if (!session) {
    return Response.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    )
  }

  // Set cookie using next/headers
  const cookieStore = await cookies()
  cookieStore.set('session', session.id, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 1 week
    path: '/',
  })

  return Response.json({
    user: session.user,
    expiresAt: session.expiresAt
  })
}

// Delete cookie on logout
export async function DELETE() {
  const cookieStore = await cookies()
  cookieStore.delete('session')

  return Response.json({ success: true })
}

Streaming Responses

Basic Streaming with ReadableStream

// app/api/stream/route.ts

export async function GET() {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 1; i <= 10; i++) {
        controller.enqueue(encoder.encode(`Chunk ${i}\n`))
        await new Promise(resolve => setTimeout(resolve, 500))
      }
      controller.close()
    }
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Transfer-Encoding': 'chunked',
    }
  })
}

Server-Sent Events (SSE)

// app/api/events/route.ts

export async function GET() {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      // Send initial connection event
      controller.enqueue(
        encoder.encode('event: connected\ndata: {}\n\n')
      )

      // Simulate real-time updates
      let count = 0
      const interval = setInterval(() => {
        count++
        const data = JSON.stringify({
          count,
          timestamp: Date.now()
        })
        controller.enqueue(
          encoder.encode(`event: update\ndata: ${data}\n\n`)
        )

        if (count >= 10) {
          clearInterval(interval)
          controller.enqueue(
            encoder.encode('event: complete\ndata: {}\n\n')
          )
          controller.close()
        }
      }, 1000)
    }
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    }
  })
}

AI/LLM Streaming with Vercel AI SDK

// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai'
import { streamText } from 'ai'

export async function POST(request: Request) {
  const { messages } = await request.json()

  const result = await streamText({
    model: openai('gpt-4-turbo'),
    messages,
    system: 'You are a helpful assistant.',
  })

  return result.toDataStreamResponse()
}

Streaming with Async Generator

// app/api/process/route.ts

async function* processItems(items: string[]) {
  for (const item of items) {
    // Simulate processing
    await new Promise(resolve => setTimeout(resolve, 200))
    yield { item, processed: true, timestamp: Date.now() }
  }
}

function iteratorToStream(iterator: AsyncIterableIterator<unknown>) {
  const encoder = new TextEncoder()

  return new ReadableStream({
    async pull(controller) {
      const { value, done } = await iterator.next()

      if (done) {
        controller.close()
      } else {
        const json = JSON.stringify(value) + '\n'
        controller.enqueue(encoder.encode(json))
      }
    }
  })
}

export async function POST(request: Request) {
  const { items } = await request.json()

  const iterator = processItems(items)
  const stream = iteratorToStream(iterator)

  return new Response(stream, {
    headers: {
      'Content-Type': 'application/x-ndjson',
    }
  })
}

Caching and Revalidation

Static vs Dynamic Behavior

Route Handlers have different caching behavior depending on the method and context:

┌─────────────────────────────────────────────────────────────────────┐
│                    Caching Decision Flow                            │
└─────────────────────────────────────────────────────────────────────┘
                                   │
                                   ▼
                        ┌─────────────────────┐
                        │    HTTP Method?     │
                        └─────────────────────┘
                          │              │
                     GET/HEAD         Other
                          │              │
                          ▼              ▼
              ┌───────────────────┐  ┌─────────────────┐
              │ Check for dynamic │  │ Always Dynamic  │
              │     functions     │  │   (no cache)    │
              └───────────────────┘  └─────────────────┘
                          │
                          ▼
    ┌──────────────────────────────────────────────┐
    │  Dynamic Functions Used?                      │
    │  • cookies()                                  │
    │  • headers()                                  │
    │  • request object used                        │
    │  • Dynamic route params                       │
    │  • Request body read                          │
    │  • dynamic = 'force-dynamic'                  │
    └──────────────────────────────────────────────┘
              │                    │
            Yes                   No
              │                    │
              ▼                    ▼
    ┌───────────────────┐  ┌───────────────────┐
    │  Dynamic Route    │  │  Static Route     │
    │  (render on each  │  │  (cached at build │
    │   request)        │  │   time)           │
    └───────────────────┘  └───────────────────┘

Route Segment Config Options

// app/api/posts/route.ts

// Force static generation
export const dynamic = 'force-static'

// Force dynamic rendering
export const dynamic = 'force-dynamic'

// Revalidation period (in seconds)
export const revalidate = 60

// Runtime (nodejs or edge)
export const runtime = 'nodejs'

// Preferred deployment regions
export const preferredRegion = ['iad1', 'sfo1']

// Maximum execution duration
export const maxDuration = 30

export async function GET() {
  const posts = await fetchPosts()
  return Response.json(posts)
}

Time-Based Revalidation

// app/api/posts/route.ts

// Revalidate every 60 seconds
export const revalidate = 60

export async function GET() {
  const data = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 }
  })
  const posts = await data.json()

  return Response.json(posts)
}

On-Demand Revalidation

// app/api/posts/route.ts
export async function GET() {
  const data = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] }
  })
  const posts = await data.json()

  return Response.json(posts)
}

// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache'

export async function POST(request: Request) {
  const { tag, path, secret } = await request.json()

  // Validate secret
  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json(
      { error: 'Invalid secret' },
      { status: 401 }
    )
  }

  if (tag) {
    revalidateTag(tag)
    return Response.json({ revalidated: true, tag })
  }

  if (path) {
    revalidatePath(path)
    return Response.json({ revalidated: true, path })
  }

  return Response.json(
    { error: 'Tag or path required' },
    { status: 400 }
  )
}

Static Generation with generateStaticParams

// app/api/posts/[slug]/route.ts

// Pre-render these routes at build time
export async function generateStaticParams() {
  const posts = await fetchAllPosts()

  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export async function GET(
  request: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const post = await fetchPost(slug)

  if (!post) {
    return Response.json(
      { error: 'Post not found' },
      { status: 404 }
    )
  }

  return Response.json(post)
}

CORS Configuration

Per-Route CORS

// app/api/route.ts

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  'Access-Control-Max-Age': '86400', // 24 hours
}

// Handle preflight requests
export async function OPTIONS() {
  return new Response(null, {
    status: 204,
    headers: corsHeaders
  })
}

export async function GET() {
  return Response.json(
    { message: 'Hello' },
    { headers: corsHeaders }
  )
}

export async function POST(request: Request) {
  const body = await request.json()

  return Response.json(
    { received: body },
    { headers: corsHeaders }
  )
}

CORS Middleware Pattern

// lib/cors.ts
type CorsOptions = {
  origin: string | string[] | ((origin: string) => boolean)
  methods?: string[]
  allowedHeaders?: string[]
  exposedHeaders?: string[]
  credentials?: boolean
  maxAge?: number
}

export function cors(handler: Function, options: CorsOptions) {
  return async function(request: Request) {
    const origin = request.headers.get('origin') || ''

    // Check if origin is allowed
    let isAllowed = false
    if (typeof options.origin === 'string') {
      isAllowed = options.origin === '*' || options.origin === origin
    } else if (Array.isArray(options.origin)) {
      isAllowed = options.origin.includes(origin)
    } else if (typeof options.origin === 'function') {
      isAllowed = options.origin(origin)
    }

    const corsHeaders: Record<string, string> = {}

    if (isAllowed) {
      corsHeaders['Access-Control-Allow-Origin'] =
        options.origin === '*' ? '*' : origin

      if (options.credentials) {
        corsHeaders['Access-Control-Allow-Credentials'] = 'true'
      }
    }

    corsHeaders['Access-Control-Allow-Methods'] =
      (options.methods || ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']).join(', ')

    corsHeaders['Access-Control-Allow-Headers'] =
      (options.allowedHeaders || ['Content-Type', 'Authorization']).join(', ')

    if (options.exposedHeaders) {
      corsHeaders['Access-Control-Expose-Headers'] =
        options.exposedHeaders.join(', ')
    }

    if (options.maxAge) {
      corsHeaders['Access-Control-Max-Age'] = String(options.maxAge)
    }

    // Handle preflight
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        status: 204,
        headers: corsHeaders
      })
    }

    // Call the handler
    const response = await handler(request)

    // Add CORS headers to response
    const newHeaders = new Headers(response.headers)
    Object.entries(corsHeaders).forEach(([key, value]) => {
      newHeaders.set(key, value)
    })

    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers: newHeaders,
    })
  }
}

// Usage in route.ts
import { cors } from '@/lib/cors'

async function handler(request: Request) {
  return Response.json({ data: 'value' })
}

export const GET = cors(handler, {
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
  maxAge: 86400,
})

export const OPTIONS = cors(async () => new Response(null), {
  origin: '*',
})

Global CORS via next.config.js

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Credentials', value: 'true' },
          { key: 'Access-Control-Allow-Origin', value: '*' },
          { key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
        ],
      },
    ]
  },
}

Redirects

// app/api/redirect/route.ts
import { redirect, permanentRedirect } from 'next/navigation'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const url = new URL(request.url)
  const destination = url.searchParams.get('to')

  // Method 1: Using next/navigation redirect (throws, 307)
  redirect('/new-location')

  // Method 2: Permanent redirect (throws, 308)
  permanentRedirect('/new-permanent-location')

  // Method 3: NextResponse.redirect (returns Response)
  return NextResponse.redirect(new URL('/target', request.url))

  // Method 4: With custom status
  return NextResponse.redirect(
    new URL('/moved', request.url),
    { status: 301 } // Moved Permanently
  )

  // Method 5: External redirect
  return NextResponse.redirect('https://example.com')
}

Non-UI Responses

XML/RSS Feed

// app/feed.xml/route.ts

export async function GET() {
  const posts = await fetchRecentPosts()

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>My Blog</title>
    <link>https://example.com</link>
    <description>Latest posts from my blog</description>
    <atom:link href="https://example.com/feed.xml" rel="self" type="application/rss+xml"/>
    <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
    ${posts.map(post => `
    <item>
      <title><![CDATA[${post.title}]]></title>
      <link>https://example.com/posts/${post.slug}</link>
      <guid isPermaLink="true">https://example.com/posts/${post.slug}</guid>
      <pubDate>${new Date(post.publishedAt).toUTCString()}</pubDate>
      <description><![CDATA[${post.excerpt}]]></description>
    </item>`).join('')}
  </channel>
</rss>`

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=3600, s-maxage=86400',
    }
  })
}

Sitemap

// app/sitemap.xml/route.ts

export async function GET() {
  const pages = await fetchAllPages()
  const posts = await fetchAllPosts()

  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://example.com</loc>
    <lastmod>${new Date().toISOString()}</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  ${pages.map(page => `
  <url>
    <loc>https://example.com${page.path}</loc>
    <lastmod>${page.updatedAt}</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.8</priority>
  </url>`).join('')}
  ${posts.map(post => `
  <url>
    <loc>https://example.com/posts/${post.slug}</loc>
    <lastmod>${post.updatedAt}</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.6</priority>
  </url>`).join('')}
</urlset>`

  return new Response(sitemap, {
    headers: {
      'Content-Type': 'application/xml',
    }
  })
}

Binary Files

// app/api/export/route.ts
import { createWriteStream } from 'fs'
import archiver from 'archiver'

export async function GET() {
  // Generate PDF
  const pdfBuffer = await generatePDF()

  return new Response(pdfBuffer, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': 'attachment; filename="report.pdf"',
    }
  })
}

// CSV export
export async function GET_CSV() {
  const data = await fetchData()

  const csv = [
    ['Name', 'Email', 'Created At'].join(','),
    ...data.map(row =>
      [row.name, row.email, row.createdAt].join(',')
    )
  ].join('\n')

  return new Response(csv, {
    headers: {
      'Content-Type': 'text/csv',
      'Content-Disposition': 'attachment; filename="export.csv"',
    }
  })
}

Production Patterns

API Response Wrapper

// lib/api-response.ts
import { NextResponse } from 'next/server'
import { ZodError } from 'zod'

type ApiResponse<T> = {
  success: true
  data: T
  meta?: Record<string, unknown>
} | {
  success: false
  error: {
    code: string
    message: string
    details?: unknown
  }
}

export function success<T>(
  data: T,
  meta?: Record<string, unknown>,
  status = 200
): NextResponse<ApiResponse<T>> {
  return NextResponse.json(
    { success: true, data, meta },
    { status }
  )
}

export function error(
  code: string,
  message: string,
  status = 400,
  details?: unknown
): NextResponse<ApiResponse<never>> {
  return NextResponse.json(
    { success: false, error: { code, message, details } },
    { status }
  )
}

export function handleApiError(err: unknown): NextResponse<ApiResponse<never>> {
  console.error('API Error:', err)

  if (err instanceof ZodError) {
    return error(
      'VALIDATION_ERROR',
      'Invalid request data',
      400,
      err.flatten()
    )
  }

  if (err instanceof NotFoundError) {
    return error('NOT_FOUND', err.message, 404)
  }

  if (err instanceof UnauthorizedError) {
    return error('UNAUTHORIZED', err.message, 401)
  }

  if (err instanceof ForbiddenError) {
    return error('FORBIDDEN', err.message, 403)
  }

  return error(
    'INTERNAL_ERROR',
    'An unexpected error occurred',
    500
  )
}

// Custom error classes
export class NotFoundError extends Error {
  constructor(message = 'Resource not found') {
    super(message)
    this.name = 'NotFoundError'
  }
}

export class UnauthorizedError extends Error {
  constructor(message = 'Authentication required') {
    super(message)
    this.name = 'UnauthorizedError'
  }
}

export class ForbiddenError extends Error {
  constructor(message = 'Access denied') {
    super(message)
    this.name = 'ForbiddenError'
  }
}

// Usage in route handler
import { success, handleApiError, NotFoundError } from '@/lib/api-response'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    const { id } = await params
    const user = await db.user.findUnique({ where: { id } })

    if (!user) {
      throw new NotFoundError(`User ${id} not found`)
    }

    return success(user)
  } catch (err) {
    return handleApiError(err)
  }
}

Authentication Wrapper

// lib/auth.ts
import { cookies } from 'next/headers'
import { type NextRequest } from 'next/server'
import { verifyToken } from './jwt'
import { error } from './api-response'

type AuthContext = {
  user: { id: string; email: string; role: string }
}

type AuthenticatedHandler = (
  request: NextRequest,
  context: { params: Promise<Record<string, string>> },
  auth: AuthContext
) => Promise<Response>

export function withAuth(handler: AuthenticatedHandler) {
  return async function(
    request: NextRequest,
    context: { params: Promise<Record<string, string>> }
  ): Promise<Response> {
    const cookieStore = await cookies()
    const token = cookieStore.get('session')?.value

    if (!token) {
      return error('UNAUTHORIZED', 'Authentication required', 401)
    }

    try {
      const payload = await verifyToken(token)
      const auth: AuthContext = {
        user: {
          id: payload.sub!,
          email: payload.email as string,
          role: payload.role as string,
        }
      }

      return handler(request, context, auth)
    } catch (err) {
      return error('UNAUTHORIZED', 'Invalid or expired token', 401)
    }
  }
}

// Role-based authorization
export function withRole(roles: string[], handler: AuthenticatedHandler) {
  return withAuth(async (request, context, auth) => {
    if (!roles.includes(auth.user.role)) {
      return error(
        'FORBIDDEN',
        `Role ${auth.user.role} cannot access this resource`,
        403
      )
    }

    return handler(request, context, auth)
  })
}

// Usage
// app/api/admin/users/route.ts
import { withRole } from '@/lib/auth'
import { success } from '@/lib/api-response'

export const GET = withRole(['admin'], async (request, context, auth) => {
  const users = await db.user.findMany()
  return success(users)
})

Rate Limiting

// lib/rate-limit.ts
import { type NextRequest } from 'next/server'
import { Redis } from '@upstash/redis'
import { Ratelimit } from '@upstash/ratelimit'
import { error } from './api-response'

const redis = Redis.fromEnv()

// Create rate limiters for different tiers
const rateLimiters = {
  anonymous: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(10, '10 s'),
    analytics: true,
  }),
  authenticated: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(100, '10 s'),
    analytics: true,
  }),
  premium: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(1000, '10 s'),
    analytics: true,
  }),
}

type RateLimitTier = keyof typeof rateLimiters

export function withRateLimit(
  tier: RateLimitTier,
  handler: (request: NextRequest) => Promise<Response>
) {
  return async function(request: NextRequest): Promise<Response> {
    const identifier =
      request.headers.get('x-user-id') ||
      request.ip ||
      'anonymous'

    const { success, limit, remaining, reset } =
      await rateLimiters[tier].limit(identifier)

    const headers = {
      'X-RateLimit-Limit': String(limit),
      'X-RateLimit-Remaining': String(remaining),
      'X-RateLimit-Reset': String(reset),
    }

    if (!success) {
      return new Response(
        JSON.stringify({
          success: false,
          error: {
            code: 'RATE_LIMITED',
            message: 'Too many requests',
          }
        }),
        {
          status: 429,
          headers: {
            ...headers,
            'Content-Type': 'application/json',
            'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
          }
        }
      )
    }

    const response = await handler(request)

    // Add rate limit headers to response
    const newHeaders = new Headers(response.headers)
    Object.entries(headers).forEach(([key, value]) => {
      newHeaders.set(key, value)
    })

    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers: newHeaders,
    })
  }
}

Request Validation

// lib/validate.ts
import { type NextRequest } from 'next/server'
import { z, ZodSchema } from 'zod'
import { error } from './api-response'

type ValidatedHandler<T> = (
  request: NextRequest,
  context: { params: Promise<Record<string, string>> },
  body: T
) => Promise<Response>

export function withValidation<T>(
  schema: ZodSchema<T>,
  handler: ValidatedHandler<T>
) {
  return async function(
    request: NextRequest,
    context: { params: Promise<Record<string, string>> }
  ): Promise<Response> {
    try {
      const body = await request.json()
      const validated = schema.parse(body)

      return handler(request, context, validated)
    } catch (err) {
      if (err instanceof z.ZodError) {
        return error(
          'VALIDATION_ERROR',
          'Invalid request body',
          400,
          err.flatten()
        )
      }

      if (err instanceof SyntaxError) {
        return error('INVALID_JSON', 'Invalid JSON body', 400)
      }

      throw err
    }
  }
}

// Usage
// app/api/users/route.ts
import { withValidation } from '@/lib/validate'
import { success } from '@/lib/api-response'
import { z } from 'zod'

const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  password: z.string().min(8),
  role: z.enum(['user', 'admin']).default('user'),
})

export const POST = withValidation(
  createUserSchema,
  async (request, context, body) => {
    const user = await db.user.create({
      data: {
        ...body,
        password: await hashPassword(body.password),
      },
    })

    return success(user, undefined, 201)
  }
)

Composable Middleware Chain

// lib/middleware-chain.ts
import { type NextRequest } from 'next/server'

type Handler = (request: NextRequest, context: any) => Promise<Response>

type Middleware = (handler: Handler) => Handler

export function compose(...middlewares: Middleware[]): Middleware {
  return (handler: Handler) => {
    return middlewares.reduceRight(
      (acc, middleware) => middleware(acc),
      handler
    )
  }
}

// Usage
// app/api/admin/data/route.ts
import { compose } from '@/lib/middleware-chain'
import { withAuth } from '@/lib/auth'
import { withRateLimit } from '@/lib/rate-limit'
import { withValidation } from '@/lib/validate'

const middleware = compose(
  withRateLimit('authenticated'),
  withAuth,
)

async function handler(request: NextRequest) {
  // Handler implementation
  return Response.json({ data: 'value' })
}

export const GET = middleware(handler)

Edge Runtime

// app/api/edge/route.ts

// Run on Edge Runtime (V8 isolates)
export const runtime = 'edge'

// Optionally specify preferred regions
export const preferredRegion = ['iad1', 'sfo1', 'fra1']

export async function GET(request: Request) {
  // Edge runtime limitations:
  // - No Node.js APIs (fs, path, child_process, etc.)
  // - No native modules
  // - Limited to Web APIs + specific polyfills

  // Available APIs:
  // - fetch
  // - Web Crypto API
  // - TextEncoder/TextDecoder
  // - URL, URLSearchParams
  // - Headers, Request, Response
  // - ReadableStream, WritableStream

  const response = await fetch('https://api.example.com/data')
  const data = await response.json()

  return Response.json(data)
}

Common Pitfalls

1. Forgetting to Await params

// ❌ Wrong - params is a Promise in Next.js 15+
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const id = params.id // undefined or Promise object
}

// ✓ Correct
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
}

2. Using Cookies/Headers Without Await

// ❌ Wrong - cookies() returns a Promise
export async function GET() {
  const cookieStore = cookies()
  const token = cookieStore.get('token') // Error
}

// ✓ Correct
export async function GET() {
  const cookieStore = await cookies()
  const token = cookieStore.get('token')
}

3. Accidentally Making Static Routes Dynamic

// This route will be dynamic because it reads cookies
export async function GET() {
  const cookieStore = await cookies()
  // Even if you don't use the cookie, reading it makes the route dynamic

  return Response.json({ static: false })
}

// Keep it static by not reading dynamic sources
export async function GET() {
  const data = await fetch('https://api.example.com/static', {
    next: { revalidate: 3600 }
  })

  return Response.json(await data.json())
}

4. Incorrect Response Content-Type

// ❌ Wrong - JSON body but no Content-Type
export async function GET() {
  return new Response('{"error": "not found"}', { status: 404 })
  // Content-Type: text/plain by default
}

// ✓ Correct
export async function GET() {
  return Response.json({ error: 'not found' }, { status: 404 })
  // or
  return new Response(JSON.stringify({ error: 'not found' }), {
    status: 404,
    headers: { 'Content-Type': 'application/json' }
  })
}

5. Blocking Operations in Edge Runtime

// ❌ Wrong - fs not available in Edge
export const runtime = 'edge'

export async function GET() {
  const fs = require('fs')
  const data = fs.readFileSync('./data.json') // Error
}

// ✓ Correct - use fetch or external APIs
export const runtime = 'edge'

export async function GET() {
  const response = await fetch('https://storage.example.com/data.json')
  const data = await response.json()
  return Response.json(data)
}

Key Takeaways

  1. Web Standards First: Route Handlers use standard Web Request/Response APIs, making them portable and familiar to developers from other frameworks.

  2. Params are Promises: In Next.js 15+, dynamic route parameters are accessed via await params, not directly.

  3. Caching by Default Changed: Next.js 15 changed GET handlers from static to dynamic by default. Use export const dynamic = 'force-static' or avoid dynamic functions to enable caching.

  4. Use NextRequest/NextResponse: These extend Web APIs with Next.js conveniences like nextUrl.searchParams and cookies.set().

  5. Edge vs Node.js Runtime: Choose Edge for low-latency, globally distributed handlers; Node.js for handlers needing native modules or filesystem access.

  6. Compose Middleware: Build reusable middleware for auth, validation, rate limiting using higher-order functions.

  7. Streaming for Long Operations: Use ReadableStream for SSE, AI responses, or large data exports.

  8. Type Safety with RouteContext: Use the global RouteContext<'/path/[param]'> helper for strongly-typed route parameters.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.