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: Advanced API Patterns

Build advanced APIs: streaming responses, middleware chaining, and error handling.

Next.jsRoute HandlersAPIBackend

By MinhVo

Introduction

Next.js Route Handlers have evolved from simple API endpoints into a full-featured backend framework capable of streaming responses, complex middleware chains, sophisticated error handling, and real-time communication. While the basic GET and POST exports cover common use cases, production APIs demand patterns that handle edge cases, maintain type safety, and scale gracefully.

The App Router's Route Handlers replace the Pages Router's API routes with a more powerful model built on the Web standard Request and Response APIs. This means your API layer is no longer Next.js-specific—it's portable, testable, and compatible with the broader ecosystem of web standards tooling.

This guide goes beyond the basics to explore advanced patterns for building production APIs with Next.js Route Handlers. We'll cover streaming responses for long-running operations, middleware composition for cross-cutting concerns, error handling strategies that don't leak internals, rate limiting, request validation, and real-time patterns using Server-Sent Events.

API Architecture

Understanding Route Handlers: Core Concepts

Route Handlers are defined in route.ts files within the app directory. Each HTTP method is exported as a named function:

// app/api/users/route.ts
export async function GET(request: Request) {
  const users = await db.users.findMany()
  return Response.json(users)
}
 
export async function POST(request: Request) {
  const body = await request.json()
  const user = await db.users.create({ data: body })
  return Response.json(user, { status: 201 })
}

Under the hood, Route Handlers run in the Edge Runtime by default (configurable to Node.js Runtime), giving you access to Request, Response, Headers, URL, and other Web APIs. This standardization means your API handlers are testable with standard fetch and don't depend on Next.js-specific request/response objects.

Dynamic Route Segments

Route Handlers support the same dynamic segment patterns as pages:

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

Route Groups and Colocation

You can organize API routes using route groups without affecting the URL structure:

app/
  api/
    (auth)/
      login/route.ts       → /api/login
      register/route.ts    → /api/register
      logout/route.ts      → /api/logout
    (resources)/
      users/route.ts       → /api/users
      posts/route.ts       → /api/posts
    (webhooks)/
      stripe/route.ts      → /api/stripe
      github/route.ts      → /api/github

This organization lets you apply middleware selectively—auth routes might need CSRF protection while webhook routes need signature verification.

Middleware Architecture

Architecture and Design Patterns

The Handler Composition Pattern

Production APIs need cross-cutting concerns applied consistently: authentication, rate limiting, request validation, logging, and error handling. Rather than duplicating this logic in every handler, compose handlers with higher-order functions:

// lib/api/compose.ts
import { NextRequest, NextResponse } from 'next/server'
 
type Handler = (
  request: NextRequest,
  context: { params: Record<string, string> }
) => Promise<Response>
 
type Middleware = (handler: Handler) => Handler
 
export function compose(...middlewares: Middleware[]) {
  return (handler: Handler): Handler => {
    return middlewares.reduceRight((next, middleware) => middleware(next), handler)
  }
}
 
// Usage
const api = compose(withAuth, withRateLimit, withValidation, withErrorHandling)
 
export const GET = api(async (request, { params }) => {
  // Handler only contains business logic
  const data = await fetchData(params.id)
  return Response.json(data)
})

The Service Layer Pattern

Route Handlers should be thin—authentication, validation, and response formatting. Business logic belongs in a service layer:

// lib/services/user-service.ts
export class UserService {
  async findById(id: string): Promise<User | null> {
    return db.users.findUnique({ where: { id } })
  }
 
  async create(data: CreateUserInput): Promise<User> {
    const hashedPassword = await bcrypt.hash(data.password, 12)
    return db.users.create({
      data: { ...data, password: hashedPassword }
    })
  }
 
  async updateProfile(id: string, data: UpdateProfileInput): Promise<User> {
    return db.users.update({ where: { id }, data })
  }
}
 
// app/api/users/route.ts
export const GET = api(async (request) => {
  const users = await userService.findAll()
  return Response.json(users)
})

Type-Safe Request Validation

Use Zod for runtime request validation with TypeScript inference:

// lib/validation/user.ts
import { z } from 'zod'
 
export const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  password: z.string().min(8).regex(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
    'Password must contain uppercase, lowercase, and number'
  ),
  role: z.enum(['user', 'admin']).default('user'),
})
 
export type CreateUserInput = z.infer<typeof CreateUserSchema>
 
// lib/api/validate.ts
export function withValidation<T>(schema: z.ZodSchema<T>) {
  return (handler: (req: NextRequest, data: T, ctx: any) => Promise<Response>) => {
    return async (request: NextRequest, context: any) => {
      const body = await request.json()
      const result = schema.safeParse(body)
 
      if (!result.success) {
        return Response.json(
          { error: 'Validation failed', details: result.error.flatten() },
          { status: 400 }
        )
      }
 
      return handler(request, result.data, context)
    }
  }
}
 
// Usage in handler
export const POST = api(
  withValidation(CreateUserSchema)(async (request, data) => {
    const user = await userService.create(data)
    return Response.json(user, { status: 201 })
  })
)

API Design Patterns

Step-by-Step Implementation

Step 1: Streaming Responses for Long-Running Operations

For operations that take time—generating reports, processing images, or querying large datasets—stream progress back to the client:

// app/api/reports/generate/route.ts
export async function POST(request: Request) {
  const { dateRange, format } = await request.json()
 
  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      try {
        // Stream progress updates
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ status: 'started', progress: 0 })}\n\n`)
        )
 
        const data = await fetchReportData(dateRange)
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ status: 'processing', progress: 50 })}\n\n`)
        )
 
        const report = await generateReport(data, format)
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ status: 'complete', progress: 100, report })}\n\n`)
        )
 
        controller.close()
      } catch (error) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ status: 'error', message: error.message })}\n\n`)
        )
        controller.close()
      }
    },
  })
 
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
}

Step 2: Rate Limiting Middleware

Implement a sliding window rate limiter that works in serverless environments:

// lib/api/rate-limit.ts
import { NextRequest } from 'next/server'
 
const rateLimit = new Map<string, { count: number; resetTime: number }>()
 
export function withRateLimit(handler: Handler, options = { limit: 100, window: 60 }) {
  return async (request: NextRequest, context: any) => {
    const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
    const key = `${ip}:${request.nextUrl.pathname}`
    const now = Date.now()
 
    const current = rateLimit.get(key)
 
    if (!current || now > current.resetTime) {
      rateLimit.set(key, { count: 1, resetTime: now + options.window * 1000 })
    } else if (current.count >= options.limit) {
      return Response.json(
        { error: 'Too many requests' },
        {
          status: 429,
          headers: {
            'Retry-After': String(Math.ceil((current.resetTime - now) / 1000)),
            'X-RateLimit-Limit': String(options.limit),
            'X-RateLimit-Remaining': '0',
          },
        }
      )
    } else {
      current.count++
    }
 
    return handler(request, context)
  }
}

Step 3: Comprehensive Error Handling

Build an error handler that catches all errors, logs them appropriately, and returns safe error responses:

// lib/api/error-handling.ts
class ApiError extends Error {
  constructor(
    message: string,
    public statusCode: number,
    public code: string,
    public details?: unknown
  ) {
    super(message)
    this.name = 'ApiError'
  }
}
 
