Next.js Rate Limiting: proxy.ts, Redis and Real Client IPs
Rate limit Next.js API routes and logins in production: proxy.ts (Next 16) or middleware.ts, a shared Redis store, the real client IP, and per-user keys.
Next.js Rate Limiting: proxy.ts, Redis and Real Client IPs
To rate limit a Next.js app in production, count requests in a shared store (Redis), key each request by the real client IP or the signed-in user, and enforce the limit in proxy.ts (called middleware.ts before Next.js 16) for routes like /api/* and /login. In-memory counters and naive X-Forwarded-For parsing are the two mistakes that make most examples work in development and fail in production.
The setup
npm install @upstash/ratelimit @upstash/redis
@upstash/ratelimit handles the window math (sliding window, fixed window or token bucket) and talks to Redis over HTTPS, so it works on Vercel, other serverless hosts and a plain next start server alike.
On Vercel, add an Upstash Redis database from the Vercel Marketplace; elsewhere, create one at Upstash. Redis.fromEnv() reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. If your integration created differently named variables (for example KV_REST_API_URL and KV_REST_API_TOKEN), pass them explicitly:
const redis = new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
});
The proxy
Next.js 16 renamed Middleware to Proxy: the file is proxy.ts at the project root (or in src/), the exported function is proxy, and it runs on the Node.js runtime by default. On Next.js 15 or earlier, use the same code in middleware.ts and name the function middleware.
// proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
const generalLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '1 m'),
prefix: 'rl:general',
});
const authLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '5 m'),
prefix: 'rl:auth',
});
function clientIp(request: NextRequest): string | null {
// Self-hosted behind your own nginx/load balancer: have it set X-Real-IP
// from the connection address and read that header.
const realIp = request.headers.get('x-real-ip');
if (realIp) return realIp.trim();
// On Vercel the platform sets X-Forwarded-For itself, so the first value is the client.
// Behind a proxy that only appends to the header, the first value is whatever the client sent.
return request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || null;
}
export async function proxy(request: NextRequest) {
const ip = clientIp(request);
const isAuth = request.nextUrl.pathname.startsWith('/api/auth') || request.nextUrl.pathname === '/login';
const limiter = isAuth ? authLimit : generalLimit;
// Don't pool every request with an unknown IP into one shared bucket.
if (!ip) return NextResponse.next();
const { success, limit, remaining, reset } = await limiter.limit(ip);
if (success) return NextResponse.next();
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'Retry-After': String(Math.max(1, Math.ceil((reset - Date.now()) / 1000))),
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
},
}
);
}
export const config = {
matcher: ['/api/:path*', '/login'],
};
Two limiters cover the typical needs: the general API at 100 requests per minute per IP, and authentication at 5 attempts per 5 minutes. Tune the numbers to your real traffic; the structure matters more than the defaults.
What's different from the naive examples
The client IP. This is the bug that matters most. If your app sits behind nginx or a load balancer that appends to X-Forwarded-For, the first value in the header is whatever the client chose to send, so an attacker can rotate it and get a fresh bucket on every request. Configure your proxy to set X-Real-IP from the connection address (proxy_set_header X-Real-IP $remote_addr; in nginx) and read that. If you skip the header entirely, every user is counted under your proxy's single address and legitimate traffic gets throttled together.
The matcher. Without a matcher, the proxy runs on every request including static assets, which wastes Redis calls. Scope it to API and auth routes. Server actions are POST requests to the page's own path, so include those paths if you want them limited here, or limit inside the action.
Sliding window. Ratelimit.slidingWindow is the right default. A fixed window allows up to twice the limit in a burst that straddles the window boundary.
The prefix. Each limiter needs its own prefix or they share counters. It's easy to miss and breaks in confusing ways.
Per-user rate limiting
For authenticated routes, key by user ID and fall back to the IP:
import { getToken } from 'next-auth/jwt';
export async function proxy(request: NextRequest) {
const token = await getToken({ req: request });
const key = token?.sub ? `user:${token.sub}` : clientIp(request);
// ...same limiter logic as above
}
getToken is the NextAuth helper; with another auth library, read the user ID from your verified session token the same way. This keeps a company network behind one NAT address from sharing a single bucket. For limits that depend on business rules (one password reset email per account per hour, five coupon attempts per order), enforce them inside the route handler or server action, where you have that context.
Why your Next.js rate limit isn't working
| Symptom | Likely cause | Fix |
|---|---|---|
| Everyone gets 429 at the same time | All requests counted under your proxy or load balancer IP | Set and read X-Real-IP, or the platform's client-IP header |
| Attackers never hit the limit | Keying on a spoofable left-most X-Forwarded-For value | Trust only the address your own proxy sets |
| Limit resets randomly or never triggers on Vercel | In-memory counter per function instance | Use Redis (or another shared store) |
| Login is limited but the API isn't (or vice versa) | Matcher doesn't include the route | Add the path to config.matcher |
| Two different limits interfere | Limiters share a prefix | Give each limiter its own prefix |
Limit works on next dev, not after deploy | Different IP headers in production | Log the key you rate limit on for a few requests and compare |
Costs
Each limit() call costs a small number of Redis commands, so your Redis bill scales with the number of requests the matcher sends through the proxy. Narrowing the matcher is the cheapest optimization. Check Upstash's pricing page for the current free tier and per-command price rather than relying on numbers in a blog post.
What this doesn't solve
Same as Express:
- Distributed attacks from rotating IPs or residential proxies
- Bot traffic that mimics legitimate users
- Application-level abuse (cart cycling, coupon stacking)
For those, add an IP-reputation layer (the SecureNow Firewall runs on the Node.js runtime) and look at behavior across requests. How to tell if your web app is under attack covers the signals to watch.
Production checklist
- ✓ Real client IP from a header your own proxy or platform sets
- ✓ Shared Redis store, not in-memory counters
- ✓ Sliding window, not fixed window
- ✓ Separate prefixes per limiter
- ✓ Stricter limits on login, signup, password reset and OTP routes
- ✓ Per-user keys when authenticated
- ✓ Matcher scoped to API and auth routes
- ✓
Retry-AfterandX-RateLimit-*response headers - ✓ Tested with synthetic burst traffic before deploying
Related
Frequently Asked Questions
Does in-memory rate limiting work on Vercel?
No. Requests are served by many short-lived function instances, so a counter kept in memory is not shared and disappears between invocations. Use a shared store such as Redis that every instance can reach over the network.
Why is my Next.js rate limit not working?
The usual causes are keying on the wrong IP (every user shares your proxy's address, or attackers rotate a spoofed X-Forwarded-For value), a per-process memory store, a matcher that skips the route you meant to protect, or two limiters sharing one prefix. Check which key each request was counted under before changing the limits.
Should I rate limit in proxy.ts or in the route handler?
Use proxy.ts (middleware.ts before Next.js 16) for coarse per-IP limits across many routes, because it runs before rendering. Put limits that depend on the signed-in user, the request body, or business rules inside the route handler or server action, where that context is available.
What about per-user rate limiting?
Key authenticated traffic by user ID from your session or JWT, and fall back to the client IP for anonymous requests. That stops one office behind a shared NAT address from exhausting a single bucket.
Is Vercel KV still the way to store rate-limit counters?
Vercel KV is no longer offered for new projects; Vercel provisions Redis through Marketplace integrations such as Upstash instead. The @upstash/ratelimit code in this guide works the same whether the database came from the Marketplace or directly from Upstash.
Recommended reading
Block one IP, a CIDR range, or a single route and method in a Node.js app within seconds, with no code change: setup, scoping, CLI, MCP, verification and undo.
Oct 27Built your app with a coding agent? 11 warning signs it needs a security audit, each checkable in five minutes, and how to run the full audit yourself.
Oct 26Part 1 of the AI security audit series: set up an AI coding agent to audit your app locally, connect SecureNow, and run your first threat-model pass.
Jul 24