Back to Blog
Apr 28, 20264 min readOnuzulike Anthony

Rate Limiting API Routes in Next.js

Sliding window rate limiting with Upstash Redis, IP extraction behind proxies, custom headers, and per-user limits.

SecuritySecurityNext.jsRedis

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

bash
npm install @upstash/ratelimit @upstash/redis
ts
// 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:

ts
// 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

ts
// 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:

ts
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:

ts
// 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.