Skip to main content
Rate Limiting in the Next.js App Router: What Breaks on Serverless and How We Fix It

Rate Limiting in the Next.js App Router: What Breaks on Serverless and How We Fix It

August 13, 2026
Full-Stack Engineering
10 min read

Key takeaways

An in-memory Map rate limiter is per-instance on serverless. With ten warm function instances, a limit of 10 requests per minute permits 100 requests per minute.

Next.js middleware is the cheapest place to shed abusive traffic because it runs before the route's own function boots, but it also sees React Server Component prefetch requests that carry the `RSC: 1` header and that the user never intentionally made.

`NextRequest.ip` was removed in Next.js 15. On Vercel, read the client address with `ipAddress(request)` from the `@vercel/functions` package instead of trusting a raw `x-forwarded-for` header.

Every Next.js Server Action POSTs to the current page URL and carries a build-generated `Next-Action` header, so the only reliable place to limit a specific action is inside the action body.

Reject with status 429 and a `Retry-After` header, and decide fail-open versus fail-closed per route before your Redis has its first outage.

Why does an in-memory rate limiter break on serverless?

A rate limiter is a counter with a deadline: allow N requests per identity per time window and reject the rest with HTTP 429. A module-scope Map holding that counter is private to one function instance, and a serverless platform runs many instances at once. Each instance therefore enforces the full limit on its own, so the effective limit is your configured limit multiplied by the number of warm instances — a number you neither control nor can observe from inside the request.

Vercel Fluid Compute makes this failure harder to notice rather than easier. Fluid Compute reuses a single function instance across concurrent requests instead of spawning one instance per request, so an in-process counter survives far longer than it did under classic per-request serverless. In local development and in a quiet preview deployment the limiter looks correct. It only comes apart under traffic spread across enough instances to matter, and nothing in the logs announces it.

The in-process Map has a second defect: it never shrinks. On a long-lived instance every unique key ever seen stays resident until the instance is recycled, which is a slow memory leak wearing a rate limiter costume.

The fix is not a cleverer Map. The counter has to live in a store that every instance shares and that supports an atomic increment — Redis, or any datastore with a compare-and-set primitive. At Devya we default to Upstash Redis for this, because it speaks HTTP and needs no connection pool, which matters when the caller is a short-lived function.

Should the limit run in middleware or in the route handler?

Both, for different jobs. Next.js middleware runs before the framework resolves the route, so a request rejected there never boots the route's function and never touches the database. That makes middleware the correct place for a coarse, identity-agnostic abuse limit. A route handler knows the authenticated user, the parsed body, and the business meaning of the call, which makes it the correct place for a per-user quota.

In middleware we match only the routes worth protecting via `config.matcher`, skip prefetches, derive the client address with `ipAddress(request)`, call the shared limiter, and return `NextResponse.json` with status 429 when the check fails. Everything else falls through to `NextResponse.next()`.

The prefetch exclusion is the line most teams add second, after watching a quota drain with no user activity. The Next.js router prefetches linked routes on hover and on viewport entry, and those prefetches are real HTTP requests that reach middleware carrying an `RSC: 1` header. Counting them means a visitor who merely scrolls a navigation-heavy page is rate limited before clicking anything. Either skip them or give them a separate, generous bucket.

One further detail: since Next.js 15.5, middleware can opt into the Node.js runtime with `export const config = { runtime: 'nodejs' }`, which allows a conventional Redis client and Node built-ins. We still prefer an HTTP-based store in middleware, because middleware sits on the latency path of every matched request and one predictable round trip beats managing a connection lifecycle there.

Placement summary: middleware sees URL, headers, cookies and IP, costs the least on rejection, and suits IP-level abuse shielding. A route handler sees everything including session and body, has already started the function, and suits per-user quotas and per-endpoint cost control. A Server Action body sees the session and typed arguments and suits form submissions and mutations.

Which algorithm should I actually use?

Pick the cheapest algorithm whose failure mode you can live with. Four are in common use, and the difference between them is entirely about burst behaviour.

Fixed window keeps one counter per window, so a check is a single INCR. Its flaw is the boundary: ten requests at 11:59:59 and ten more at 12:00:00 both pass a ten-per-minute limit while delivering twenty requests in one second.

Sliding window log stores a timestamp per request and is exact, but its memory grows with traffic — the wrong direction for a defence against traffic.

Sliding window counter weights the previous window by how much of it still overlaps the current one. It is an approximation with bounded memory and no boundary burst, and it is our default.

Token bucket gives each identity a capacity and a refill rate, so a client that has been quiet may spend saved tokens at once. This is the right choice for APIs whose clients legitimately batch.

