// LOADING
// LOADING
// LOADING_ARTICLE
An unprotected API endpoint is an open invitation for abuse — whether from scrapers, credential-stuffing bots, or a poorly written client that loops indefinitely. Rate limiting is one of the foundational defenses, and it's straightforward to add to Next.js Route Handlers.
Before writing code, it helps to understand what each algorithm trades off:
Fixed window: Count requests per time window (e.g., 100 requests per minute). Simple to implement but has a burst problem — a client can use 100 requests at 00:59 and another 100 at 01:00.
Sliding window: Tracks request timestamps and counts only those within the last N seconds. Smoother than fixed window, slightly more storage overhead.
Token bucket: Each client has a bucket of tokens that refills at a constant rate. A request consumes one token; if the bucket is empty, the request is rejected. Handles bursts gracefully because unused tokens accumulate up to the bucket capacity.
For most API protection use cases, the sliding window or token bucket gives the best behavior.
For a simple, single-server setup, an in-memory store is enough to get started:
ts// lib/rateLimiter.ts const requests = new Map<string, number[]>(); export function slidingWindowCheck( key: string, limit: number, windowMs: number ): boolean { const now = Date.now(); const timestamps = (requests.get(key) ?? []).filter( t => now - t < windowMs ); if (timestamps.length >= limit) return false; timestamps.push(now); requests.set(key, timestamps); return true; }
Usage in a Route Handler:
ts// app/api/contact/route.ts import { slidingWindowCheck } from '@/lib/rateLimiter'; import { NextRequest, NextResponse } from 'next/server'; export async function POST(request: NextRequest) { const ip = request.headers.get('x-forwarded-for') ?? 'unknown'; const allowed = slidingWindowCheck(ip, 10, 60_000); // 10/min per IP if (!allowed) { return NextResponse.json( { error: 'Too many requests' }, { status: 429, headers: { 'Retry-After': '60' } } ); } // ... handle request }
Always return HTTP 429 with a Retry-After header. Clients that respect HTTP semantics will back off correctly.
In-memory rate limiting breaks as soon as you have more than one server instance — Vercel serverless functions, for example, run in isolation. Redis gives you a shared, atomic counter:
ts// lib/redisRateLimiter.ts import { createClient } from 'redis'; const redis = createClient({ url: process.env.REDIS_URL }); await redis.connect(); export async function tokenBucketCheck( key: string, capacity: number, refillRatePerSecond: number ): Promise<boolean> { const now = Date.now() / 1000; const bucketKey = `ratelimit:${key}`; const data = await redis.hGetAll(bucketKey); const lastRefill = parseFloat(data.lastRefill ?? String(now)); const tokens = parseFloat(data.tokens ?? String(capacity)); const elapsed = now - lastRefill; const newTokens = Math.min(capacity, tokens + elapsed * refillRatePerSecond); if (newTokens < 1) return false; await redis.hSet(bucketKey, { tokens: String(newTokens - 1), lastRefill: String(now), }); await redis.expire(bucketKey, 3600); return true; }
For a production setup, prefer an atomic Lua script or a library like @upstash/ratelimit (which uses Upstash Redis) to avoid race conditions between the read and write.
Upstash is purpose-built for serverless environments. The @upstash/ratelimit library wraps the complexity:
tsimport { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(20, '1 m'), }); export async function POST(request: NextRequest) { const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown'; const { success, limit, remaining, reset } = await ratelimit.limit(ip); if (!success) { return NextResponse.json( { error: 'Rate limit exceeded' }, { status: 429, headers: { 'X-RateLimit-Limit': String(limit), 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': String(reset), }, } ); } // handle request }
This is the approach that fits well with the serverless architecture patterns used in modern Next.js deployments.
For endpoints that warrant protection at the edge before they even reach your route code, rate limiting can be placed in Next.js middleware. The trade-off is that you need an Edge-compatible store (Upstash works here too). See the full discussion of Next.js middleware for auth and edge logic for how to structure the middleware layer.
Rate limit keys determine the granularity:
For sensitive authenticated actions, always prefer user-ID-based limiting. IP-based limits on authenticated routes can lock out entire office networks. This ties into the broader topic of securing REST APIs.
Well-behaved rate limiting communicates its state to clients:
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Maximum requests allowed |
| X-RateLimit-Remaining | Requests left in the window |
| X-RateLimit-Reset | Unix timestamp when the window resets |
| Retry-After | Seconds until the client can retry (on 429) |
These headers let clients implement exponential backoff correctly instead of hammering until they get a 200.
Rate limiting is not a hard problem — it's an easy problem that's easy to skip until it's too late. Add it to every public-facing endpoint from day one. Start with the sliding window for simplicity, move to Redis or Upstash once you have multiple instances, and always return proper 429 responses with retry guidance. If you're building contact forms, comment endpoints, or AI-backed routes, rate limiting is non-negotiable — check the projects to see how these patterns are applied in real Next.js applications.