MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js Route Handlers: Building APIs in the App Router

Create API routes with Route Handlers: GET, POST, middleware, and streaming responses.

Next.jsAPI RoutesRoute HandlersBackend

By MinhVo

Introduction

The App Router introduced a fundamentally different approach to building APIs in Next.js. Route Handlers replaced the Pages Router's API routes with a model built on Web standards, offering better type safety, colocated file organization, and native support for streaming and edge deployment. For developers building full-stack Next.js applications, understanding Route Handlers is essential for creating backend logic that integrates seamlessly with Server Components and Server Actions.

Unlike Pages Router API routes that received a modified NextApiRequest and NextApiResponse, Route Handlers work with the standard Request and Response objects from the Web Fetch API. This means your API layer is portable across runtimes, testable with standard HTTP clients, and compatible with the growing ecosystem of Web API tooling.

This guide covers the complete journey from basic CRUD operations to advanced patterns like streaming, file uploads, and webhook processing. We'll build a realistic API layer for a content management system that demonstrates the patterns you'll use in production applications.

Full-Stack Architecture

Understanding Route Handlers: Core Concepts

File-Based API Routing

Route Handlers follow the same file-system conventions as pages. A route.ts file in a directory defines the API endpoint for that path:

app/
  api/
    posts/
      route.ts           → GET /api/posts, POST /api/posts
      [id]/
        route.ts         → GET /api/posts/:id, PUT /api/posts/:id, DELETE /api/posts/:id
      [id]/comments/
        route.ts         → GET /api/posts/:id/comments, POST /api/posts/:id/comments
    users/
      route.ts           → GET /api/users, POST /api/users
    health/
      route.ts           → GET /api/health

Each route.ts exports named functions for the HTTP methods it supports:

// app/api/posts/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') || '1')
  const limit = parseInt(searchParams.get('limit') || '10')
 
  const posts = await db.posts.findMany({
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: 'desc' },
  })
 
  return Response.json(posts)
}
 
export async function POST(request: Request) {
  const body = await request.json()
  const post = await db.posts.create({ data: body })
  return Response.json(post, { status: 201 })
}

Dynamic Segments and Type Safety

Dynamic segments are available through the second parameter of handler functions:

// app/api/posts/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const { id } = await params
  const post = await db.posts.findUnique({
    where: { id },
    include: { author: true, tags: true },
  })
 
  if (!post) {
    return Response.json(
      { error: 'Post not found' },
      { status: 404 }
    )
  }
 
  return Response.json(post)
}

Catch-All Segments

For deeply nested APIs, catch-all segments capture multiple path levels:

// app/api/docs/[...slug]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { slug: string[] } }
) {
  const { slug } = await params
  const path = slug.join('/') // e.g., ['guides', 'getting-started'] → 'guides/getting-started'
 
  const document = await findDocumentByPath(path)
  if (!document) {
    return Response.json({ error: 'Document not found' }, { status: 404 })
  }
 
  return Response.json(document)
}

The Cookies and Headers APIs

Route Handlers can read request cookies and headers using the Web API:

// app/api/me/route.ts
export async function GET(request: Request) {
  const cookieHeader = request.headers.get('cookie') || ''
  const token = parseCookie(cookieHeader, 'session-token')
 
  if (!token) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const session = await verifySession(token)
  const user = await db.users.findUnique({ where: { id: session.userId } })
 
  return Response.json(user)
}

For Next.js-specific cookie handling, use the cookies() function from next/headers:

import { cookies, headers } from 'next/headers'
 
export async function GET() {
  const cookieStore = await cookies()
  const token = cookieStore.get('session-token')
 
  const headerStore = await headers()
  const userAgent = headerStore.get('user-agent')
 
  return Response.json({ hasToken: !!token, userAgent })
}

API Development Workflow

Architecture and Design Patterns

Separating Concerns with a Service Layer

Keep Route Handlers thin by delegating business logic to service modules:

// lib/services/post-service.ts
import { db } from '@/lib/db'
import { slugify } from '@/lib/utils'
 
