Rate Limiting API Routes in Next.js
Sliding window rate limiting with Upstash Redis, IP extraction behind proxies, custom headers, and per-user limits.
Rate limiting is non-negotiable for any public API endpoint. Without it, a single bot can drain your third-party API credits, hammer your database, or scrape your entire content catalog. Here's how NodWatch rate limits API routes using Upstash Redis with a sliding window algorithm.
Why Upstash Redis
- Serverless-compatible — HTTP-based client, no persistent connections needed (works in Cloudflare Workers, Vercel Edge)
- Low latency — global Redis with ~1ms reads from edge locations
- Built-in expiry — keys expire automatically without manual cleanup
@upstash/ratelimit— official library that implements sliding window, fixed window, and token bucket
Setup
npm install @upstash/ratelimit @upstash/redis// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// 20 requests per 10 seconds, sliding window
export const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, "10 s"),
analytics: true, // logs to Upstash console
});
// Stricter limiter for auth routes
export const authRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "60 s"),
});
// Per-user limiter for download endpoints
export const downloadRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.tokenBucket(3, "10 s", 5), // 3 tokens per 10s, burst of 5
});IP Extraction
Behind Cloudflare or Vercel, the real client IP is in a header, not req.socket.remoteAddress:
// lib/get-ip.ts
import { headers } from "next/headers";
export async function getClientIp(): Promise<string> {
const h = await headers();
// Cloudflare
const cfIp = h.get("cf-connecting-ip");
if (cfIp) return cfIp;
// Vercel / generic proxies
const forwarded = h.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return "unknown";
}Never trust x-forwarded-for without knowing your proxy chain — a malicious client can spoof it. Cloudflare's cf-connecting-ip is set by Cloudflare itself and can't be spoofed by the client.
Applying Rate Limits
// app/api/search/route.ts
import { ratelimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/get-ip";
export async function GET(req: Request) {
const ip = await getClientIp();
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return new Response("Too Many Requests", {
status: 429,
headers: {
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": String(reset),
"Retry-After": String(Math.ceil((reset - Date.now()) / 1000)),
},
});
}
// Proceed with the actual handler
const query = new URL(req.url).searchParams.get("q");
// ...
}Per-User Limits
For authenticated endpoints, limit by user ID instead of IP — this handles shared office IPs and VPNs:
export async function POST(req: Request) {
const session = await getSession();
if (!session) return new Response("Unauthorized", { status: 401 });
const identifier = `download:${session.user.id}`;
const { success } = await downloadRatelimit.limit(identifier);
if (!success) {
return new Response("Download limit reached. Please wait.", { status: 429 });
}
// ...
}Middleware-Level Rate Limiting
Apply rate limits in middleware for blanket protection before hitting any route:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, "1 m"),
});
export async function middleware(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith("/api")) {
return NextResponse.next();
}
const ip = req.headers.get("cf-connecting-ip") ?? req.ip ?? "unknown";
const { success } = await ratelimit.limit(ip);
if (!success) {
return new NextResponse("Too Many Requests", { status: 429 });
}
return NextResponse.next();
}
export const config = {
matcher: "/api/:path*",
};Response Headers
Always include rate limit headers — they help clients implement proper backoff:
| Header | Meaning |
| --- | --- |
| X-RateLimit-Limit | Max requests in the window |
| X-RateLimit-Remaining | Requests left in the current window |
| X-RateLimit-Reset | Unix timestamp when the window resets |
| Retry-After | Seconds until the client can retry |
Algorithms
- Fixed window — simplest, but allows 2x burst at window boundaries
- Sliding window — smooths the burst; what most APIs use
- Token bucket — best for bursty legitimate traffic; allows short spikes within a sustained limit
Use sliding window as the default. Token bucket for download/heavy endpoints where you want to allow a few quick requests but limit sustained throughput.