Skip to content
Rate Limiting Your Next.js API: Before You Need It
Milos KnezevicMilos Knezevic20 Oct 2026 · 12 min read

Rate Limiting Your Next.js API: Before You Need It

Credential stuffing, surprise API bills, and runaway retry loops end the same way. How to rate limit Next.js API routes — and make it survive serverless.

Three incidents, one missing line of code

Security advice tends to be abstract, which is why it gets ignored. So start with the concrete failures. These are the three ways an unprotected API route hurts you, roughly in order of how often they actually happen.

The first is credential stuffing. Somewhere on the internet is a text file containing billions of leaked email and password pairs, and attackers replay those pairs against every login form they can find — including yours. If you wired up authentication with NextAuth, the credentials route it exposes is target number one. Hashing passwords with bcrypt makes each individual guess slow to verify, but it does not stop anyone from making ten thousand guesses; it just spreads the run over a longer evening. And because some of your users reused a password that leaked somewhere else — statistically, several did — one of those guesses will eventually be right. A limiter that allows five attempts per minute turns a ten-thousand-guess run into a month-long project, which is long enough for you to notice it in the logs and long enough for the attacker to move on to a softer target.

The second failure is financial rather than criminal. Some endpoints cost you money every time they run: a newsletter form that calls an email API, a contact form that sends a transactional message, anything that touches a paid LLM, geocoding, or image-processing service. A bot hammering your newsletter endpoint is not trying to break in. It is spending your quota, and possibly your sending reputation, at whatever rate your infrastructure allows. The first sign is usually an email from your provider — either a bill or a warning that your domain is being flagged for spam. Both arrive after the damage is done.

The third failure needs no attacker at all. The most frequent abuser in a small app's logs is the app itself: a client with retry-on-failure and no backoff, a useEffect with a wrong dependency array, a webhook consumer stuck in a loop. One buggy browser tab can generate hundreds of requests per second, and unlike a human attacker it never gets bored and never goes to sleep. A rate limiter converts that bug from an outage — or a startling serverless invoice — into a tidy row of 429s in your logs and a fix the next morning.

Three algorithms, and when the difference actually matters

Every rate limiter answers the same question — has this caller used up their budget? — and the algorithms differ only in how they keep the books. There are three worth knowing.

A fixed window is a turnstile with a counter. It lets five people through, then locks until the wall clock reaches the next minute, at which point the counter resets to zero. One counter per caller per window, almost free to store and check. Its weakness is the boundary: five requests at 11:59:58 and five more at 12:00:01 is ten requests in three seconds — double the rate you thought you set, and perfectly legal under the rules.

A sliding window drops the wall clock and looks backwards from the current moment: has this caller made five requests in the trailing sixty seconds? There is no boundary to exploit and the enforced rate is smooth. The cost is bookkeeping — either you store a timestamp per request, or you approximate by weighting the counters of two adjacent fixed windows, which is what most production libraries do.

A token bucket is a different shape entirely. The bucket holds, say, ten tokens and refills at one token every six seconds. Each request spends a token; an empty bucket means rejection. The result is a limiter that permits a short burst up to the bucket size, then enforces the average rate over time. That is the right model when bursts are legitimate — a page load that fires four API requests at once is normal behaviour, not abuse.

Does the choice matter? Less often than the diagrams suggest. For a login endpoint at five attempts per minute, a fixed window's boundary leak means ten attempts in a bad second — still a rounding error against the millions of guesses credential stuffing needs. For a public API serving real volume, boundary bursts compound across thousands of callers, and a sliding window earns its extra bookkeeping. Pick a token bucket when your own clients legitimately burst. And do not agonize over it: any limiter beats no limiter, and all three hide behind the same function signature, so you can swap the algorithm later without touching your routes.

The key decides who counts as one user

The algorithm is the easy half. The key — the string that identifies whose budget a request spends — is where rate limiting actually goes wrong, because it defines what your limiter believes a person is.

For anonymous traffic the default key is the IP address, and the IP address lies in two directions. The first lie is structural: on any serverless platform your code sits behind a proxy, so the connection you see comes from the proxy, not the visitor. The real client address travels in the x-forwarded-for header. On Vercel the platform terminates the connection and writes that header itself, which makes the first entry trustworthy. On a host where you operate your own proxy chain, the header arrives as whatever the client chose to send plus whatever your infrastructure appended — and trusting the wrong entry means an attacker can reset their budget by editing a header.

lib/get-client-ip.ts
import { NextRequest } from "next/server";

export function getClientIp(request: NextRequest): string {
  // On Vercel the platform sets x-forwarded-for itself,
  // so the first entry is the real client IP.
  const forwarded = request.headers.get("x-forwarded-for");
  if (forwarded) return forwarded.split(",")[0].trim();
  return "unknown";
}

The second lie is human: an IP is not a person. A university dorm, a corporate office, or a mobile carrier's NAT can put thousands of people behind a single address. Set an aggressive per-IP limit on a popular endpoint and one curious office locks out an entire company. The practical rule: per-IP limits should be generous on reads and tight only where the action itself is naturally rare. Nobody legitimately submits a contact form twenty times in a minute, so a tight limit there punishes no one real.

Better keys exist once you know more about the caller. For authenticated routes, key by user ID — it follows the user across networks and is never shared with strangers. For login attempts, add a second limiter keyed by the email being tried: credential stuffing campaigns distribute across thousands of IPs precisely to evade per-IP limits, but the account under attack stays constant. Five attempts per email per fifteen minutes stops a distributed campaign that per-IP limiting never even sees.

Rate limiting and input validation are siblings guarding the same trust boundary — the limiter decides whether you answer at all, and validation decides what you accept. I covered the second half in the Zod at trust boundaries guide; the two patterns belong together in the first ten lines of every public route handler.

The response contract: 429, Retry-After, and a client that listens

Rejecting a request is an API design decision, and there is a contract for it. The status code is 429 Too Many Requests. The one header that genuinely matters is Retry-After, which tells the client how many seconds to wait before trying again is worthwhile. Well-behaved clients honour it, and your own frontend should be the best-behaved client you have.

app/api/contact/route.ts
import { NextRequest, NextResponse } from "next/server";
import { rateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/get-client-ip";

const WINDOW_MS = 60 * 1000; // one minute

export async function POST(request: NextRequest) {
  const ip = getClientIp(request);
  const { success } = rateLimit(`contact:${ip}`, 10, WINDOW_MS);

  if (!success) {
    return NextResponse.json(
      { error: "Too many submissions. Try again in a minute." },
      { status: 429, headers: { "Retry-After": "60" } }
    );
  }

  // validate the body, send the email...
  return NextResponse.json({ ok: true });
}

On the client side, a 429 is not an error to retry immediately — immediate retry on 429 is exactly the loop from the self-DoS scenario, now with your server's blessing. Wait at least the Retry-After value, add some jitter so a crowd of clients does not return in lockstep, and show the human a plain message: too many attempts, try again in a minute. Keep whatever they typed in the form — a contact form that swallows a three-paragraph message because someone double-clicked submit turns a protection into a punishment. Background jobs should back off exponentially with a hard cap.

And log every 429 along with the key that triggered it. A burst of rejections from one IP is a script; a burst across many IPs aimed at one email is credential stuffing; a burst spread across your own signed-in users is a bug you shipped this morning. The only way to tell them apart is from the logs — record the route, the hashed key, and the timestamp on every rejection, and each of those questions takes one query to answer.

In-memory limiters die with the lambda — and sometimes that is fine

Here is where most tutorials quietly stop being honest. The classic implementation is a Map in module scope:

lib/rate-limit.ts
const buckets = new Map<string, { count: number; resetTime: number }>();

export function rateLimit(
  key: string,
  limit: number,
  windowMs: number
): { success: boolean; remaining: number } {
  const now = Date.now();
  const entry = buckets.get(key);

  if (!entry || now > entry.resetTime) {
    buckets.set(key, { count: 1, resetTime: now + windowMs });
    return { success: true, remaining: limit - 1 };
  }

  if (entry.count >= limit) {
    return { success: false, remaining: 0 };
  }

  entry.count += 1;
  return { success: true, remaining: limit - entry.count };
}

On a long-running Node server this behaves exactly as it reads. On Vercel, the same code runs inside a serverless function, and three things change. The Map survives warm invocations of a single function instance, so it mostly works under light traffic. It vanishes on every cold start, so counters reset whenever the platform recycles the instance. And under load the platform runs several instances in parallel, each with its own private Map — so a determined caller gets one budget per instance, not one budget total.

Knowing that, you can decide honestly when it is enough. In-memory limiting is fine when traffic is light enough to stay mostly on warm instances, the deployment is single-region, and the goal is to blunt abuse rather than enforce an exact quota. This site runs precisely this limiter on its newsletter and contact forms in production. The worst realistic outcome is that a bot squeezes a few multiples of the limit out of parallel instances before they converge — a nuisance, not a breach. In exchange: zero dependencies, zero added latency, zero configuration.

It stops being fine when the budget is a security boundary or a financial one. Login and password-reset endpoints, anything metering a paid quota, anything deployed to multiple regions — those need counters every instance can see, in storage that answers within a few milliseconds. In serverless the de-facto answer is Upstash Redis, reached over HTTP so there are no connection pools to babysit from short-lived functions, with the official ratelimit package on top:

lib/rate-limit-upstash.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv(); // UPSTASH_REDIS_REST_URL + _TOKEN

export const loginLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, "15 m"),
  prefix: "rl:login",
});

