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.
// 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.
// 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.
// 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.
npm install @upstash/ratelimit @upstash/redisConfigure it once and export the instance, so all routes that import it share the same window and the same connection settings.
// 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.
// 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.
// 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.
| Placement | Best for | Watch out for |
|---|---|---|
| Route file | One expensive endpoint with its own budget | Repeated per file |
| Proxy | A whole path prefix under one rule | Runs before every matched request |
| Host or firewall | Volumetric floods before they reach your code | Cannot 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
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.
Frequently Asked Questions
Why can I not use request.ip anymore?
Does an in-memory counter work in production?
Should rate limiting live in the route or in proxy?
What should the endpoint return when the limit is exceeded?
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.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.