Next.js API routes are easy to get started with and easy to build wrong at scale. This guide covers the patterns that distinguish production-ready API routes from ones that cause problems later.
Route Handlers vs Pages API Routes
Pages Router API routes live in pages/api/. App Router Route Handlers live in app/api/ as route.ts files. They are not interchangeable.
Route Handlers are the current standard for new projects. They support the Web API Response and Request objects natively, support streaming responses, and integrate naturally with App Router conventions.
If you are on an existing Pages Router project, there is no urgency to migrate. Both work. Mixed usage (some in Pages Router, some in App Router) is possible but adds cognitive overhead with no benefit unless you specifically need App Router features.
The import difference:
// Pages Router -- /pages/api/projects.ts
export default function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ projects: [] });
}
// App Router Route Handler -- /app/api/projects/route.ts
export async function GET() {
return Response.json({ projects: [] });
}
Authentication Pattern
Every protected route needs an authentication check at the top. The pattern:
import { getCurrentUser } from "@/lib/auth/utils";
export async function GET(request: Request) {
const user = await getCurrentUser();
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// user is typed and available
}
getCurrentUser() reads the auth cookie, verifies the JWT, and returns the user object or null. This is the single check that gates every protected route.
For routes that need organization-scoped data, extract the organization ID from the request and verify the user belongs to that organization before querying:
export async function GET(
request: Request,
{ params }: { params: Promise<{ orgId: string }> }
) {
const user = await getCurrentUser();
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { orgId } = await params;
// Verify user is member of orgId before querying
const isMember = user.organizations.includes(orgId);
if (!isMember) return Response.json({ error: "Forbidden" }, { status: 403 });
// Proceed with org-scoped query
}
Never trust organization IDs from the request body without verifying membership. Always filter queries by organization_id.
Error Handling: Consistent Response Format
Define and stick to a consistent error response format across all routes. Inconsistent error shapes make client-side error handling brittle.
A simple format that works:
// Success
{ data: T }
// Error
{ error: { code: string; message: string; field?: string } }
A helper for consistent error responses:
function errorResponse(code: string, message: string, status: number, field?: string) {
return Response.json({ error: { code, message, field } }, { status });
}
// Usage
return errorResponse("VALIDATION_ERROR", "Name is required", 400, "name");
return errorResponse("NOT_FOUND", "Project not found", 404);
return errorResponse("CONFLICT", "Email already exists", 409);
Status code guide:
- 400: Bad request (malformed request, missing required fields)
- 401: Unauthorized (not authenticated)
- 403: Forbidden (authenticated but not allowed)
- 404: Not found
- 409: Conflict (duplicate, version mismatch)
- 422: Unprocessable entity (request is well-formed but semantically invalid)
- 429: Too many requests (rate limited)
- 500: Internal server error (unexpected failure)
Never return 500 for client errors. Never return 200 with an error body (makes error detection on the client harder).
Input Validation with Zod
Every route that accepts a request body should validate it. Zod is the standard choice in TypeScript projects:
import { z } from "zod";
const CreateProjectSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
description: z.string().max(500).optional(),
color: z.string().regex(/^#[0-9A-F]{6}$/i).optional(),
});
export async function POST(request: Request) {
const user = await getCurrentUser();
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json().catch(() => null);
if (!body) return Response.json({ error: "Invalid JSON" }, { status: 400 });
const result = CreateProjectSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: { code: "VALIDATION_ERROR", issues: result.error.issues } },
{ status: 400 }
);
}
const { name, description, color } = result.data; // Typed and validated
// Proceed with database operation
}
safeParse does not throw -- it returns a result object with success boolean. This is preferable to parse (which throws) in route handlers where you want to return a structured error response.
Zod strips unknown fields automatically with z.object(). Properties sent in the request body that are not in the schema are ignored. This prevents unexpected data from reaching your database.
Rate Limiting
Without rate limiting, a single client can hammer your API routes. For serverless deployments, Upstash Redis provides a simple, compatible solution:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"), // 10 requests per 10 seconds
});
export async function POST(request: Request) {
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await ratelimit.limit(ip);
if (!success) {
return Response.json({ error: "Too many requests" }, { status: 429 });
}
// Proceed
}
For authenticated routes, rate limit by user ID rather than IP for more accurate per-user limits.
What Goes in Middleware vs Route Handlers
Next.js Middleware runs at the edge before the request reaches the route handler. Use it for:
Fast authentication checks (verify the JWT cookie exists and is valid using a lightweight edge-compatible JWT verifier). Route protection (redirect unauthenticated users to login). Adding security headers. Geographic routing.
Do not put in Middleware: database queries (no Node.js runtime), complex business logic, anything that needs the full Node.js runtime.
The pattern: Middleware does a fast token validity check. The Route Handler calls getCurrentUser() for the full user object when it needs it.
File Uploads in Route Handlers
The App Router supports multipart form data natively:
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) return Response.json({ error: "No file provided" }, { status: 400 });
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Upload buffer to Cloudinary, S3, etc.
}
Set a maximum file size limit before reading the entire file. Check the Content-Length header or implement a size check on the buffer.
Keep Reading
- Next.js App Router Patterns in 2026: What to Use and What to Avoid -- Route Handlers in the broader App Router context
- TypeScript for React Developers: Practical Patterns That Actually Help -- typing route handler request and response shapes
- Database Connection Pooling in Next.js: Solving the Serverless Problem -- database access from route handlers
Pristren builds AI-powered software for teams. Zlyqor is our all-in-one workspace -- chat, projects, time tracking, AI meeting summaries, and invoicing -- in one tool. Try it free.