// In the route handler:
// const { success, reset } = await loginLimiter.limit(`email:${email}`);
// reset is a unix timestamp — convert it to seconds for Retry-After.

The sliding-window state now lives in Redis, every function instance and region reads the same counters, and the limit call returns a reset timestamp you can convert straight into a Retry-After header. The price is a network round trip per check — single-digit milliseconds in the same region — and one more piece of infrastructure to configure. Pay it for your auth routes. The contact form does not need it.

There is a third layer above both: the platform edge. Vercel's WAF rules and Cloudflare's rate limiting stop floods before they ever invoke your functions, which on serverless means before they cost you anything. The trade-off is coarseness — the edge sees IPs and paths, not your users or your business logic, so it cannot know that five requests per minute is suspicious on login and unremarkable on search. Use it as the outer wall against dumb, high-volume traffic, and keep the per-endpoint policy in application code. The layers complement each other; neither replaces the other.

Per-endpoint budgets: sane defaults to start from

Different endpoints deserve different budgets, because the cost of abuse differs. These are the tiers this site starts from — starting points, not gospel; the right numbers come out of your logs, not out of a blog post:

  • Auth endpoints (login, register, password reset): 5 per minute per IP, plus a parallel limit per target email — humans do not try passwords faster than this, and distributed campaigns converge on the account even when they rotate IPs
  • Forms that send email (contact, newsletter): 10 per minute per IP — generous enough for someone fixing a typo and resubmitting, useless for a spam run
  • Search and autocomplete: 30 per minute per user — typing is bursty, so pair the server limit with client-side debouncing
  • Public read APIs: 100 per minute per key or IP, with room to raise it for known consumers

Tune in both directions. If real users hit 429s, your limit is too tight or your key is too coarse — usually a shared IP. If abuse traffic sails comfortably under the limit, it is too loose. Either way the signal lives in your logs, which is one more reason to record every rejection.

warning

Rate limiting belongs on every public POST, not just login. Newsletter signups, contact forms, comment endpoints, webhook receivers — anything that writes a row, sends an email, or spends money. If an unauthenticated visitor can trigger it, a bot eventually will.

Add it this afternoon

The whole point of calling this the cheapest security control is that you can finish it today. Copy the in-memory limiter into lib/rate-limit.ts, call it in the first lines of every public POST handler, and return proper 429s with a Retry-After header. That is roughly an hour of work including testing. Then watch the logs for a week: if you see 429s from real users, loosen the limits; if you see attack patterns on auth routes, that is your cue to move those routes — and only those — to the Upstash limiter.

In the production-grade code checklist rate limiting is one line among forty. But it is the line with the best ratio of incidents prevented to effort spent, and it is exactly the kind of invisible decision that separates a tutorial project from software you can put in front of strangers. Add the line.