export function withErrorHandling(handler: Handler): Handler {
  return async (request: NextRequest, context: any) => {
    try {
      return await handler(request, context)
    } catch (error) {
      // Log the full error internally
      console.error(`[API Error] ${request.method} ${request.nextUrl.pathname}:`, {
        error: error instanceof Error ? error.message : 'Unknown error',
        stack: error instanceof Error ? error.stack : undefined,
        timestamp: new Date().toISOString(),
      })
 
      if (error instanceof ApiError) {
        return Response.json(
          { error: error.code, message: error.message, details: error.details },
          { status: error.statusCode }
        )
      }
 
      if (error instanceof z.ZodError) {
        return Response.json(
          { error: 'VALIDATION_ERROR', message: 'Invalid request data', details: error.flatten() },
          { status: 400 }
        )
      }
 
      // Never expose internal errors to clients
      return Response.json(
        { error: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
        { status: 500 }
      )
    }
  }
}

Step 4: Server-Sent Events for Real-Time Updates

Implement SSE for real-time features like live notifications or status updates:

// app/api/events/route.ts
export async function GET(request: NextRequest) {
  const session = await getSession(request)
  if (!session) {
    return new Response('Unauthorized', { status: 401 })
  }
 
  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      // Send initial connection event
      controller.enqueue(
        encoder.encode(`event: connected\ndata: ${JSON.stringify({ userId: session.userId })}\n\n`)
      )
 
      // Subscribe to user-specific events
      const unsubscribe = eventBus.subscribe(session.userId, (event) => {
        controller.enqueue(
          encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`)
        )
      })
 
      // Keep-alive heartbeat
      const heartbeat = setInterval(() => {
        controller.enqueue(encoder.encode(`: heartbeat\n\n`))
      }, 30000)
 
      // Cleanup on disconnect
      request.signal.addEventListener('abort', () => {
        clearInterval(heartbeat)
        unsubscribe()
        controller.close()
      })
    },
  })
 
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
}

Step 5: Request/Response Logging Middleware

Add structured logging that captures request details without logging sensitive data:

// lib/api/logging.ts
export function withLogging(handler: Handler): Handler {
  return async (request: NextRequest, context: any) => {
    const start = Date.now()
    const requestId = crypto.randomUUID()
 
    console.log(JSON.stringify({
      type: 'request',
      requestId,
      method: request.method,
      path: request.nextUrl.pathname,
      query: Object.fromEntries(request.nextUrl.searchParams),
      userAgent: request.headers.get('user-agent'),
      timestamp: new Date().toISOString(),
    }))
 
    const response = await handler(request, context)
 
    console.log(JSON.stringify({
      type: 'response',
      requestId,
      status: response.status,
      duration: Date.now() - start,
      timestamp: new Date().toISOString(),
    }))
 
    // Add request ID to response headers for debugging
    response.headers.set('X-Request-ID', requestId)
    return response
  }
}

Real-World Use Cases

Use Case 1: File Upload with Progress Streaming

Build a file upload endpoint that streams upload progress to the client:

// app/api/upload/route.ts
export async function POST(request: NextRequest) {
  const formData = await request.formData()
  const file = formData.get('file') as File
 
  if (!file) {
    return Response.json({ error: 'No file provided' }, { status: 400 })
  }
 
  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      const buffer = await file.arrayBuffer()
      let uploaded = 0
      const chunkSize = 1024 * 1024 // 1MB chunks
 
      while (uploaded < buffer.byteLength) {
        const chunk = buffer.slice(uploaded, uploaded + chunkSize)
        await uploadChunk(chunk, uploaded)
        uploaded += chunk.byteLength
 
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({
            progress: Math.round((uploaded / buffer.byteLength) * 100),
          })}\n\n`)
        )
      }
 
      const url = await getFileUrl(file.name)
      controller.enqueue(
        encoder.encode(`data: ${JSON.stringify({ complete: true, url })}\n\n`)
      )
      controller.close()
    },
  })
 
  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream' },
  })
}

Use Case 2: Webhook Processing with Signature Verification

Securely process webhooks from payment providers:

// app/api/webhooks/stripe/route.ts
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) {
    console.error('Webhook signature verification failed:', err)
    return Response.json({ error: 'Invalid signature' }, { status: 400 })
  }
 
  // Process event asynchronously
  await processWebhookEvent(event)
 
  return Response.json({ received: true })
}

Use Case 3: GraphQL Endpoint

Implement a GraphQL API using Route Handlers:

// app/api/graphql/route.ts
import { createYoga, createSchema } from 'graphql-yoga'
 