export const postService = {
  async findAll(options: { page: number; limit: number; tag?: string }) {
    const where = options.tag ? { tags: { some: { slug: options.tag } } } : {}
 
    const [posts, total] = await Promise.all([
      db.posts.findMany({
        where,
        skip: (options.page - 1) * options.limit,
        take: options.limit,
        orderBy: { createdAt: 'desc' },
        include: { author: { select: { name: true, avatar: true } } },
      }),
      db.posts.count({ where }),
    ])
 
    return {
      posts,
      pagination: {
        page: options.page,
        limit: options.limit,
        total,
        pages: Math.ceil(total / options.limit),
      },
    }
  },
 
  async create(data: CreatePostInput, authorId: string) {
    return db.posts.create({
      data: {
        ...data,
        slug: slugify(data.title),
        authorId,
      },
    })
  },
 
  async update(id: string, data: UpdatePostInput, userId: string) {
    const post = await db.posts.findUnique({ where: { id } })
 
    if (!post) throw new ApiError('Post not found', 404)
    if (post.authorId !== userId) throw new ApiError('Forbidden', 403)
 
    return db.posts.update({ where: { id }, data })
  },
 
  async delete(id: string, userId: string) {
    const post = await db.posts.findUnique({ where: { id } })
 
    if (!post) throw new ApiError('Post not found', 404)
    if (post.authorId !== userId) throw new ApiError('Forbidden', 403)
 
    return db.posts.delete({ where: { id } })
  },
}

Request Validation with Zod

Define validation schemas that generate both runtime validators and TypeScript types:

// lib/validation/post.ts
import { z } from 'zod'
 
export const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  excerpt: z.string().max(500).optional(),
  tags: z.array(z.string()).max(10).default([]),
  published: z.boolean().default(false),
  coverImage: z.string().url().optional(),
})
 
export const UpdatePostSchema = CreatePostSchema.partial()
 
export type CreatePostInput = z.infer<typeof CreatePostSchema>
export type UpdatePostInput = z.infer<typeof UpdatePostSchema>
 
// lib/api/validate.ts
export async function validateRequest<T>(
  request: Request,
  schema: z.ZodSchema<T>
): Promise<{ success: true; data: T } | { success: false; error: Response }> {
  try {
    const body = await request.json()
    const result = schema.safeParse(body)
 
    if (!result.success) {
      return {
        success: false,
        error: Response.json(
          {
            error: 'Validation failed',
            details: result.error.issues.map((issue) => ({
              path: issue.path.join('.'),
              message: issue.message,
            })),
          },
          { status: 400 }
        ),
      }
    }
 
    return { success: true, data: result.data }
  } catch {
    return {
      success: false,
      error: Response.json(
        { error: 'Invalid JSON body' },
        { status: 400 }
      ),
    }
  }
}

Centralized Error Handling

Create a wrapper that catches all errors and formats responses consistently:

// lib/api/handler.ts
type RouteHandler = (
  request: Request,
  context: { params: Record<string, string> }
) => Promise<Response>
 
export function createHandler(handler: RouteHandler): RouteHandler {
  return async (request, context) => {
    try {
      return await handler(request, context)
    } catch (error) {
      console.error(`[API Error] ${request.method} ${new URL(request.url).pathname}:`, error)
 
      if (error instanceof ApiError) {
        return Response.json(
          { error: error.code, message: error.message },
          { status: error.statusCode }
        )
      }
 
      return Response.json(
        { error: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
        { status: 500 }
      )
    }
  }
}

Database Architecture

Step-by-Step Implementation

Step 1: Set Up the Database Connection

Use a singleton Prisma client for development and production:

// lib/db.ts
import { PrismaClient } from '@prisma/client'
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}
 
export const db =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
  })
 
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db

Step 2: Build the Posts CRUD API

Implement complete CRUD operations with validation and error handling:

// app/api/posts/route.ts
import { CreatePostSchema } from '@/lib/validation/post'
import { postService } from '@/lib/services/post-service'
import { createHandler, validateRequest } from '@/lib/api'
import { getSession } from '@/lib/auth'
 
export const GET = createHandler(async (request) => {
  const { searchParams } = new URL(request.url)
  const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
  const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') || '10')))
  const tag = searchParams.get('tag') || undefined
 
  const result = await postService.findAll({ page, limit, tag })
 
  return Response.json(result, {
    headers: {
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
    },
  })
})
 
export const POST = createHandler(async (request) => {
  const session = await getSession(request)
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const validation = await validateRequest(request, CreatePostSchema)
  if (!validation.success) {
    return validation.error
  }
 
  const post = await postService.create(validation.data, session.userId)
 
  return Response.json(post, {
    status: 201,
    headers: {
      'Location': `/api/posts/${post.id}`,
    },
  })
})

Step 3: Implement the Single Post Endpoint

// app/api/posts/[id]/route.ts
import { UpdatePostSchema } from '@/lib/validation/post'
import { postService } from '@/lib/services/post-service'
import { createHandler, validateRequest } from '@/lib/api'
import { getSession } from '@/lib/auth'
 
