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.

May 9, 2026·7 min read

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

SymptomLikely causeFix
Everyone gets 429 at the same timeAll requests counted under your proxy or load balancer IPSet and read X-Real-IP, or the platform's client-IP header
Attackers never hit the limitKeying on a spoofable left-most X-Forwarded-For valueTrust only the address your own proxy sets
Limit resets randomly or never triggers on VercelIn-memory counter per function instanceUse Redis (or another shared store)
Login is limited but the API isn't (or vice versa)Matcher doesn't include the routeAdd the path to config.matcher
Two different limits interfereLimiters share a prefixGive each limiter its own prefix
Limit works on next dev, not after deployDifferent IP headers in productionLog 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-After and X-RateLimit-* response headers
  • ✓ Tested with synthetic burst traffic before deploying

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 an IP, a Range or One Route Without a Deploy (Node.js)

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 27
11 Signs Your AI-Built App Needs a Security Audit

Built 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 26
AI Security Audit Part 1: Set Up Your Agent to Audit Code

Part 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