The `@upstash/ratelimit` package ships all four as `fixedWindow`, `slidingWindow`, `tokenBucket`, and `cachedFixedWindow`. When writing a limiter directly against Redis, the thing to get right is atomicity: INCR followed by a separate EXPIRE is two round trips, and a process that dies between them leaves a key with no expiry, which locks that identity out permanently. Do the increment and the conditional PEXPIRE inside a single Lua script so the TTL is set on the first hit of a window and never afterwards.

How do I identify the client when everything sits behind a proxy?

A rate limit is only as good as its key, and on a hosting platform every request arrives from the platform's own edge. `NextRequest.ip` and `NextRequest.geo` were removed in Next.js 15, so reading `request.ip` is now a type error rather than a subtly wrong answer. On Vercel the replacement is `ipAddress(request)` from `@vercel/functions`.

Do not key on a raw `x-forwarded-for` header unless a trusted proxy is certain to overwrite it. `x-forwarded-for` is an ordinary request header, so on any origin reachable directly an attacker sets it to a new value per request and obtains an unlimited number of fresh limit buckets. That is not a partial failure of the limiter; it is a complete bypass.

IPv6 needs its own rule. A single residential subscriber is routinely assigned an entire /64 prefix, so keying on the full 128-bit address hands one attacker 2^64 distinct identities. Key IPv6 clients on the /64 prefix — the first four hextets — instead of the full address.

Where a request is authenticated, key on the user id or the API key. An IP address is the fallback for anonymous routes, not the default.

How do I rate limit a Server Action when every action is a POST to the same URL?

A Server Action is a function marked with the `'use server'` directive that the client invokes over the network. The invocation is an HTTP POST to the URL of the page the action was called from, carrying a `Next-Action` header with a build-generated identifier for that specific action. There is no dedicated route path, which is exactly what breaks the obvious approach.

In middleware, a POST from a contact form on /contact and a POST from a delete-account button on /contact are the same URL and the same method. A path-based limit either treats them identically or does not apply at all. Reading `request.headers.get('next-action')` distinguishes action POSTs from ordinary document requests, but that identifier is a hash that changes between builds, so branch on its presence and never on its value.

The reliable place is inside the action body, where the session and the typed arguments are already available. We resolve the session, build a key such as `action:sendMessage:<userId>`, call the limiter, and return a failure value when the check fails.

Returning a value rather than throwing matters. An uncaught error inside a Server Action is redacted in production and reaches the client as a generic message with a digest, so the user is told something went wrong instead of being told to wait forty seconds. Rate limiting is a normal outcome, not an exception.

What should a 429 response actually contain?

HTTP 429 Too Many Requests is the correct status, and `Retry-After` is the header that makes a limiter usable by any client other than a human staring at a browser. Its value is either a number of seconds or an HTTP date.

Alongside it, emit the limit state. The `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` convention is not a standard, but it is what most SDKs already parse. The IETF draft `draft-ietf-httpapi-ratelimit-headers` defines `RateLimit` and `RateLimit-Policy` as structured fields and is worth adopting only when consumers understand it.

Two common mistakes: returning 403 for a rate limit, which tells the client to stop forever rather than retry, and returning 500, which pollutes the error rate with your own defences working correctly.

Then decide what happens when the store is unreachable, because it will be. Failing open on a login endpoint turns a Redis outage into an open brute-force window. Failing closed on a public read endpoint turns a Redis outage into a full site outage. Choose per route: fail closed on authentication and on anything that spends money, fail open on reads.

FAQ

**Q:** Can I rate limit in Next.js without Redis?

**A:** Only if the app runs as a single long-lived process, such as one container instance. On any serverless or autoscaled deployment the counter must live in a store shared across instances, because in-process state is multiplied by the instance count.

**Q:** Does Next.js middleware run on RSC prefetch requests?

**A:** Yes. Router prefetches are real HTTP requests that match the middleware `matcher` and carry the `RSC: 1` header. Exclude them from user-facing quotas or they consume a visitor's budget before the visitor clicks anything.

**Q:** How do I get the client IP in Next.js 15 and Next.js 16?

**A:** `NextRequest.ip` was removed in Next.js 15. On Vercel, call `ipAddress(request)` from `@vercel/functions`. Elsewhere, read the forwarded header your own trusted proxy sets, and confirm the proxy overwrites rather than appends it.

**Q:** Should a rate limiter fail open or fail closed when Redis is down?

**A:** Decide per route. Fail closed on login, signup, password reset, and payment endpoints, where failing open creates a security window. Fail open on public reads, where failing closed converts a dependency outage into a site outage.

**Q:** Is Vercel BotID a replacement for rate limiting?

**A:** No. Vercel BotID is bot detection, which answers whether a caller is automated. A rate limit answers how often any caller, human or not, may perform an expensive operation. They defend different things and compose well together.