Rate limiting in Next.js: what to protect before picking a library
There's a pattern I keep seeing in web projects: someone reads about credential stuffing, opens the terminal, and runs npm install @upstash/ratelimit. Fifteen minutes later there's a middleware capping 10 requests per IP per minute across all routes. The problem isn't the library — it's a good one — the problem is that configuration protects a profile image API with the same intensity as a login endpoint, and it blocks a legitimate user behind a corporate NAT before they ever get to authenticate.
My thesis is simple: rate limiting isn't a dependency, it's an abuse policy. And a policy without a defined asset, expected abuse pattern, and false positive cost isn't security — it's noise with latency.
Before installing anything, there are three questions you need to answer. This post is about those questions.
Rate limiting in Next.js web apps: the missing mental model
When we think about rate limiting, we tend to think "how many requests per second." But that confuses the mechanism with the goal. The real goal is making certain abuse patterns expensive for the attacker without making them expensive for the legitimate user.
OWASP covers this in their Authentication Cheat Sheet from the authentication angle: progressive failed attempt counting, temporary lockout, user notification. What it doesn't say — and this is equally important — is what not to block. That part you have to decide yourself.
The framework I find most honest has four columns:
| Question | What you're trying to define |
|---|---|
| What asset are you protecting? | Specific endpoint, resource, flow |
| What abuse pattern do you expect? | Credential stuffing, scraping, layer-7 DDoS, form spam |
| What does a false positive cost you? | Blocked user, lost conversion, wrong support ticket |
| How are you going to observe it? | Metrics, logs, alerts, hit/block differentiation |
Without all four columns filled in, any configuration you pick is a guess. It might work. It might also block real users in production with nobody noticing until the complaint comes in.
Where the standard recipe breaks
Global rate limiting middleware in Next.js has a legitimate use case: protecting public routes from mass scraping or brute force on login. But it comes with costs that tutorials tend to skip.
The shared IP problem. If you limit by IP and the user is behind a corporate proxy or a university NAT, dozens or hundreds of different users share the same address. One active user can burn through everyone else's budget. This isn't an edge case — it's the normal scenario for any B2B app.
The scope-too-wide problem. A middleware in middleware.ts that intercepts /(.*) applies the limit to /api/auth/login, /api/profile/avatar, /api/search, and /sitemap.xml equally. The cost of a false positive on login is very different from the cost of one on image assets. Mixing them gives you apparent protection, not real protection.
The missing observability problem. How many requests did you block today? How many were legitimate? Without that distinction, you can't calibrate. It's not that rate limiting is bad — it's that without observability, you don't know if it's working or if it's causing damage.
A more defensive pattern in Next.js App Router looks like this:
// middleware.ts — selective rate limiting by route, not global
import { NextRequest, NextResponse } from 'next/server'
// Explicit list of routes that justify protection
const PROTECTED_ROUTES = ['/api/auth/login', '/api/auth/register', '/api/contact']
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Only apply on routes we've defined as critical assets
if (!PROTECTED_ROUTES.some((route) => pathname.startsWith(route))) {
return NextResponse.next()
}
// The counting mechanism goes here (Upstash, Redis, etc.)
// The point: this block has explicit scope, not implicit
return NextResponse.next()
}
export const config = {
matcher: ['/api/auth/:path*', '/api/contact/:path*'],
}The difference isn't in the library. It's that the matcher is explicit. If you add /api/upload tomorrow, it doesn't inherit the limit by accident — you have to consciously decide whether to protect it.
The decision matrix before choosing a mechanism
This is the part I most want to share because it's the part most people skip. Before choosing between Redis + Upstash, a stateless token middleware, or your cloud provider's built-in rate limiting, you need to answer:
What asset are you protecting?
Not all routes carry the same risk under abuse. One way to think about it:
- High sensitivity: login, registration, password reset, payment endpoints, email sending. Abuse here has direct consequences: compromised accounts, real costs in third-party services, spam.
- Medium sensitivity: search, public listings, internal APIs. Abuse here is more about scraping or overload, not account takeover.
- Low sensitivity: static assets, UI routes, sitemap. Protecting these with rate limiting adds latency without reducing real risk.
What abuse pattern do you expect?
This changes the mechanism, not just the threshold:
- Credential stuffing on login: you want a limit by IP and by username, with progressive backoff. OWASP specifically recommends against permanent account lockout to prevent attackers from using that as a DoS vector against legitimate users.
- Scraping of listings: IP limit with a sliding window. Here throughput matters, not failed attempts.
- Contact form spam: IP limit + honeypot + origin validation. Rate limiting alone isn't enough if the form doesn't have a CSRF token.
What does a false positive cost you?
This is the most uncomfortable question because it forces you to put a number on something that feels abstract. Some questions to calibrate:
- How much is an erroneously blocked user session worth? (support cost, lost conversion)
- How many legitimate users share an IP in your target market segment?
- Is there a low-friction recovery path if the rate limit fires incorrectly?
If the false positive cost is high and the asset is critical, the threshold needs to be conservative on blocking but generous on recovery time.
How are you going to observe it?
A rate limiter without metrics is a black box. The minimum you need:
// Minimal logging example when rejecting a request
// Adapt to your own logging system (pino, winston, structured stdout)
function logRateLimitEvent(request: NextRequest, result: 'blocked' | 'allowed') {
const event = {
timestamp: new Date().toISOString(),
route: request.nextUrl.pathname,
ip: request.ip ?? 'unknown',
result,
// Never log auth headers or body here
}
console.log(JSON.stringify(event))
}With this, you can at least run a daily query: how many blocked, on which routes, at what time? Without it, the policy is opaque.
Common mistakes and their real costs
Using a global limit as a substitute for analysis. "10 requests per minute per IP across the whole domain" sounds reasonable until a bot uses 10,000 rotating IPs and gets through anyway, while a real user on a corporate VPN gets locked out.
Trusting IP as a unique identifier. IPv4 with NAT and CDNs makes IP a noisy identifier. For authenticated routes, the identifier should be the user ID, not the IP. For public routes, IP is what you've got — but with all the limitations that implies.
Not differentiating between 429 Too Many Requests with and without Retry-After. If you block a request and don't return a Retry-After header, the client (and the user) has no idea when to retry. OWASP calls out backoff as an explicit mechanism; the header is how the server communicates it.
// Correct response with recovery information
return new NextResponse('Too many attempts. Please wait a moment.', {
status: 429,
headers: {
'Retry-After': '60', // seconds until they can retry
'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 60),
},
})Adding rate limiting without checking if an upstream layer already exists. Railway, Vercel, and Cloudflare all have their own rate limiting controls. Adding your own in middleware without knowing what the upstream is doing can create unexpected behavior — or simply duplicate work without reducing additional risk.
Limits of this guide: what you can't conclude without your own data
I need to be direct about what this guide does not give you:
-
There are no universal thresholds. "10 requests per minute" for login might be too low for an app with mobile users reconnecting frequently, and too high for a B2B app where a legitimate login rarely repeats more than twice in a row. The right number comes from observing your actual users' real behavior.
-
There's no evidence that rate limiting alone prevents account takeover. OWASP treats it as a complementary control, not the main defense. Without MFA, without compromised credential detection (haveibeenpwned.com has a public API for this), rate limiting on login slows down simple brute force but not sophisticated credential stuffing with rotating IPs.
-
You can't calibrate the false positive without logs. Whatever number you pick today is a hypothesis. Calibration comes from observing how many legitimate requests approach the threshold under normal conditions.
This connects to something I covered in the post on OAuth Scope Creep: security controls have to be designed from specific risk, not from a generic recipe. And in OWASP LLM Top 10 for agents I landed on a similar conclusion: the guide gives you the framework, but calibration comes from your own data.
FAQ: Rate limiting in Next.js
Is Upstash the only option for rate limiting in Next.js with App Router? No. Upstash with Redis is popular because it works well in serverless environments (Vercel, Railway with Workers), but you can implement rate limiting with any shared storage: your own Redis, Memcached, or even a database if the volume allows. The choice depends on tolerated latency and your deployment model. If the middleware runs on the edge, you need something with low latency that's compatible with the edge runtime (no native Node.js).
Does it make sense to rate limit static asset routes?
In most cases, no. Static assets (/_next/static/, public images) have low abuse cost and high false positive cost (real users hammer them heavily during page loads). CDN or hosting provider rate limiting already covers this better than custom middleware.
How do I handle users behind NAT or a corporate VPN? For authenticated routes, use the user ID as the limit identifier, not the IP. For public routes, you can combine IP with header fingerprinting or use more generous limits paired with anomaly detection (many failed attempts from the same IP). There's no perfect solution here — it's a trade-off between blocking precision and false positive cost.
What does the server return when rate limiting fires? Does the message matter?
More than you'd think. A 429 without Retry-After leaves the client with no information on when to retry. A message that's too specific ("blocked due to excessive login attempts") can hand the attacker information about the mechanism. The reasonable approach: 429 with Retry-After and a generic user-facing message ("Too many requests, please wait a moment").
Does rate limiting in Next.js middleware also cover Server Actions?
It depends on how you configure it. Server Actions generate POST requests to the same page URL, not a separate API route. If the middleware matcher doesn't cover those routes, Server Actions won't have the limit applied. Check the matcher explicitly if you want to protect forms using Server Actions.
Does Railway have native rate limiting that replaces middleware? Railway doesn't have native application-level rate limiting (as of when I'm writing this). It has infrastructure-level protection, but no granular control per route or per user. For application-specific abuse logic, you need to implement it yourself. If you're running Cloudflare in front of Railway, Cloudflare does offer per-route rate limiting that can be enough for simple cases.
Conclusion: the policy before the library
Thirty years in tech taught me that the most expensive mistakes aren't the ones that break the system — they're the ones that give you the feeling of control without actually having it. A rate limiting middleware installed without a defined policy falls squarely in that category: the 200 OK from the deploy masks the fact that you don't know what you're protecting, against what, with what threshold, or whether you're silently damaging legitimate users.
My practical recommendation: before opening any library, fill in the four columns of the matrix. Asset, expected abuse, false positive cost, observability. If you can't fill them in, you don't have a policy — you have configuration by imitation.
Then, yes, pick the mechanism that fits your stack. Upstash for serverless, your own Redis if you have the control, the cloud provider's rate limiting if the case is simple. The technology is the easy part. The hard part is the design decision that comes before it.
The concrete next step: take a single critical route in the app — preferably login or registration — and answer the four questions for that route alone. Not the whole system. One route, four answers, one threshold calibrated with logs. That's more useful than a global middleware configured with numbers someone copied from a tutorial.
Sources
Related Articles
npm Dependencies: How to Evaluate a Library Before Shipping It to Production
Adding an npm dependency isn't just installing code — it's taking on its maintenance, its attack surface, and its transitive deps. Here's the checklist I run before adding any package to a serious TypeScript project.
How I built a self-auditing editorial pipeline with AI
The README on juanchi.dev says "portfolio landing". The code says something else: an editorial system with repo ingestion, quality gate, automatic rewriting, and crons on Railway. The technical story the README doesn't tell.
OWASP LLM Top 10 in Production: How I Audited My TypeScript Agent Pipeline Against All 10 Risks — and What I Found
Running the OWASP LLM Top 10 as a real audit is a completely different experience than reading it as a checklist. I ran it against my TypeScript agent stack with system prompts, MCP tools, and Cline — and the findings were uncomfortable.
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.