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.

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
RequestandResponseobjects - 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:
// 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.
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:
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:
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
- Validate at boundaries — Use Zod schemas for all external input
- Consistent error responses — Standardize your error format early
- Rate limit everything — Protect your APIs from abuse
- Type everything — TypeScript catches bugs before they reach production
- 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.


