API Rate Limiting with Claude Code
Protect your oRPC procedures and Next.js 16 API routes from abuse and runaway costs with Upstash Redis sliding-window limits, per-user and per-IP keys, clean 429 responses, and graceful client retries.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Problem: One scraper, one runaway retry loop, or one abusive user can hammer an unprotected API endpoint thousands of times a minute. On a serverless stack that means a real bill: function invocations, database reads, and any paid API you call downstream. Most Next.js apps ship with zero rate limiting because it feels like plumbing you will add "later."
Quick Win: You can protect every route with a sliding-window limiter, per-user and per-IP keys, and correct 429 responses in about 30 minutes. Claude Code writes most of it once you hand it the exact pattern below.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Why In-Memory Counters Fail on Serverless
The naive approach is a Map in module scope that counts requests per IP. It works on your laptop and breaks the moment you deploy.
On Vercel, every request can land on a different function instance, and each instance has its own memory. A limit of 10 requests per 10 seconds becomes 10 times the number of warm instances, which changes minute to minute. There is no single counter, so there is no real limit.
The fix is shared state that every instance reads and writes. Redis is the standard choice, and Upstash Redis is built for serverless: it speaks HTTP instead of a raw TCP socket, so it works from route handlers, oRPC procedures, and even proxy.ts on the Edge. Vercel ships a first-party rate limiter built on the same principle, but Upstash is the more portable option, so this guide uses it.
Set Up Upstash Redis
Create a Redis database in the Upstash console (the free tier is enough to start). It gives you two values: a REST URL and a REST token. Add them to your environment so the SDK can read them automatically.
# .env.local
UPSTASH_REDIS_REST_URL="https://your-db.upstash.io"
UPSTASH_REDIS_REST_TOKEN="your-rest-token"Install the two packages. The @upstash/ratelimit package holds the algorithms, and @upstash/redis is the HTTP client it writes counters through.
npm install @upstash/ratelimit @upstash/redisBuild the Core Limiter
Define your limiters in one place and import them everywhere. Two limiters matter: one keyed by user ID for logged-in traffic, and one keyed by IP for anonymous traffic. Give them different prefixes so the two sets of counters never collide in Redis.
Redis.fromEnv() reads the two env vars you just set. Ratelimit.slidingWindow(20, "10 s") allows 20 requests per rolling 10-second window, weighting the current and previous windows so a client cannot double the rate at a boundary. Turning on analytics records usage you can inspect later in the Upstash dashboard.
// lib/ratelimit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
export const redis = Redis.fromEnv();
// Authenticated users get a higher ceiling.
export const userLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, "10 s"),
analytics: true,
prefix: "rl:user",
});
// Anonymous traffic (keyed by IP) gets a tighter ceiling.
export const ipLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "10 s"),
analytics: true,
prefix: "rl:ip",
});You also need the caller's IP. On Vercel the client IP arrives in the x-forwarded-for header, which can hold a comma-separated list where the first entry is the real client. This helper pulls the header and falls back to a placeholder when it is missing (local development, for instance). Note that headers() is async in Next.js 16, so it is awaited.
// lib/rate-key.ts
import { headers } from "next/headers";
export async function getClientIp(): Promise<string> {
const h = await headers();
const forwarded = h.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0].trim();
}
return h.get("x-real-ip") ?? "127.0.0.1";
}Protect a Next.js Route Handler
Here is the pattern for a plain App Router route handler. It picks the user limiter when a session exists and the IP limiter otherwise, then builds the Redis key from the same choice so the limiter and the key always agree.
The .limit() call returns an object with success, limit, remaining, and reset fields. reset is a Unix timestamp in milliseconds telling you when the window frees up. When success is false, return a 429 with a Retry-After header (in seconds) plus the standard X-RateLimit-* headers so clients can back off intelligently. Do the real work only after the check passes.
// app/api/messages/route.ts
import { NextResponse } from "next/server";
import { userLimiter, ipLimiter } from "@/lib/ratelimit";
import { getClientIp } from "@/lib/rate-key";
import { getCurrentUser } from "@/lib/auth";
export async function POST(request: Request) {
const user = await getCurrentUser();
const limiter = user ? userLimiter : ipLimiter;
const key = user ? `user:${user.id}` : `ip:${await getClientIp()}`;
const { success, limit, remaining, reset } = await limiter.limit(key);
const retryAfter = Math.max(0, Math.ceil((reset - Date.now()) / 1000));
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Slow down and try again shortly." },
{
status: 429,
headers: {
"Retry-After": String(retryAfter),
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": String(remaining),
"X-RateLimit-Reset": String(reset),
},
},
);
}
const body = await request.json();
// ... your real handler logic runs here
return NextResponse.json({ ok: true, remaining }, { status: 200 });
}That is the whole contract: check first, reject with a 429 and a Retry-After header, otherwise proceed. Every protected route repeats this shape, which is exactly why it is worth factoring into a limiter you import.
Rate Limit Your oRPC Procedures
If your API runs on oRPC with Zod, you do not want to repeat the check in every handler. A middleware runs the limiter once and every procedure that opts in inherits it. The middleware reads the user ID and IP from your context, runs the same user-or-IP logic, and throws a typed error when the limit is hit.
oRPC maps the TOO_MANY_REQUESTS error code to HTTP 429 automatically, and the data payload rides along to the client so it can read retryAfter. Define the context shape once and build a base from it.
// lib/orpc.ts
import { os, ORPCError } from "@orpc/server";
import { userLimiter, ipLimiter } from "@/lib/ratelimit";
export interface AppContext {
userId?: string;
ip: string;
}
export const base = os.$context<AppContext>();
export const rateLimit = base.middleware(async ({ context, next }) => {
const limiter = context.userId ? userLimiter : ipLimiter;
const key = context.userId
? `user:${context.userId}`
: `ip:${context.ip}`;
const { success, limit, remaining, reset } = await limiter.limit(key);
if (!success) {
const retryAfter = Math.max(0, Math.ceil((reset - Date.now()) / 1000));
throw new ORPCError("TOO_MANY_REQUESTS", {
message: "Rate limit exceeded. Try again shortly.",
data: { retryAfter, limit, remaining },
});
}
return next();
});Any procedure that calls .use(rateLimit) is now protected. The Zod input validation and the rate check both run before your handler body, so an abusive caller never reaches your database.
// api/messages.ts
import { z } from "zod";
import { base, rateLimit } from "@/lib/orpc";
export const sendMessage = base
.use(rateLimit)
.input(z.object({ text: z.string().min(1).max(2000) }))
.handler(async ({ input, context }) => {
// context.userId is available; input.text is validated
// ... persist the message
return { delivered: true };
});To wire the context, populate userId and ip where you build your oRPC handler (in the route that mounts the router), reusing the same getClientIp helper from earlier. The middleware stays identical no matter how many procedures use it.
Add a Global Ceiling in proxy.ts
Per-route limits protect specific endpoints. A global limit in proxy.ts (the Next.js 16 replacement for middleware.ts) catches everything at once, which is your blunt defense against a broad flood before any route code runs. Because Upstash talks over HTTP, the limiter works on the Edge with no extra config.
Keep the global ceiling generous. It is a backstop against volumetric abuse, not a replacement for the tighter per-route limits. The matcher scopes it to your API surface so static assets are not counted. In Next.js 16 the exported function is named proxy.
// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const globalLimiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, "60 s"),
prefix: "rl:global",
});
export const config = {
matcher: "/api/:path*",
};
export async function proxy(request: NextRequest) {
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
"127.0.0.1";
const { success, limit, remaining, reset } = await globalLimiter.limit(
`ip:${ip}`,
);
if (!success) {
const retryAfter = Math.max(0, Math.ceil((reset - Date.now()) / 1000));
return NextResponse.json(
{ error: "Too many requests" },
{
status: 429,
headers: {
"Retry-After": String(retryAfter),
"X-RateLimit-Limit": String(limit),
},
},
);
}
const response = NextResponse.next();
response.headers.set("X-RateLimit-Remaining", String(remaining));
return response;
}Now you have defense in depth: a wide global ceiling at the Edge, and tight per-user and per-IP limits on the routes that actually cost you money.
Handle 429s Gracefully on the Client
A 429 is not an error to swallow silently. Your own frontend can trip the limit during a burst of legitimate activity, and when it does you want a short, automatic backoff instead of a broken screen. This helper retries on 429 only, honoring the server's Retry-After header when present and falling back to exponential backoff (1s, 2s, 4s) when it is not. Every other status passes straight through.
// lib/fetch-with-retry.ts
export async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
maxRetries = 3,
): Promise<Response> {
let attempt = 0;
while (true) {
const response = await fetch(input, init);
if (response.status !== 429 || attempt >= maxRetries) {
return response;
}
const retryAfter = response.headers.get("Retry-After");
const waitSeconds = retryAfter ? Number(retryAfter) : 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
attempt += 1;
}
}Use it anywhere you would call fetch. The caller does not need to know rate limiting exists; it just gets a slightly slower response under load instead of a failure.
// example call site
const response = await fetchWithRetry("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "hello" }),
});
if (!response.ok) {
// still failing after retries: surface a real message to the user
throw new Error("Message could not be sent. Please try again later.");
}One caution: retry only on 429 and only for idempotent or safe-to-repeat actions. Blindly retrying a non-idempotent write can create duplicates. When in doubt, add an idempotency key to the request so a retry is a no-op server-side.
Let Claude Code Wire It Up
You do not have to type all of this by hand. Claude Code writes it reliably once you give it the shape and the constraints. The trick is to be specific about keys, status codes, and headers, because those are the details a vague prompt gets wrong.
claude "Add rate limiting to my oRPC API and Next.js route handlers using
@upstash/ratelimit with Redis.fromEnv(). Use a sliding window. Create two
limiters in lib/ratelimit.ts: userLimiter keyed by user ID and ipLimiter
keyed by IP, with different prefixes. Add an oRPC middleware that picks the
right limiter, throws ORPCError('TOO_MANY_REQUESTS') with retryAfter in the
data payload, and returns 429. Add a global limiter in proxy.ts scoped to
/api. Read the x-forwarded-for header for the IP. Include Retry-After and
X-RateLimit-* headers on every 429."Then hold it to the same bar you would hold yourself to. Ask it to prove the code compiles and passes lint before you trust it:
npx tsc --noEmit
npm run lint
npm run buildRate limiting is exactly the kind of code that looks right and is subtly wrong: an off-by-one on the window, a key that mixes user and IP counters, a 429 without a Retry-After header. Ask Claude to walk one abusive request and one normal request through the full path and explain what Redis stores at each step. That review catches the subtle bugs faster than reading the diff.
Test It Before You Trust It
A rate limiter you have not exercised is a guess. Fire more requests than the limit allows and confirm you get a 429 with the right headers. This one-liner sends 15 requests at a route limited to 10 and prints each status code.
for i in $(seq 1 15); do
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:3000/api/messages \
-H "Content-Type: application/json" \
-d '{"text":"test"}'
doneYou should see a run of 200s followed by 429s once the window fills. Check the response headers on a rejected request with curl -i and confirm Retry-After is present and sane. If every request returns 200, your counter is not shared (the in-memory trap), and if the very first request 429s, your window or limit is misconfigured.
Where the $29 Code Kit Fits
Everything above is standard Claude Code plus Upstash. You can do it with an Anthropic paid plan and an afternoon. The $29 Code Kit is a one-time purchase (no subscription) that puts a harness around Claude Code so this kind of work runs as a pipeline instead of a single prompt: it plans the change, builds it, evaluates the output, tests it against a running app, and holds every feature to the same gates used here (zero type errors, zero lint errors, a clean build) before anything ships. Claude Code itself still needs a paid Anthropic plan; the Kit is the process layer on top that makes "check first, reject with a 429, otherwise proceed" the default for every route you add, not a thing you remember to do. It does not replace understanding your limiter. It makes the boring, easy-to-forget guardrails automatic.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Posted by @speedy_devv
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
AI Chat Feature
Build a streaming in-app AI assistant with the Vercel AI SDK: token streaming to the UI, tool calling into your own data, message persistence in Supabase, and per-user usage limits.
Stripe Webhooks
Build a signature-verified Stripe webhook handler that survives retries. Idempotency, event dedupe, and processing offloaded to Inngest so a slow handler never drops a payment event.