Utility · Starter
Rate Limiting
Sliding-window rate limiting for Next.js 14+ API routes — in-memory store for development, drop-in Upstash Redis upgrade for production, with per-IP and per-user strategies and standard RateLimit-* response headers.
Read the Getting Access guideif you haven't yet.
1. Get the file
Sign in at marrowstack.dev, open the Rate Limiting block, and click Copy all files. Paste lib/ratelimit.ts into your project.
Out of the box the block uses an in-process memory store — no external dependencies required. When you're ready for production, swap in Upstash Redis by setting two environment variables (see section 4).
2. Prerequisites
- Next.js 14 or 15, App Router
- TypeScript 5+
- Upstash Redis account (optional — only for production Redis backend)
3. Install
No dependencies required for the in-memory mode. For Upstash Redis:
npm install @upstash/redis4. Environment
| Variable | Required | Default | Purpose |
|---|---|---|---|
| UPSTASH_REDIS_REST_URL | no | — | Upstash Redis REST URL. When set, Redis is used instead of in-memory store. |
| UPSTASH_REDIS_REST_TOKEN | no | — | Upstash Redis REST token. Required alongside UPSTASH_REDIS_REST_URL. |
When neither variable is set, the block falls back to an in-process sliding-window store. The in-memory store is not shared across serverless instances — use Redis in production.
5. Wire it in
Wrap any API route handler with withRateLimit():
import { withRateLimit } from '@/lib/ratelimit'
import { NextRequest, NextResponse } from 'next/server'
export const POST = withRateLimit(
async (req: NextRequest) => {
// your handler logic here
return NextResponse.json({ ok: true })
},
{
limit: 5, // max requests
window: 60, // per 60 seconds
strategy: 'ip', // 'ip' | 'user' | 'global'
}
)import { withRateLimit } from '@/lib/ratelimit'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
export const POST = withRateLimit(
async (req) => {
const session = await getServerSession(authOptions)
// handler...
return Response.json({ ok: true })
},
{
limit: 20,
window: 3600, // 20 requests per hour
strategy: 'user', // uses session user ID as the key
getUserId: async (req) => {
const session = await getServerSession(authOptions)
return session?.user?.id ?? null
},
}
)const res = await fetch('/api/contact', { method: 'POST', body: JSON.stringify(data) })
// Standard rate limit headers are set on every response
const limit = res.headers.get('RateLimit-Limit') // e.g. "5"
const remaining = res.headers.get('RateLimit-Remaining') // e.g. "4"
const reset = res.headers.get('RateLimit-Reset') // Unix timestamp
if (res.status === 429) {
const retryAfter = res.headers.get('Retry-After')
console.log(`Rate limited. Retry after ${retryAfter}s.`)
}6. Verify it works
- Set
limit: 2, window: 10temporarily and call the route 3 times — confirm the third returns429 Too Many Requests. - Inspect the response headers and confirm
RateLimit-Limit,RateLimit-Remaining, andRetry-Afterare present. - Wait for the window to expire and confirm requests succeed again.
- If using Upstash: open the Upstash console and confirm sliding-window keys appear under your database.
7. Failure modes & fixes
Rate limit not enforced — all requests pass
Cause: When using the in-memory store, each serverless function instance has its own counter. Two concurrent cold starts each start at 0.
Fix: Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN to use a shared Redis store. For local dev, in-memory is fine.
strategy: 'user' always falls back to IP
Cause: getUserId() returned null — session was not available or the user was not signed in.
Fix: Ensure the route is behind an auth check, or handle the null case explicitly to apply a stricter IP-based limit for unauthenticated users.
429 response has no Retry-After header
Cause: An older version of the block file is in use.
Fix: Re-copy lib/ratelimit.ts from the block detail page to get the latest version with standard headers.