Rate-Limit a Next.js Route Handler with Upstash
A typed rate-limiter for Next.js route handlers using Upstash Redis. Sliding window, per-IP, with proper 429 responses and headers.
A drop-in rate limiter for Next.js route handlers using Upstash Redis and the official @upstash/ratelimit package. Identifies callers by IP, applies a sliding-window limit, and returns proper 429 Too Many Requests responses with the standard rate-limit headers. This is the pattern I use for sign-up endpoints and contact forms.
Tested on Next.js 16, @upstash/ratelimit 2.x, @upstash/redis 1.35+.
When to Use This
- Protecting sign-up, password reset, or contact endpoints from abuse
- Limiting expensive AI/LLM API calls per user
- Rate-limiting webhook receivers from chatty third parties
- Throttling search endpoints to control downstream cost
Don't use this when you need application-level quotas (per-user, per-plan — that's a billing concern, not rate limiting) or when you need request shaping (use a proper API gateway).
Code
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// Create the limiter once at module scope so it's reused across requests
export const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
analytics: true,
prefix: 'ratelimit:contact',
});// app/api/contact/route.ts
import { NextResponse } from 'next/server';
import { ratelimit } from '@/lib/rate-limit';
export async function POST(request: Request) {
// Get the caller's IP from the platform header
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'127.0.0.1';
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
},
}
);
}
// ... handle the actual request ...
return NextResponse.json({ ok: true });
}The ratelimit instance is module-scoped so it's created once per cold start, not per request. The sliding window is more accurate than a fixed window — it doesn't let users burst at the start of every minute.
Usage
The Upstash Redis credentials are auto-provisioned when you install the Upstash integration on Vercel. They show up as UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN in your environment, and Redis.fromEnv() reads them automatically.
Local development needs the same env vars in .env.local:
UPSTASH_REDIS_REST_URL="https://...upstash.io"
UPSTASH_REDIS_REST_TOKEN="AX..."For multiple endpoints with different limits, create multiple Ratelimit instances with different prefixes:
// lib/rate-limit.ts
export const contactLimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 h'), // 5/hour
prefix: 'rl:contact',
});
export const signupLimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(3, '1 h'), // 3/hour
prefix: 'rl:signup',
});The prefix keeps the counters separate even though they share one Redis instance.
Caveats
x-forwarded-forcan contain multiple IPs. Always take the first one (split(',')[0]) — that's the original client. The others are intermediate proxies.- Trust the platform header, not the client. A direct
request.ipaccessor doesn't exist for Vercel-deployed Next.js.x-forwarded-foris what Vercel injects. - Don't use the user's email as the rate-limit key for unauthenticated endpoints. Anyone can submit any email, which lets attackers lock out other users. Always rate-limit by IP for unauthenticated traffic.
- The
Retry-Afterheader is in seconds, not milliseconds. Convert fromreset - Date.now()carefully. - Module-scoped
Ratelimitinstance is intentional. Don'tnew Ratelimit()inside the handler — that creates a new client per request and slows everything down. - Set
analytics: trueonly if you'll use it. It costs extra Redis writes and shows up in your Upstash dashboard.
Related Snippets & Reading
- Auth-Gated proxy.ts — for coarser site-wide limiting
- Server Action with Zod Validation — pair rate limiting with validation
- @upstash/ratelimit docs — official package reference
Frequently Asked Questions
Why use Upstash Redis for rate limiting instead of an in-memory store?
Serverless functions on Vercel run on different instances per request. An in-memory map only protects one instance and resets on every cold start. Upstash gives you a shared, persistent counter across every invocation, with millisecond latency and a generous free tier.
Should rate limiting live in proxy.ts or the route handler?
Both, depending on the threat model. Coarse limiting (e.g., 100 req/min per IP across the whole API) belongs in proxy.ts. Per-endpoint limiting (e.g., 5 sign-up attempts per hour) belongs in the route handler where you have more context about the user and action.