Your Next.js API Routes Are Wide Open — How to Add Rate Limiting Before You Get Abused
You built a Next.js app with API routes that talk to your database, send emails, or call third-party APIs. You deployed it. It works great.
Then one morning, you check your billing dashboard and find 400,000 requests hit your /api/contact endpoint overnight. Someone found it and decided to spam it — or worse, a bot is brute-forcing your /api/auth/login. Your Resend email quota is burned. Your database is drowning. Your Vercel bill has a surprise zero on the end.
This happens because most Next.js tutorials deploy API routes with zero rate limiting. Every endpoint is wide open, accepting unlimited requests from any IP, at any speed.
Let's fix that.
Why This Is a Bigger Problem Than You Think
Next.js API routes (both Pages Router and App Router) are regular HTTP endpoints. They have no built-in rate limiting. Unlike Express, there's no express-rate-limit middleware you've already heard of. So developers just... don't add any.
Here's what's at risk:
/api/auth/login— Brute force attacks. Someone tries 10,000 password combinations./api/contact— Spam bots. Your inbox fills up or your email provider blocks you./api/send-otp— OTP abuse. Someone triggers thousands of SMS messages on your Twilio account.- Any endpoint calling a paid API — You eat the cost of every abusive request.
Common Mistakes
Mistake 1: "Vercel handles it"
No, Vercel does not rate limit your API routes. Vercel has DDoS protection at the infrastructure level, but your application-level endpoints will happily process every single request until your function invocations or bandwidth bill explodes.
Mistake 2: Client-side throttling
// ❌ This only protects honest users
const [disabled, setDisabled] = useState(false);
const handleSubmit = () => {
setDisabled(true);
setTimeout(() => setDisabled(false), 5000);
// ...
};Anyone with curl or Postman bypasses this completely. Client-side rate limiting is a UX feature, not a security feature.
Mistake 3: Checking origin or referer headers
// ❌ These headers can be spoofed in seconds
if (request.headers.get('origin') !== 'https://mysite.com') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}Both Origin and Referer are trivially spoofable. They're useful for CSRF protection in browsers, but they don't stop bots.
Solution 1: In-Memory Rate Limiting (Simple, No Dependencies)
For small apps or serverless environments with few routes, a simple in-memory store works:
// lib/rate-limit.ts
type RateLimitEntry = {
count: number;
resetTime: number;
};
const store = new Map<string, RateLimitEntry>();
// Clean up expired entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store.entries()) {
if (now > entry.resetTime) {
store.delete(key);
}
}
}, 5 * 60 * 1000);
export function rateLimit({
windowMs = 60 * 1000, // 1 minute
maxRequests = 10,
}: {
windowMs?: number;
maxRequests?: number;
} = {}) {
return function check(identifier: string): {
allowed: boolean;
remaining: number;
resetIn: number;
} {
const now = Date.now();
const entry = store.get(identifier);
if (!entry || now > entry.resetTime) {
store.set(identifier, {
count: 1,
resetTime: now + windowMs,
});
return { allowed: true, remaining: maxRequests - 1, resetIn: windowMs };
}
entry.count++;
if (entry.count > maxRequests) {
return {
allowed: false,
remaining: 0,
resetIn: entry.resetTime - now,
};
}
return {
allowed: true,
remaining: maxRequests - entry.count,
resetIn: entry.resetTime - now,
};
};
}Using It in an API Route
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute window
maxRequests: 3, // 3 requests per minute
});
export async function POST(request: NextRequest) {
// Use IP address as identifier
const ip = request.headers.get('x-forwarded-for')
?? request.headers.get('x-real-ip')
?? 'unknown';
const { allowed, remaining, resetIn } = limiter(ip);
if (!allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil(resetIn / 1000)),
'X-RateLimit-Remaining': '0',
},
}
);
}
// Your actual route logic here
const body = await request.json();
// ... send email, save to DB, etc.
return NextResponse.json(
{ success: true },
{
headers: {
'X-RateLimit-Remaining': String(remaining),
},
}
);
}The Caveat: Serverless Memory
On Vercel (serverless), each invocation might spin up a new instance — which means the in-memory Map resets. In practice this still helps because:
- Multiple rapid requests from one IP usually hit the same warm instance
- Even partial rate limiting is better than none
- For small apps, this covers 90% of abuse cases
For guaranteed rate limiting, you need a shared store.
Solution 2: Redis-Based Rate Limiting (Production-Grade)
For production apps, use Redis (via Upstash, which has a generous free tier and works perfectly with serverless):
// lib/rate-limit-redis.ts
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv(); // Uses UPSTASH_REDIS_REST_URL & UPSTASH_REDIS_REST_TOKEN
export async function rateLimitRedis({
identifier,
windowMs = 60 * 1000,
maxRequests = 10,
}: {
identifier: string;
windowMs?: number;
maxRequests?: number;
}): Promise<{
allowed: boolean;
remaining: number;
}> {
const key = `rate_limit:${identifier}`;
const windowSeconds = Math.ceil(windowMs / 1000);
// Atomic increment + expire using Redis pipeline
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.expire(key, windowSeconds);
const results = await pipeline.exec<[number, number]>();
const currentCount = results[0];
// Set expiry only on first request (when count is 1)
if (currentCount === 1) {
await redis.expire(key, windowSeconds);
}
const allowed = currentCount <= maxRequests;
const remaining = Math.max(0, maxRequests - currentCount);
return { allowed, remaining };
}Using It
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { rateLimitRedis } from '@/lib/rate-limit-redis';
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
// Strict limit for auth endpoints: 5 attempts per 15 minutes
const { allowed, remaining } = await rateLimitRedis({
identifier: `login:${ip}`,
windowMs: 15 * 60 * 1000,
maxRequests: 5,
});
if (!allowed) {
return NextResponse.json(
{ error: 'Too many login attempts. Try again in 15 minutes.' },
{ status: 429 }
);
}
// ... actual login logic
}Upstash Also Has a Built-In Rate Limiter
If you don't want to build your own, Upstash provides @upstash/ratelimit:
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '60 s'), // 10 requests per 60 seconds
analytics: true,
});
// In your route:
const { success, remaining } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json({ error: 'Rate limited' }, { status: 429 });
}This uses a sliding window algorithm which is smoother than fixed windows — users can't burst-request at the boundary of two windows.
Solution 3: Middleware-Level Rate Limiting
For global protection across all API routes, use Next.js middleware:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const rateMap = new Map<string, { count: number; resetTime: number }>();
export function middleware(request: NextRequest) {
// Only rate limit API routes
if (!request.nextUrl.pathname.startsWith('/api')) {
return NextResponse.next();
}
const ip = request.headers.get('x-forwarded-for')
?? request.headers.get('x-real-ip')
?? 'unknown';
const now = Date.now();
const windowMs = 60_000;
const maxRequests = 30; // 30 requests per minute globally
const entry = rateMap.get(ip);
if (!entry || now > entry.resetTime) {
rateMap.set(ip, { count: 1, resetTime: now + windowMs });
return NextResponse.next();
}
entry.count++;
if (entry.count > maxRequests) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
);
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};This gives you a baseline of global rate limiting. Combine it with per-route limits for sensitive endpoints like login and contact forms.
Per-Route Limit Recommendations
| Endpoint | Window | Max Requests | Why |
|----------|--------|-------------|-----|
| /api/auth/login | 15 min | 5 | Prevent brute force |
| /api/auth/register | 1 hour | 3 | Prevent mass account creation |
| /api/contact | 1 min | 2 | Prevent spam |
| /api/send-otp | 5 min | 3 | Prevent OTP bombing |
| /api/upload | 1 min | 5 | Prevent storage abuse |
| General API | 1 min | 30 | Baseline protection |
Key Takeaways
- Next.js has zero built-in rate limiting. If you don't add it, your endpoints are wide open.
- Client-side throttling is not security. Anyone with
curlbypasses it. - In-memory limiting works for small apps but resets on serverless cold starts.
- Use Redis (Upstash) for production. It's shared across all instances and survives restarts.
- Different endpoints need different limits. Auth routes should be much stricter than read routes.
- Always return HTTP 429 with proper
Retry-Afterheaders — it's the standard and well-behaved clients will respect it. - Add rate limiting before you need it. It's 10 minutes of work that prevents hours of incident response.
Every Next.js app I ship now has rate limiting from day one. It takes minutes to set up and has saved me from real abuse incidents at least three times. Don't wait until it's your turn.
Find me at: itszain.dev