Rate Limiting in Next.js: Protecting API Routes with Upstash Redis
By Arash Latifi
A practical guide to rate limiting Next.js route handlers with Upstash Redis sliding windows, an in-memory fallback, and correct 429 responses with Retry-After.
TL;DR: Put a rate limit in front of every expensive route handler — count by key, return 429 with a
Retry-Afterheader, and keep an in-memory fallback for environments without Redis.
If you have a route handler that costs money — sending SMS, generating images, calling a payment gateway, signups — then no limit at all means anyone can burn your quota with a simple loop. Real users get locked out too, and support starts hearing "the site is slow".
Here we build two layers: the primary one on Redis (shared across server instances) and a plain in-memory fallback for local development and Redis-free environments.
The architecture is the same idea behind image storage on R2r2-image-storage-tech: an external service that keeps the app stateless. Here as well, keeping the limiter in each instance's memory resets everything as soon as a new instance scales up.
Layer one: Redis with a sliding window
Install the packages: npm i @upstash/redis @upstash/ratelimit.
Then create the shared limiter. @upstash/ratelimit handles caching internally, so build this singleton once and import it in every route:
Call it inside the handler and return 429 when the request is rejected:
Key detail: choose the key by action, not IP alone. For SMS login, a composite key such as otp:${ip}:${phone} keeps a shared household IP in Iran from consuming the whole budget.
Layer two: the in-memory fallback
If your deploy target has no Redis yet, add a simple counter in a module. It works on a single instance and is enough for dev and tests:
Keep the map pruned with a light periodic sweep, otherwise it grows forever. Fine for a local demo, but do not use it in place of Redis on a multi-instance server.
Picking limits and keys
| Sensitive route | Suggested limit | Suggested key | Why | |---|---|---|---| | SMS OTP send | 3 per minute | IP + phone number | Every message costs real money | | Signup / login | 5 per 15 minutes | IP | Stops brute-force attempts | | Public read API | 60 per minute | IP or user key | Caps database cost | | Discount redemption | 100 per hour | User key | Prevents promo abuse |
General rule: set the limit from normal user behaviour, then be slightly generous. A limit that locks out a real customer is a limit you will hate.
The right response for users
A bare 429 with no explanation makes people think the site is broken. Keep the message plain, no jargon, and tell them when to retry. If your audience is Persian-speaking, the same JSON shape with a translated string works:
Pre-production checklist
- [ ] Each sensitive route has its own limit instead of one global number.
- [ ] The key is chosen per action, not IP alone.
- [ ] 429 responses include
Retry-After. - [ ] Redis environment variables are set in production.
- [ ] Sensitive routes log errors so attack patterns are visible.
- [ ] The limit also sits in front of auth in middleware.
Quick test
Loop the same route twenty times and confirm that responses turn 429 past the threshold. If everything returns 200, the key or the proxy header is not being read — usually x-forwarded-for.
Common mistakes
- Limiting input instead of the expensive path. Input rendering is cheap; the cost lives in sending SMS and calling external APIs.
- Keying by build version, which resets every counter on each deploy.
- Ignoring shared IPs. Many users in Iran sit behind one static IP, so a composite key is the fix.
Access control usually lives in the same middleware layer — see auth with middleware for that pattern.
Q: Should the limit live in middleware or inside the route?
Inside the route is more precise because only that code runs. Middleware is faster and sees every request on the path. Use middleware for auth, and inside the route for API cost.
Q: What happens with multiple server instances?
That is exactly why you need Redis: an in-memory counter is not shared between instances, so the real limit becomes weaker. With Redis every instance sees the same counter.
Q: Is the free Upstash Redis tier enough?
Its monthly ceiling is limited and not enough for serious production on its own, but it is fine for testing and small projects. When traffic grows, switch providers — your key logic does not change.