const yoga = createYoga({
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type Query {
        user(id: ID!): User
        users: [User!]!
      }
      type User {
        id: ID!
        name: String!
        email: String!
      }
    `,
    resolvers: {
      Query: {
        user: (_, { id }) => db.users.findUnique({ where: { id } }),
        users: () => db.users.findMany(),
      },
    },
  }),
  graphqlEndpoint: '/api/graphql',
})
 
export async function GET(request: Request) {
  return yoga.fetch(request)
}
 
export async function POST(request: Request) {
  return yoga.fetch(request)
}

Use Case 4: Batch API Operations

Implement a batch endpoint that processes multiple operations in a single request:

// app/api/batch/route.ts
export async function POST(request: NextRequest) {
  const { operations } = await request.json()
 
  if (!Array.isArray(operations) || operations.length > 10) {
    return Response.json(
      { error: 'Batch must contain 1-10 operations' },
      { status: 400 }
    )
  }
 
  const results = await Promise.allSettled(
    operations.map(async (op: { method: string; path: string; body?: any }) => {
      const response = await handleInternalRequest(op)
      return { status: response.status, data: await response.json() }
    })
  )
 
  return Response.json({
    results: results.map((result) =>
      result.status === 'fulfilled'
        ? result.value
        : { status: 500, error: 'Operation failed' }
    ),
  })
}

API Testing

Best Practices for Production

  1. Always validate input with Zod: Never trust client input. Validate every request body, query parameter, and path parameter with Zod schemas that generate TypeScript types automatically.

  2. Use the Web standard Request/Response APIs: Avoid Next.js-specific extensions. Standard APIs are testable with any HTTP client and portable across runtimes.

  3. Implement proper CORS headers: Configure CORS in middleware.ts or per-route. Never use Access-Control-Allow-Origin: * in production—specify allowed origins explicitly.

  4. Return consistent error shapes: Every error response should have the same structure: { error: string, message: string, details?: unknown }. Clients should be able to parse errors generically.

  5. Set appropriate Cache-Control headers: GET endpoints should specify caching behavior. Use Cache-Control: private, no-cache for authenticated endpoints and public, s-maxage=60 for public data.

  6. Use NextRequest instead of Request: NextRequest extends the standard Request with helper methods for cookies, geo-location, and IP address without manual header parsing.

  7. Handle request body parsing errors: Wrap request.json() in try-catch. Malformed JSON should return a 400, not crash the handler with a 500.

  8. Implement idempotency for mutation endpoints: Use idempotency keys for POST/PUT operations to safely handle retries. Store keys in a short-lived cache with their results.

  9. Version your API from day one: Use /api/v1/users even for v1. When you need breaking changes, /api/v2/users can coexist without disrupting existing clients.

  10. Add request timeouts: Set maximum execution times for handlers. If a handler runs longer than 10 seconds, it's likely stuck—return a 504 and log the timeout for investigation.

Common Pitfalls and Solutions

PitfallImpactSolution
Not handling request.json() parse errors500 errors from malformed requestsWrap in try-catch, return 400 with clear error message
Exposing internal error detailsSecurity vulnerability—stack traces reveal implementation detailsLog errors internally, return generic messages to clients
Missing Content-Type headersClients can't parse responses correctlyAlways set Content-Type: application/json when returning JSON
No rate limiting on auth endpointsBrute force attacks on login/registerImplement strict rate limits (5-10/minute) on authentication routes
Synchronous database operations blocking responsesSlow response times, connection pool exhaustionUse async/await with connection pooling; consider read replicas for read-heavy routes
Forgetting to handle OPTIONS requestsCORS preflight failuresNext.js handles OPTIONS automatically, but add custom headers in middleware if needed

Performance Optimization

Optimize Route Handler performance with caching and connection management:

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

Use response caching for expensive queries:

// lib/api/cache.ts
const cache = new Map<string, { data: any; expiry: number }>()
 
export function withCache(handler: Handler, ttl: number = 60): Handler {
  return async (request: NextRequest, context: any) => {
    if (request.method !== 'GET') {
      return handler(request, context)
    }
 
    const key = request.nextUrl.toString()
    const cached = cache.get(key)
 
    if (cached && Date.now() < cached.expiry) {
      return Response.json(cached.data, {
        headers: { 'X-Cache': 'HIT' },
      })
    }
 
    const response = await handler(request, context)
    const data = await response.json()
 
    cache.set(key, { data, expiry: Date.now() + ttl * 1000 })
 
    return Response.json(data, {
      headers: { 'X-Cache': 'MISS' },
    })
  }
}

Comparison with Alternatives

FeatureNext.js Route HandlersExpress/Fastify APItRPCGraphQL (Yoga)
Type SafetyManual (Zod recommended)ManualAutomaticSchema-based
StreamingBuilt-in (Web Streams)ManualLimitedSubscriptions
Edge DeploymentSupportedNot nativeSupportedSupported
MiddlewareManual compositionBuilt-inMiddlewarePlugins
CachingISR integrationManualManualManual
Bundle SizeZero (colocated)Separate deploymentClient+ServerClient+Server
Learning CurveLowLowModerateModerate

Advanced Patterns

Optimistic Locking for Concurrent Updates

Prevent lost updates when multiple clients modify the same resource:

// app/api/posts/[id]/route.ts
export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const { id } = await params
  const body = await request.json()
  const ifMatch = request.headers.get('If-Match')
 
  if (!ifMatch) {
    return Response.json(
      { error: 'If-Match header required for updates' },
      { status: 428 }
    )
  }
 
  const current = await db.posts.findUnique({ where: { id } })
 
  if (!current) {
    return Response.json({ error: 'Post not found' }, { status: 404 })
  }
 
  if (current.version !== parseInt(ifMatch)) {
    return Response.json(
      { error: 'Resource has been modified', currentVersion: current.version },
      { status: 409 }
    )
  }
 
  const updated = await db.posts.update({
    where: { id },
    data: { ...body, version: current.version + 1 },
  })
 
  return Response.json(updated, {
    headers: { ETag: String(updated.version) },
  })
}

Composable Middleware Chains

Build middleware that can be combined and reused:

// lib/api/middleware.ts
type MiddlewareFn = (
  request: NextRequest,
  context: { params: Record<string, string>; state: Record<string, any> },
  next: () => Promise<Response>
) => Promise<Response>
 
export function chain(...middlewares: MiddlewareFn[]) {
  return async (request: NextRequest, context: { params: Record<string, string> }) => {
    const state: Record<string, any> = {}
    let index = 0
 
    const next = async (): Promise<Response> => {
      if (index >= middlewares.length) {
        return new Response('Not Found', { status: 404 })
      }
      const middleware = middlewares[index++]
      return middleware(request, { ...context, state }, next)
    }
 
    return next()
  }
}
 
// Usage
const handler = chain(
  withAuth,
  withRateLimit({ limit: 100 }),
  withValidation(CreatePostSchema),
  async (request, { state }) => {
    // state.user from withAuth, state.validatedData from withValidation
    const post = await postService.create(state.validatedData, state.user.id)
    return Response.json(post, { status: 201 })
  }
)

Testing Strategies

// __tests__/api/users.test.ts
import { GET, POST } from '@/app/api/users/route'
import { NextRequest } from 'next/server'
 
describe('/api/users', () => {
  describe('GET', () => {
    it('returns paginated users', async () => {
      const request = new NextRequest('http://localhost:3000/api/users?page=1&limit=10')
      const response = await GET(request)
      const data = await response.json()
 
      expect(response.status).toBe(200)
      expect(data.users).toBeInstanceOf(Array)
      expect(data.users.length).toBeLessThanOrEqual(10)
      expect(data.pagination).toBeDefined()
    })
 
    it('returns 400 for invalid query parameters', async () => {
      const request = new NextRequest('http://localhost:3000/api/users?page=-1')
      const response = await GET(request)
 
      expect(response.status).toBe(400)
    })
  })
 
  describe('POST', () => {
    it('creates a user with valid data', async () => {
      const request = new NextRequest('http://localhost:3000/api/users', {
        method: 'POST',
        body: JSON.stringify({
          email: 'test@example.com',
          name: 'Test User',
          password: 'StrongPass123',
        }),
        headers: { 'Content-Type': 'application/json' },
      })
 
      const response = await POST(request)
      const data = await response.json()
 
      expect(response.status).toBe(201)
      expect(data.email).toBe('test@example.com')
      expect(data.password).toBeUndefined() // Never return passwords
    })
 
    it('returns 400 for invalid email', async () => {
      const request = new NextRequest('http://localhost:3000/api/users', {
        method: 'POST',
        body: JSON.stringify({ email: 'invalid', name: 'Test', password: 'StrongPass123' }),
        headers: { 'Content-Type': 'application/json' },
      })
 
      const response = await POST(request)
      expect(response.status).toBe(400)
    })
  })
})

Future Outlook

Next.js Route Handlers are converging with the broader web platform. The WinterCG initiative is standardizing server-side Web APIs across runtimes (Node.js, Deno, Cloudflare Workers, Bun), meaning your Route Handlers will become increasingly portable.

React's ongoing work on Server Actions creates interesting dynamics with Route Handlers. While Server Actions handle form mutations naturally, Route Handlers remain essential for webhooks, public APIs, SSE, and any endpoint consumed by non-React clients. Expect these two patterns to coexist with clearer guidelines on when to use each.

The Edge Runtime continues to gain Node.js API compatibility, making Route Handlers viable for more complex backend logic. Combined with edge databases (Turso, Neon at the edge) and edge caching (Vercel KV, Cloudflare KV), the pattern of deploying API logic at the edge is becoming the default rather than the exception.

Conclusion

Next.js Route Handlers provide a powerful, standards-based foundation for building production APIs. By applying composition patterns for middleware, proper error handling, request validation, and streaming responses, you can build APIs that are secure, performant, and maintainable.

Key takeaways:

  1. Route Handlers use Web standard Request/Response APIs, making them portable and testable
  2. Compose handlers with higher-order functions for cross-cutting concerns (auth, validation, rate limiting, logging)
  3. Use Zod for runtime request validation with TypeScript type inference
  4. Implement streaming responses for long-running operations and real-time updates via SSE
  5. Never expose internal error details—log server-side, return generic messages to clients
  6. Structure your API with a thin handler layer backed by a service layer for business logic
  7. Test Route Handlers using standard Request objects without needing a running server

Start by applying the composition pattern to your existing Route Handlers. Extract authentication, validation, and error handling into reusable middleware. The investment pays off immediately in code consistency and reduced duplication across your API layer.