← Back
April 4, 2026React7 min read
Next.js Route Handlers: the patterns that replaced my Express backend
Published April 4, 20267 min read
When I started using Next.js App Router, I kept my Express backend for the API layer because I assumed Route Handlers were too limited. Three months later, I had replaced Express entirely. Route Handlers support streaming, edge deployment, proper middleware, and everything I needed. Here are the patterns that made the switch possible.
Type-safe request parsing with Zod
typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
role: z.enum(['user', 'admin']).default('user'),
});
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
const parsed = CreateUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten() },
{ status: 400 }
);
}
const user = await db.users.create({ data: parsed.data });
return NextResponse.json({ data: user }, { status: 201 });
}
Middleware chains with higher-order functions
typescript
// lib/api/middleware.ts
type Handler = (req: NextRequest, context?: unknown) => Promise;
// Auth middleware
export function withAuth(handler: Handler): Handler {
return async (req, context) => {
const token = req.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await validateToken(token);
if (!user) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Attach user to headers for the handler
const newReq = new NextRequest(req, {
headers: { ...Object.fromEntries(req.headers), 'x-user-id': user.id },
});
return handler(newReq, context);
};
}
// Rate limiting middleware
export function withRateLimit(
handler: Handler,
{ max = 100, windowMs = 60000 } = {}
): Handler {
return async (req, context) => {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
const key = `rate_limit:${ip}`;
const current = await redis.incr(key);
if (current === 1) await redis.expire(key, windowMs / 1000);
if (current > max) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429, headers: { 'Retry-After': String(windowMs / 1000) } }
);
}
return handler(req, context);
};
}
// Usage: compose middleware
// app/api/protected/route.ts
const handler: Handler = async (req) => {
const userId = req.headers.get('x-user-id')!;
const data = await getPrivateData(userId);
return NextResponse.json({ data });
};
export const GET = withAuth(withRateLimit(handler));
Streaming responses
typescript
// app/api/ai/stream/route.ts
import { NextRequest } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
export async function POST(request: NextRequest) {
const { prompt } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 1024,
stream: true,
messages: [{ role: 'user', content: prompt }],
});
for await (const event of response) {
if (
event.type === 'content_block_delta' &&
event.delta.type === 'text_delta'
) {
controller.enqueue(encoder.encode(event.delta.text));
}
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
}
Dynamic segment handlers
typescript
// app/api/posts/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const post = await db.posts.findUnique({ where: { id: params.id } });
if (!post) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ data: post });
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const body = await request.json();
const updated = await db.posts.update({
where: { id: params.id },
data: body,
});
return NextResponse.json({ data: updated });
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
await db.posts.delete({ where: { id: params.id } });
return new NextResponse(null, { status: 204 });
}
Edge runtime for fast global APIs
typescript
// app/api/geo/route.ts
// Runs at the edge — close to the user, cold start ~0ms
export const runtime = 'edge';
export async function GET(request: NextRequest) {
const country = request.geo?.country ?? 'unknown';
const city = request.geo?.city ?? 'unknown';
return NextResponse.json({ country, city });
}
What made the Express migration worth it: Route Handlers live next to the UI code, share TypeScript types directly, and deploy with zero additional infrastructure. The middleware pattern above is slightly less ergonomic than Express middleware but production-proven. For new Next.js projects I start with Route Handlers and only reach for a separate API service when the complexity justifies it.
Share this
← All Posts7 min read