export const GET = createHandler(async (request, { params }) => {
  const { id } = await params
  const post = await postService.findById(id)
 
  if (!post) {
    return Response.json({ error: 'Post not found' }, { status: 404 })
  }
 
  return Response.json(post, {
    headers: {
      'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
      'ETag': `"${post.updatedAt.getTime()}"`,
    },
  })
})
 
export const PUT = createHandler(async (request, { params }) => {
  const { id } = await params
  const session = await getSession(request)
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const validation = await validateRequest(request, UpdatePostSchema)
  if (!validation.success) {
    return validation.error
  }
 
  const post = await postService.update(id, validation.data, session.userId)
  return Response.json(post)
})
 
export const DELETE = createHandler(async (request, { params }) => {
  const { id } = await params
  const session = await getSession(request)
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  await postService.delete(id, session.userId)
  return new Response(null, { status: 204 })
})

Step 4: Add File Upload Support

Handle multipart form data for image uploads:

// app/api/upload/route.ts
import { put } from '@vercel/blob'
import { createHandler } from '@/lib/api'
import { getSession } from '@/lib/auth'
 
export const POST = createHandler(async (request) => {
  const session = await getSession(request)
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const formData = await request.formData()
  const file = formData.get('file') as File | null
 
  if (!file) {
    return Response.json({ error: 'No file provided' }, { status: 400 })
  }
 
  // Validate file type and size
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
  if (!allowedTypes.includes(file.type)) {
    return Response.json(
      { error: 'Invalid file type. Allowed: JPEG, PNG, WebP, GIF' },
      { status: 400 }
    )
  }
 
  const maxSize = 5 * 1024 * 1024 // 5MB
  if (file.size > maxSize) {
    return Response.json(
      { error: 'File too large. Maximum size: 5MB' },
      { status: 400 }
    )
  }
 
  const blob = await put(`uploads/${session.userId}/${file.name}`, file, {
    access: 'public',
  })
 
  return Response.json({
    url: blob.url,
    filename: file.name,
    size: file.size,
    type: file.type,
  })
})

Step 5: Implement Webhook Endpoints

Process incoming webhooks with signature verification:

// app/api/webhooks/[provider]/route.ts
import { createHandler } from '@/lib/api'
import { verifyStripeSignature, verifyGitHubSignature } from '@/lib/webhooks'
 
export const POST = createHandler(async (request, { params }) => {
  const { provider } = await params
  const body = await request.text()
  const signature = request.headers.get('x-signature') || request.headers.get('stripe-signature') || ''
 
  let verified = false
  let event: any
 
  switch (provider) {
    case 'stripe':
      try {
        event = verifyStripeSignature(body, signature)
        verified = true
      } catch (e) {
        return Response.json({ error: 'Invalid signature' }, { status: 400 })
      }
      break
 
    case 'github':
      verified = verifyGitHubSignature(body, signature)
      if (!verified) {
        return Response.json({ error: 'Invalid signature' }, { status: 400 })
      }
      event = JSON.parse(body)
      break
 
    default:
      return Response.json({ error: 'Unknown provider' }, { status: 400 })
  }
 
  // Process event asynchronously
  await processWebhookEvent(provider, event)
 
  return Response.json({ received: true })
})

Real-World Use Cases

Use Case 1: REST API for a Blog Platform

A content management system needs CRUD endpoints for posts, comments, users, and media. Route Handlers provide the API layer while Server Components consume it for server-rendered pages. The same API can serve a mobile app or third-party integrations.

Use Case 2: Payment Processing Webhooks

Stripe, PayPal, and other payment providers send webhooks for payment events. Route Handlers are ideal because they can verify signatures, process events idempotently, and respond quickly while queuing heavy processing for background jobs.

Use Case 3: Real-Time Data Endpoints

Server-Sent Events (SSE) via Route Handlers enable real-time features like live notifications, chat messages, or dashboard updates. The streaming response keeps the connection open and pushes data to the client as events occur.

Use Case 4: Internal Microservice Gateway

In a microservice architecture, Next.js Route Handlers can serve as a Backend-for-Frontend (BFF) layer, aggregating data from multiple internal services into a single optimized response for the client.

API Gateway Pattern

Best Practices for Production

  1. Export named functions for each HTTP method: Don't use a single default export that checks request.method. Named exports (GET, POST, PUT, DELETE) enable Next.js to optimize each method independently and are clearer to read.

  2. Validate every request body: Use Zod schemas for runtime validation. Never trust client input—even if your frontend validates, malicious clients can send anything.

  3. Use proper HTTP status codes: 200 for success, 201 for created, 204 for no content (deletes), 400 for bad input, 401 for unauthenticated, 403 for unauthorized, 404 for not found, 409 for conflicts, 429 for rate limits, 500 for server errors.

  4. Set Cache-Control headers explicitly: Route Handlers are not cached by default. For GET endpoints that serve public data, set appropriate cache headers to leverage CDN caching.

  5. Use Response.json() consistently: Don't construct new Response(JSON.stringify(data)). The Response.json() method handles content-type headers and encoding automatically.

  6. Handle concurrent requests safely: In serverless environments, each request runs in its own context. Don't rely on in-memory state for cross-request data. Use external stores (Redis, database) for shared state.

  7. Return Location headers for created resources: When POST creates a resource, include a Location header pointing to the new resource's URL. This follows HTTP conventions and helps clients navigate.

  8. Support pagination for list endpoints: Always paginate collections. Return pagination metadata (page, limit, total, pages) alongside the data. Cap the maximum limit to prevent abuse.

  9. Use ETag for conditional requests: Include ETag headers on GET responses. Clients can send If-None-Match to avoid re-downloading unchanged data, saving bandwidth.

  10. Keep handlers under 50 lines: If a handler is getting long, extract business logic into the service layer. Handlers should validate input, call services, and format output—nothing more.

Common Pitfalls and Solutions

PitfallImpactSolution
Using request.json() without try-catch500 error on malformed JSON requestsAlways wrap in try-catch, return 400 with clear error
Returning sensitive data in responsesSecurity breach—passwords, tokens exposedDefine explicit response types, omit sensitive fields in queries
No pagination on list endpointsMemory exhaustion, slow responses with large datasetsImplement cursor or offset pagination with configurable limits
Forgetting async params in Next.js 15Undefined parameter valuesAlways await params before accessing properties: const { id } = await params
In-memory rate limiting in serverlessRate limits reset per-invocation, ineffectiveUse Redis or similar external store for distributed rate limiting
Missing Content-Type for non-JSON responsesClient misinterprets response formatSet appropriate Content-Type: text/csv, application/pdf, etc.

Performance Optimization

Database Query Optimization

Minimize database round-trips with selective field inclusion and proper indexing:

// Bad: Fetches all fields, including large text content
const posts = await db.posts.findMany()
 
// Good: Select only needed fields
const posts = await db.posts.findMany({
  select: {
    id: true,
    title: true,
    slug: true,
    excerpt: true,
    publishedAt: true,
    author: { select: { name: true, avatar: true } },
    _count: { select: { comments: true } },
  },
  orderBy: { publishedAt: 'desc' },
  take: 20,
})

Response Compression

Enable gzip compression for large JSON responses:

// next.config.ts
const nextConfig = {
  compress: true, // Enabled by default
  experimental: {
    serverActions: {
      bodySizeLimit: '2mb',
    },
  },
}

For streaming responses, use chunked transfer encoding to send data progressively without buffering the entire response.

Comparison with Alternatives

FeatureNext.js Route HandlersExpress.jstRPCGraphQL
Setup ComplexityZero—file-basednpm install + configSchema + router setupSchema + resolvers
Type SafetyManual (Zod)NoneAutomaticSchema-based
Edge RuntimeNativeNot supportedSupportedSupported
ColocationWith pages/componentsSeparate serverShared typesSeparate server
StreamingNative Web StreamsManualLimitedSubscriptions
CachingISR integrationManualManualComplex
TestingStandard fetchSupertesttRPC clientGraphQL client

Advanced Patterns

Conditional Responses with ETag

Implement conditional GET to save bandwidth:

export const GET = createHandler(async (request, { params }) => {
  const { id } = await params
  const post = await postService.findById(id)
 
  if (!post) {
    return Response.json({ error: 'Not found' }, { status: 404 })
  }
 
  const etag = `"${post.updatedAt.getTime()}"`
  const ifNoneMatch = request.headers.get('if-none-match')
 
  if (ifNoneMatch === etag) {
    return new Response(null, { status: 304 })
  }
 
  return Response.json(post, {
    headers: {
      ETag: etag,
      'Cache-Control': 'private, max-age=0, must-revalidate',
    },
  })
})

Request Deduplication

Prevent duplicate processing of identical requests:

const pendingRequests = new Map<string, Promise<Response>>()
 
