Build This Now
Build This Now
Qu'est-ce que le code Claude ?Installer Claude CodeL'installateur natif de Claude CodeTon premier projet Claude Code
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email SequencesClaude Code v2.1.122 Release NotesClaude Code Dynamic Workflows : comment orchestrer 1 000 sous-agents sur une vraie codebaseBonnes pratiques Claude CodeMeilleures pratiques pour Claude Opus 4.7Claude Code sur un VPSIntégration GitRevue de code avec Claude CodeLes Worktrees avec Claude CodeClaude Code à distanceClaude Code ChannelsChannels, Routines, Teleport, DispatchTâches planifiées avec Claude CodePermissions Claude CodeLe mode auto de Claude CodeAjouter les paiements Stripe avec Claude CodeFeedback LoopsWorkflows TodoGestion des tâches dans Claude CodeTemplates de projetTarification et utilisation des tokens Claude CodeTarifs de Claude Code : ce que tu vas vraiment payerClaude Code Ultra ReviewConstruire une app Next.js avec Claude CodeClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeConstruire un MVP SaaS avec Claude CodeAjouter l'authentification avec Claude Code (Supabase Auth)Ajouter les emails transactionnels avec Claude Code (Resend + React Email)Construire une API type-safe avec Claude Code (oRPC + Zod)How to Add File Uploads With Claude Code (Supabase Storage)How to Run Background Jobs with Claude Code and InngestHow to Build an Admin Dashboard With Claude CodeHow to Add Full-Text Search With Claude Code (Postgres)Commerce agentique : comment construire une app que les agents IA peuvent payerClaude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Headless ModeMax Plan vs APIPrompt CachingCombien coûte la création d'un SaaS avec Claude Code en 2026Run a Team of AI Agents in Parallel with Git WorktreesPrompt Injection in Coding Agents: How to Not Get Pwned
speedy_devvkoen_salo
Blog/Handbook/Workflow/Rate Limiting

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.

Découvrez ce que nous construisons pour les entreprises →
speedy_devvWritten by speedy_devvPublished Jul 14, 20269 min readHandbook hubWorkflow index

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.

Découvrez ce que nous construisons pour les entreprises →

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/redis

Build 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 build

Rate 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"}'
done

You 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.

Découvrez ce que nous construisons pour les entreprises →

Posted by @speedy_devv

Continue in Workflow

  • Commerce agentique : comment construire une app que les agents IA peuvent payer
    Un guide en français simple du commerce agentique en 2026 : ce que font x402, ACP et le Machine Payments Protocol, plus un pas-à-pas d'un week-end pour livrer une API payante que les agents IA peuvent acheter.
  • Bonnes pratiques Claude Code
    Cinq habitudes séparent les ingénieurs qui livrent avec Claude Code : les PRDs, les règles CLAUDE.md modulaires, les slash commands personnalisés, les resets /clear, et un état d'esprit d'évolution du système.
  • Le mode auto de Claude Code
    Un second modèle Sonnet examine chaque appel d'outil Claude Code avant qu'il s'exécute. Ce que le mode auto bloque, ce qu'il autorise, et les règles d'autorisation qu'il place dans tes paramètres.
  • Channels, Routines, Teleport, Dispatch
    Les quatre fonctionnalités Claude Code livrées par Anthropic en mars et avril 2026 qui transforment le CLI en une couche de coordination orientée événements, entre téléphone, web et desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Principes de base de l'agent
    Cinq façons de construire des agents spécialisés dans le code Claude : Sous-agents de tâches, .claude/agents YAML, commandes slash personnalisées, personas CLAUDE.md, et invites de perspective.
  • L'ingénierie du harness agent
    Le harness, c'est toutes les couches autour de ton agent IA sauf le modèle lui-même. Découvre les cinq leviers de contrôle, le paradoxe des contraintes, et pourquoi le design du harness détermine les performances de l'agent bien plus que le modèle.
  • Patterns d'agents
    Orchestrateur, fan-out, chaîne de validation, routage par spécialiste, raffinement progressif, et watchdog. Six formes d'orchestration pour câbler des sub-agents Claude Code.
  • Meilleures pratiques des équipes d'agents
    Patterns éprouvés pour les équipes d'agents Claude Code. Prompts de création riches en contexte, tâches bien calibrées, propriété des fichiers, mode délégué, et correctifs v2.1.33-v2.1.45.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

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.

On this page

Why In-Memory Counters Fail on Serverless
Set Up Upstash Redis
Build the Core Limiter
Protect a Next.js Route Handler
Rate Limit Your oRPC Procedures
Add a Global Ceiling in proxy.ts
Handle 429s Gracefully on the Client
Let Claude Code Wire It Up
Test It Before You Trust It
Where the $29 Code Kit Fits

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →