Rate Limiting a Next.js API Endpoint

Identify the caller, count requests in a window, and return a 429 with Retry-After, first in memory and then with a shared store that survives multiple instances.

8 min read

Rate limiting in Next.js caps how many requests one caller may make in a time window. You enforce it inside a Route Handler, and when the budget is gone you answer with a 429 status instead of doing the work.

Three decisions make up any implementation: who the caller is, where the counters live, and what the rejection response looks like.

Identify the caller

Counting only works if you can tell requests apart. An authenticated user id or API key is the best key, because it survives a change of network and cannot be swapped by opening a new tab.

When the request is anonymous, the IP address is a common fallback. NextRequest no longer exposes it, since the ip and geo properties were removed in Next.js 15, so use the trusted client-IP value documented by your host.

For example, the helper below fits a deployment whose trusted proxy overwrites X-Forwarded-For with the verified client address.

typescripttypescript
// lib/client-key.ts
export function clientKey(request: Request) {
  const forwarded = request.headers.get('x-forwarded-for')
  const clientIp = forwarded?.split(',')[0]?.trim()
  return clientIp ? `ip:${clientIp}` : 'ip:unknown'
}

Do not copy the leftmost-value rule to an unknown proxy setup. A caller can prepend a fake address when a proxy appends rather than overwrites the header, so follow your provider's helper or trusted-proxy selection rules.

The ip:unknown fallback deliberately shares one bucket instead of letting requests without the header bypass the limit. Related request-reading helpers are covered in NextRequest and NextResponse explained.

Count requests in a window

The simplest counter keeps recent timestamps per key and drops the ones that have aged out. This runs on the server, in the same process as the handler.

typescripttypescript
// lib/rate-limit.ts
const hits = new Map<string, number[]>()
 
export function isRateLimited(key: string, max = 10, windowMs = 60_000) {
  const now = Date.now()
  const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs)
  recent.push(now)
  hits.set(key, recent)
  return recent.length > max
}

Each call filters out timestamps older than the window, records the current one, and reports whether the caller has gone past the budget. Ten requests per minute per key is a starting point, not a rule.

Answer with 429 and Retry-After

The handler checks the limit before it touches a database or a paid API, so a blocked request costs almost nothing.

typescripttypescript
// app/api/search/route.ts
import { isRateLimited } from '@/lib/rate-limit'
import { clientKey } from '@/lib/client-key'
 
const tooMany = { status: 429, headers: { 'Retry-After': '60' } }
export async function POST(request: Request) {
  if (isRateLimited(clientKey(request))) {
    return Response.json({ error: 'Too many requests' }, tooMany)
  }
  return Response.json({ results: [] })
}

The eleventh request within a minute gets a 429 with a JSON body, and the browser network panel shows the Retry-After header telling the client to wait sixty seconds. Your client code must read the header and schedule the retry; the browser does not do that automatically.

Returning 429 rather than 403 matters: it tells the caller the request was well formed and will succeed later.

Why the in-memory version is not enough

The map above lives in one process. That is fine for a single long-running server, and misleading everywhere else.

  • On a serverless host, each invocation may start with an empty map, so the counter resets constantly.
  • Across several instances, every instance keeps its own map, and the effective limit becomes the budget multiplied by the instance count.
  • A deploy or a restart clears all counters at once.

The fix is to move the counters into a store every instance can reach.

A limiter backed by a shared store

Upstash publishes a rate limiting library that speaks HTTP to Redis, which suits environments where holding a TCP connection is awkward.

bashbash
npm install @upstash/ratelimit @upstash/redis

Configure it once and export the instance, so all routes that import it share the same window and the same connection settings.

typescripttypescript
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
 
export const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '60 s'),
})

The sliding window counts ten requests per sixty seconds per key. Redis.fromEnv() reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN, which keeps those credentials on the server and out of the bundle.

The check itself is now asynchronous, and the result carries the next reset boundary reported by the limiter.

typescripttypescript
// lib/too-many.ts
export function tooManyRequests(reset: number) {
  const seconds = Math.max(1, Math.ceil((reset - Date.now()) / 1000))
  return Response.json(
    { error: 'Too many requests' },
    { status: 429, headers: { 'Retry-After': String(seconds) } }
  )
}

Deriving the wait from that boundary is more useful than a fixed number. Upstash documents that a sliding window's reset value is the start of the next window, not an exact promise that every earlier hit has left the calculation.

typescripttypescript
// app/api/search/route.ts
import { ratelimit } from '@/lib/rate-limit'
import { clientKey } from '@/lib/client-key'
import { tooManyRequests } from '@/lib/too-many'
 
export async function POST(request: Request) {
  const { success, reset } = await ratelimit.limit(clientKey(request))
  if (!success) return tooManyRequests(reset)
  return Response.json({ results: [] })
}

Every instance now shares one budget, and a restart no longer hands callers a fresh allowance. The limit call also returns the ceiling and the remaining count if you want to expose them as response headers.

Where to put the rule

The same logic can sit at three different levels, and they solve different problems.

PlacementBest forWatch out for
Route fileOne expensive endpoint with its own budgetRepeated per file
ProxyA whole path prefix under one ruleRuns before every matched request
Host or firewallVolumetric floods before they reach your codeCannot see application identity

Proxy is a good fit when the limit belongs to a section of the site rather than a single handler, as described in proxy.ts and what replaced middleware. Keep in mind that Server Functions are POST requests to the route they are used on, so a matcher that excludes a path also skips those calls.

Platform level protection is worth enabling in addition, since it drops floods before they consume any of your execution time. Those features vary by host, so check what yours offers rather than assuming.

Common mistakes

  • Trusting a forwarded header on an endpoint that is reachable without going through your proxy.
  • Doing the expensive work first and checking the limit afterwards.
  • Using one global counter, so a single noisy caller locks out everyone else.
  • Returning 429 without Retry-After, which leaves clients without a backoff hint.
  • Assuming a limit on the route also protects a Server Function, which needs its own checks as covered in securing server actions.

The short version

Choose an identifier you can trust, count requests for it in a window, and return 429 with Retry-After once the budget runs out. Start in memory to get the shape right, then move the counters to a shared store before the endpoint runs on more than one instance. Public endpoints such as webhook receivers benefit from the same treatment.

Rune AI

Rune AI

Key Insights

  • Pick an identifier: a user or API key beats an IP address when you have one.
  • NextRequest lost its ip property in Next.js 15, so use the trusted IP value documented by your host.
  • Return 429 with Retry-After so clients know how long to wait.
  • In-memory counters do not survive multiple instances or serverless invocations.
  • A shared store such as Redis gives one budget across every instance.
  • Put the rule in the route for one endpoint and in proxy for a whole prefix.
RunePowered by Rune AI

Frequently Asked Questions

Why can I not use request.ip anymore?

The ip and geo properties were removed from NextRequest in Next.js 15. Use the trusted client-IP helper or header documented by your host, or identify an authenticated caller by user or API key.

Does an in-memory counter work in production?

Only on a single long-running server process. Serverless invocations and multiple instances each keep their own map, so the effective limit is multiplied by the number of instances and resets whenever an instance is recycled.

Should rate limiting live in the route or in proxy?

Use the route file when one endpoint is expensive and needs its own budget. Use proxy when a whole path prefix shares one rule, and remember it runs before every matched request.

What should the endpoint return when the limit is exceeded?

Return a 429 status with a Retry-After header giving the wait in seconds. Client code must read that header and implement the backoff; browsers do not retry later automatically.

Conclusion

Rate limiting a Next.js endpoint means choosing an identifier you trust, counting requests for it in a time window, and returning 429 with Retry-After once the budget is gone. An in-memory counter teaches the shape but only holds on a single instance, so anything deployed across several instances needs a shared store.