export function withDeduplication(handler: RouteHandler): RouteHandler {
  return async (request, context) => {
    if (request.method !== 'GET') {
      return handler(request, context)
    }
 
    const key = request.url
    const existing = pendingRequests.get(key)
 
    if (existing) {
      return existing.then((response) => response.clone())
    }
 
    const promise = handler(request, context)
    pendingRequests.set(key, promise)
 
    promise.finally(() => pendingRequests.delete(key))
 
    return promise
  }
}

Testing Strategies

// __tests__/api/posts.test.ts
import { GET, POST } from '@/app/api/posts/route'
import { GET as GET_BY_ID, PUT, DELETE } from '@/app/api/posts/[id]/route'
import { NextRequest } from 'next/server'
 
const mockPost = {
  id: '1',
  title: 'Test Post',
  slug: 'test-post',
  content: 'Test content that is long enough',
  authorId: 'user-1',
}
 
describe('Posts API', () => {
  describe('GET /api/posts', () => {
    it('returns paginated posts', async () => {
      const request = new NextRequest('http://localhost:3000/api/posts?page=1&limit=5')
      const response = await GET(request)
      const data = await response.json()
 
      expect(response.status).toBe(200)
      expect(data.posts).toBeDefined()
      expect(data.pagination).toBeDefined()
    })
 
    it('filters by tag', async () => {
      const request = new NextRequest('http://localhost:3000/api/posts?tag=nextjs')
      const response = await GET(request)
 
      expect(response.status).toBe(200)
    })
  })
 
  describe('POST /api/posts', () => {
    it('creates a post with valid data', async () => {
      const request = new NextRequest('http://localhost:3000/api/posts', {
        method: 'POST',
        body: JSON.stringify({
          title: 'New Post',
          content: 'This is a valid post content with enough characters.',
          published: true,
        }),
        headers: { 'Content-Type': 'application/json' },
      })
 
      const response = await POST(request)
 
      expect(response.status).toBe(201)
      expect(response.headers.get('Location')).toBeTruthy()
    })
 
    it('returns 400 for missing title', async () => {
      const request = new NextRequest('http://localhost:3000/api/posts', {
        method: 'POST',
        body: JSON.stringify({ content: 'Content only' }),
        headers: { 'Content-Type': 'application/json' },
      })
 
      const response = await POST(request)
      expect(response.status).toBe(400)
    })
  })
 
  describe('GET /api/posts/[id]', () => {
    it('returns a single post', async () => {
      const response = await GET_BY_ID(
        new NextRequest('http://localhost:3000/api/posts/1'),
        { params: { id: '1' } }
      )
 
      expect(response.status).toBe(200)
    })
 
    it('returns 404 for non-existent post', async () => {
      const response = await GET_BY_ID(
        new NextRequest('http://localhost:3000/api/posts/nonexistent'),
        { params: { id: 'nonexistent' } }
      )
 
      expect(response.status).toBe(404)
    })
  })
})

Future Outlook

Route Handlers continue to evolve alongside the Web platform. The WinterCG initiative is standardizing server-side APIs across JavaScript runtimes, meaning your Route Handlers will run identically on Node.js, Deno, Bun, and Cloudflare Workers. This portability eliminates vendor lock-in and simplifies deployment decisions.

Server Actions are gaining ground for mutations initiated from React components, but Route Handlers remain essential for public APIs, webhooks, SSE, and any endpoint consumed by non-React clients. The two patterns are complementary—use Server Actions for form mutations within your app and Route Handlers for everything else.

Edge databases and edge caching are making it practical to run entire API layers at the edge. With databases like Turso, Neon, and PlanetScale offering edge-compatible drivers, Route Handlers deployed to edge runtimes can achieve single-digit millisecond response times globally.

Conclusion

Route Handlers provide a clean, standards-based API layer for Next.js applications. Their integration with the file system, Web standard APIs, and the broader Next.js ecosystem (Server Components, middleware, ISR) makes them the natural choice for backend logic in full-stack Next.js apps.

Key takeaways:

  1. Route Handlers use Web standard Request/Response APIs, making them portable and testable
  2. Use file-system routing for API endpoints—route.ts files colocate with related pages
  3. Keep handlers thin by extracting business logic into service modules
  4. Validate all input with Zod schemas for runtime type safety
  5. Implement consistent error handling with a wrapper that catches and formats all errors
  6. Set appropriate Cache-Control headers to leverage CDN caching for GET endpoints
  7. Always paginate list endpoints and cap maximum page sizes

Start by converting your Pages Router API routes to Route Handlers. The migration is straightforward—replace NextApiRequest/NextApiResponse with Web standard APIs, move to named exports for HTTP methods, and colocate handlers with their related pages. The result is a cleaner, more maintainable API layer that integrates naturally with the rest of your App Router application.