Skip to content

Building Scalable APIs with Next.js Route Handlers

A complete guide to designing scalable, production-ready APIs with Next.js Route Handlers, TypeScript, validation and error-handling patterns.

2 min read
API architecture diagram showing request flow through Next.js route handlers

Introduction

Building APIs that scale isn't just about handling more requests — it's about designing systems that remain maintainable, testable, and reliable as your application grows. In this post, I'll walk through the patterns I use when building production APIs with Next.js Route Handlers.

Why Next.js Route Handlers?

Next.js App Router introduced Route Handlers as a first-class way to build API endpoints. They offer several advantages over the legacy API routes:

  • Colocation — API routes live alongside your pages in the app directory
  • Web Standard APIs — Built on Request and Response objects
  • Edge Runtime Support — Deploy closer to your users
  • Static & Dynamic — Choose the right rendering strategy per route

Setting Up a Scalable Structure

Here's how I organize API routes in large projects:

tstypescript
// src/app/api/v1/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
 
const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  tags: z.array(z.string()).max(10).optional(),
});
 
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const validated = createPostSchema.parse(body);
 
    // Process the validated data
    const post = await createPost(validated);
 
    return NextResponse.json(post, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { errors: error.flatten().fieldErrors },
        { status: 400 },
      );
    }
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 },
    );
  }
}

Validation at the Boundary

One principle I follow religiously: validate at system boundaries, trust internally. Every external input — request bodies, query parameters, headers — gets validated with Zod schemas before touching business logic.

tstypescript
const querySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(["date", "title", "popularity"]).default("date"),
});
 
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const query = querySchema.parse(Object.fromEntries(searchParams));
 
  const posts = await getPosts(query);
  return NextResponse.json(posts);
}

Error Handling Patterns

A consistent error handling strategy makes debugging and client integration much smoother:

tstypescript
class AppError extends Error {
  constructor(
    message: string,
    public statusCode: number,
    public code: string,
  ) {
    super(message);
  }
}
 
function handleError(error: unknown): NextResponse {
  if (error instanceof AppError) {
    return NextResponse.json(
      { error: error.message, code: error.code },
      { status: error.statusCode },
    );
  }
 
  console.error("Unhandled error:", error);
  return NextResponse.json(
    { error: "Internal server error", code: "INTERNAL_ERROR" },
    { status: 500 },
  );
}

Rate Limiting

For production APIs, rate limiting is essential. Here's a simple in-memory approach suitable for serverless:

tstypescript
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
 
function rateLimit(ip: string, maxRequests = 60, windowMs = 60000): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(ip);
 
  if (!entry || now > entry.resetAt) {
    rateLimitMap.set(ip, { count: 1, resetAt: now + windowMs });
    return true;
  }
 
  if (entry.count >= maxRequests) return false;
 
  entry.count++;
  return true;
}

Key Takeaways

  1. Validate at boundaries — Use Zod schemas for all external input
  2. Consistent error responses — Standardize your error format early
  3. Rate limit everything — Protect your APIs from abuse
  4. Type everything — TypeScript catches bugs before they reach production
  5. Keep handlers thin — Extract business logic into separate modules

Building scalable APIs is an iterative process. Start with these patterns, measure your actual usage, and optimize where the data tells you to.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX