Most Next.js API route tutorials show you the basics.
Nobody shows you how to structure them for a real production app.
Here's exactly how I do it after building multiple SaaS products.
The Problem With Basic API Routes
Most tutorials show this:
// app/api/users/route.ts
export async function GET() {
const users = await db.users.findMany();
return Response.json(users);
}
This works. But it breaks down fast in production:
- No input validation
- No error handling
- No authentication
- No consistent response format
- No TypeScript types
1. Consistent Response Format
First thing I do in every project โ create a response helper:
// lib/api-response.ts
export function successResponse(data: unknown, status = 200) {
return Response.json(
{ success: true, data },
{ status }
);
}
export function errorResponse(message: string, status = 400) {
return Response.json(
{ success: false, error: message },
{ status }
);
}
Now every route returns the same shape. Your frontend always knows what to expect.
2. Input Validation with Zod
Never trust incoming data. Ever.
// app/api/users/route.ts
import { z } from 'zod';
import { successResponse, errorResponse } from '@/lib/api-response';
const CreateUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
role: z.enum(['admin', 'user']).default('user'),
});
export async function POST(request: Request) {
try {
const body = await request.json();
// Validate input
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return errorResponse(result.error.errors[0].message, 400);
}
const { name, email, role } = result.data;
const user = await db.users.create({ data: { name, email, role } });
return successResponse(user, 201);
} catch (error) {
return errorResponse('Internal server error', 500);
}
}
safeParse never throws โ it returns a result object you can check. Much cleaner than try/catch around parse.
3. Middleware for Authentication
Don't repeat auth logic in every route:
// lib/auth-middleware.ts
import { NextRequest } from 'next/server';
export async function withAuth(
request: NextRequest,
handler: (req: NextRequest, userId: string) => Promise<Response>
): Promise<Response> {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
return Response.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
);
}
try {
const payload = verifyJWT(token); // your JWT verify function
return handler(request, payload.userId);
} catch {
return Response.json(
{ success: false, error: 'Invalid token' },
{ status: 401 }
);
}
}
Usage in any route:
// app/api/dashboard/route.ts
export async function GET(request: NextRequest) {
return withAuth(request, async (req, userId) => {
const data = await db.dashboard.findFirst({ where: { userId } });
return successResponse(data);
});
}
One line of auth in every protected route. Clean.
4. Rate Limiting
Simple in-memory rate limiter for API routes:
// lib/rate-limit.ts
const requests = new Map<string, { count: number; resetTime: number }>();
export function rateLimit(ip: string, limit = 10, windowMs = 60000): boolean {
const now = Date.now();
const record = requests.get(ip);
if (!record || now > record.resetTime) {
requests.set(ip, { count: 1, resetTime: now + windowMs });
return true; // allowed
}
if (record.count >= limit) {
return false; // blocked
}
record.count++;
return true; // allowed
}
Usage:
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
if (!rateLimit(ip, 10, 60000)) {
return errorResponse('Too many requests', 429);
}
// rest of your route
}
Note: use Redis for production multi-instance apps. This works fine for single-server deployments.
5. Full Route Example
Putting it all together:
// app/api/posts/route.ts
import { NextRequest } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/auth-middleware';
import { rateLimit } from '@/lib/rate-limit';
import { successResponse, errorResponse } from '@/lib/api-response';
const CreatePostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
published: z.boolean().default(false),
});
export async function POST(request: NextRequest) {
// Rate limit
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
if (!rateLimit(ip)) {
return errorResponse('Too many requests', 429);
}
// Auth + handler
return withAuth(request, async (req, userId) => {
try {
const body = await req.json();
// Validate
const result = CreatePostSchema.safeParse(body);
if (!result.success) {
return errorResponse(result.error.errors[0].message);
}
// Create
const post = await db.posts.create({
data: { ...result.data, authorId: userId }
});
return successResponse(post, 201);
} catch (error) {
console.error('Create post error:', error);
return errorResponse('Internal server error', 500);
}
});
}
Every production API route I write follows this exact pattern.
Summary
| Pattern | Why |
|---|---|
| Consistent response format | Frontend always knows what to expect |
| Zod validation | Never trust input data |
| Auth middleware | No repeated auth logic |
| Rate limiting | Protect against abuse |
| Try/catch everything | Never expose stack traces |
I use these patterns in all my Next.js templates.
See them in action:
Get the templates: https://pixelanas.gumroad.com
What patterns do you use in your API routes? Drop them below ๐
Anas โ full-stack Next.js developer. Building SaaS products and premium templates. X: @pixelanas
Top comments (0)