
In our previous guide on keeping your entire AI agent system end-to-end type-safe with TypeScript, we locked down every boundary between our Zod tool schemas, our LLM orchestrator, and our React UI components. Looking back at what we have built from Post 13 to Post 21, our DevPulse triage agent now streams tokens in 300 milliseconds, switches between Claude 3.5 Sonnet and GPT-4o, executes live GitHub API tools, renders a zero-CLS thinking timeline, and compiles with zero any casts. Functionally, it feels complete—until you ask one uncomfortable question: what happens the moment you deploy this URL publicly and a single user or automated bot fires 60 complex queries in two minutes?
To make a Next.js AI agent truly production-ready and protect your API budget from Denial-of-Wallet (DoW) attacks, you must implement a Sliding-Window Rate Limiter inside Next.js Edge Middleware (src/middleware.ts) backed by HTTP-based Redis (@upstash/ratelimit) alongside a reusable server-side guard for Server Actions. Unlike standard CRUD endpoints where a request costs a fraction of a cent, a single multi-step agent turn can trigger up to five LLM calls (maxSteps: 5) and multiple external API lookups—requiring dual-tier rate limiting (short-term burst protection plus daily quota caps) that short-circuits abusive traffic at the network edge with HTTP 429 Too Many Requests and Retry-After headers before your LLM keys are ever touched.
This post launches Phase 3 (Posts 22–24) of our implementation roadmap, where we harden our agent for real-world traffic with Edge rate limiting, resilient Server-Sent Events (SSE), and persistent Supabase conversation storage.
The Production Readiness Audit: Why AI Agents Are Uniquely Vulnerable
Before writing our middleware, let us audit the DevPulse stack we have built so far against real production failure modes:
- Type Safety & Schema Guards: Ready (Handled via Zod +
ToolContractRegistryin Post 21). - Streaming UX & Thinking States: Ready (Handled via
useChat+AgentThinkingTimelinein Posts 16 & 20). - External Tool Timeouts: Ready (Handled via
AbortSignal.timeout(5000)in Post 19). - Traffic & Budget Protection: Missing. Right now,
/api/chatand our Server Actions accept unlimited requests from any IP address.
In a traditional web application, if someone refreshes a page 50 times in a minute, your PostgreSQL database barely notices. In an agentic system, cost and latency multiply across steps. Because our agent runs with maxSteps: 5, one user submitting a compound bug triage prompt can trigger:
- Step 1: Prompt + 10-turn history + 4 tool schemas sent to Claude 3.5 Sonnet (~1,500 input tokens).
- Tool 1: Vector documentation lookup + Tool 2: Live GitHub issue API call.
- Step 2: Tool observations appended to scratchpad and re-sent to Claude (~2,200 input tokens + 500 output tokens).
That single click consumes roughly 4,200 tokens—costing between $0.02 and $0.05 depending on the model. Without rate limiting at the Next.js Edge layer, a simple curl loop running for ten minutes can drain $50 of OpenAI or Anthropic credits and exhaust your organization's Tokens-Per-Minute (TPM) quota, locking out every legitimate user on your platform.

How Next.js Edge Middleware Protects the Agent Pipeline
Next.js Middleware (src/middleware.ts) executes at the network edge before a request ever reaches your /api/chat Node.js Route Handler or spins up an LLM connection. By placing a dual-tier sliding-window check at the edge, rejected requests cost $0 in LLM tokens and finish in under 15 milliseconds.
Here is how incoming agent requests flow through our two-tier Edge Middleware rate limiter:
flowchart TD
Client([User / Browser Client]) -->|POST /api/chat| Edge[Next.js Edge Middleware - middleware.ts]
Edge --> Extract[Extract Client IP + Session Identifier]
Extract --> BurstCheck{Tier 1: Sliding Window Burst
Max 5 turns / 60s}
BurstCheck -->|Exceeded| Reject429[Return HTTP 429 JSON
+ Retry-After Header]
BurstCheck -->|Passed| DailyCheck{Tier 2: Daily Quota Guard
Max 40 turns / 24h}
DailyCheck -->|Exceeded| Reject429
DailyCheck -->|Passed| Route[Forward to /api/chat Route Handler
+ X-RateLimit-Remaining Headers]
Route --> LLM[Execute Multi-Step Agent Loop
OpenAI / Claude + Tools]
Reject429 -->|Zero LLM Tokens Spent| ClientArchitecture Flow Summary: Every POST /api/chat request is intercepted first by Next.js Edge Middleware, which extracts the client's IP and session key and evaluates two sliding-window rules: a Tier 1 burst limit (5 turns per 60 seconds) and a Tier 2 daily budget limit (40 turns per 24 hours). If either limit is exceeded, Middleware short-circuits the request at the edge with an HTTP 429 JSON payload and Retry-After countdown header—spending zero LLM tokens—while permitted requests proceed to /api/chat with X-RateLimit-* telemetry headers attached.
Step 1: Installing HTTP-Based Redis for the Edge Runtime
Because Next.js Middleware runs in the lightweight Edge runtime (V8 isolates) rather than a persistent Node.js server, you cannot use traditional TCP-based Redis clients (ioredis) and you should not rely on a plain JavaScript Map() in production (which resets whenever serverless isolates scale or cold-start).
Install the HTTP-based Upstash Redis and Ratelimit SDKs, which are built specifically for Next.js Edge Middleware and serverless functions:
npm install @upstash/ratelimit @upstash/redis
Add your Upstash REST credentials to .env.local:
# .env.local
UPSTASH_REDIS_REST_URL=https://your-instance.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-upstash-rest-token
Step 2: Building the Dual-Tier Agent Rate Limiter (src/lib/agent/rate-limiter.ts)
What if you are testing locally on a plane without internet access to Redis, or what if a user hits your burst limit vs. your daily quota? Let us build src/lib/agent/rate-limiter.ts. This module configures two Upstash sliding-window limiters (5 requests per 60s burst and 40 requests per 24h daily cap) and includes a clean in-memory fallback strictly for local offline development when .env.local Redis keys are absent.
// src/lib/agent/rate-limiter.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
export interface RateLimitDecision {
allowed: boolean;
limitType: 'burst-per-minute' | 'daily-quota';
limit: number;
remaining: number;
resetTimestampMs: number;
retryAfterSeconds: number;
}
const hasUpstashEnv =
Boolean(process.env.UPSTASH_REDIS_REST_URL) &&
Boolean(process.env.UPSTASH_REDIS_REST_TOKEN);
const redisClient = hasUpstashEnv ? Redis.fromEnv() : null;
const burstLimiter = redisClient
? new Ratelimit({
redis: redisClient,
limiter: Ratelimit.slidingWindow(5, '60 s'),
prefix: 'devpulse:ratelimit:burst',
analytics: true,
})
: null;
const dailyLimiter = redisClient
? new Ratelimit({
redis: redisClient,
limiter: Ratelimit.slidingWindow(40, '24 h'),
prefix: 'devpulse:ratelimit:daily',
analytics: false,
})
: null;
const localDevMemoryStore = new Map<
string,
{ count: number; windowResetMs: number }
>();
function checkLocalDevFallback(identifier: string): RateLimitDecision {
const now = Date.now();
const windowMs = 60_000;
const maxBurst = 5;
const existing = localDevMemoryStore.get(identifier);
if (!existing || now > existing.windowResetMs) {
const resetTimestampMs = now + windowMs;
localDevMemoryStore.set(identifier, { count: 1, windowResetMs: resetTimestampMs });
return {
allowed: true,
limitType: 'burst-per-minute',
limit: maxBurst,
remaining: maxBurst - 1,
resetTimestampMs,
retryAfterSeconds: 0,
};
}
if (existing.count >= maxBurst) {
const retryAfterSeconds = Math.max(
1,
Math.ceil((existing.windowResetMs - now) / 1000)
);
return {
allowed: false,
limitType: 'burst-per-minute',
limit: maxBurst,
remaining: 0,
resetTimestampMs: existing.windowResetMs,
retryAfterSeconds,
};
}
existing.count += 1;
return {
allowed: true,
limitType: 'burst-per-minute',
limit: maxBurst,
remaining: maxBurst - existing.count,
resetTimestampMs: existing.windowResetMs,
retryAfterSeconds: 0,
};
}
export async function evaluateAgentRateLimit(
identifier: string
): Promise<RateLimitDecision> {
if (!burstLimiter || !dailyLimiter) {
return checkLocalDevFallback(identifier);
}
const now = Date.now();
const burstResult = await burstLimiter.limit(identifier);
if (!burstResult.success) {
return {
allowed: false,
limitType: 'burst-per-minute',
limit: burstResult.limit,
remaining: burstResult.remaining,
resetTimestampMs: burstResult.reset,
retryAfterSeconds: Math.max(1, Math.ceil((burstResult.reset - now) / 1000)),
};
}
const dailyResult = await dailyLimiter.limit(identifier);
if (!dailyResult.success) {
return {
allowed: false,
limitType: 'daily-quota',
limit: dailyResult.limit,
remaining: dailyResult.remaining,
resetTimestampMs: dailyResult.reset,
retryAfterSeconds: Math.max(1, Math.ceil((dailyResult.reset - now) / 1000)),
};
}
return {
allowed: true,
limitType: 'burst-per-minute',
limit: burstResult.limit,
remaining: burstResult.remaining,
resetTimestampMs: burstResult.reset,
retryAfterSeconds: 0,
};
}
Look at why the Sliding Window algorithm (Ratelimit.slidingWindow(5, '60 s')) is much safer for AI agents than a Fixed Window counter:
In a fixed window that resets at the top of every minute (12:00:00, 12:01:00), an attacker can send 5 heavy agent requests at 12:00:58 and another 5 requests two seconds later at 12:01:01—forcing your backend to process 10 multi-step agent runs in 3 seconds. A sliding window calculates a weighted rolling average across the trailing 60 seconds, making boundary-burst attacks impossible.
Step 3: Writing the Next.js Edge Middleware (src/middleware.ts)
Now let us create src/middleware.ts at the root of our src directory. This middleware intercepts POST requests to /api/chat (and any other /api/agent/* endpoints), resolves the client IP from proxy headers (x-forwarded-for or x-real-ip), checks evaluateAgentRateLimit, and returns a structured JSON error with standard Retry-After headers when a user exceeds their quota.
// src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { evaluateAgentRateLimit } from '@/lib/agent/rate-limiter';
function getClientIdentifier(req: NextRequest): string {
const forwardedFor = req.headers.get('x-forwarded-for');
if (forwardedFor) {
const firstIp = forwardedFor.split(',')[0]?.trim();
if (firstIp) return `ip:${firstIp}`;
}
const realIp = req.headers.get('x-real-ip');
if (realIp) return `ip:${realIp.trim()}`;
return 'ip:127.0.0.1-local';
}
export async function middleware(req: NextRequest) {
if (req.method !== 'POST') {
return NextResponse.next();
}
const identifier = getClientIdentifier(req);
const decision = await evaluateAgentRateLimit(identifier);
const rateLimitHeaders = new Headers({
'X-RateLimit-Limit': String(decision.limit),
'X-RateLimit-Remaining': String(decision.remaining),
'X-RateLimit-Reset': String(decision.resetTimestampMs),
});
if (!decision.allowed) {
rateLimitHeaders.set('Retry-After', String(decision.retryAfterSeconds));
rateLimitHeaders.set('Content-Type', 'application/json');
const friendlyMessage =
decision.limitType === 'daily-quota'
? `Daily AI agent quota reached (${decision.limit} turns/day). Please try again tomorrow.`
: `Rate limit exceeded (${decision.limit} agent turns per minute). Please wait ${decision.retryAfterSeconds}s before sending another query.`;
return new NextResponse(
JSON.stringify({
error: friendlyMessage,
code: 'RATE_LIMIT_EXCEEDED',
limitType: decision.limitType,
retryAfterSeconds: decision.retryAfterSeconds,
}),
{
status: 429,
headers: rateLimitHeaders,
}
);
}
const response = NextResponse.next();
rateLimitHeaders.forEach((value, key) => {
response.headers.set(key, value);
});
return response;
}
export const config = {
matcher: ['/api/chat', '/api/agent/:path*'],
};
Notice two critical engineering choices in src/middleware.ts:
- Scoped
config.matcher: We explicitly restrict middleware execution to['/api/chat', '/api/agent/:path*']and checkif (req.method !== 'POST'). If you omitmatcher, Next.js runs your Redis rate limiter on every single page load, favicon fetch, and_next/staticchunk—burning your Redis quota in seconds! - Structured JSON 429 Payload: Because React's
useChathook reads the body of non-200 responses and exposes it onerror.message, returning a JSON string withretryAfterSecondslets our frontend render a live cooldown timer.
Step 4: Guarding Server Actions and Rendering a Cooldown Timer in React
What about the 'use server' Server Actions we built in Posts 13 and 18 (executeMultiStepTriage)? In Next.js, Server Actions are invoked as POST requests to the page URL rather than /api/chat. That means you should also call a lightweight helper using headers() from next/headers right at the top of any Server Action that invokes an LLM:
// src/lib/agent/server-action-guard.ts
import 'server-only';
import { headers } from 'next/headers';
import { evaluateAgentRateLimit, type RateLimitDecision } from './rate-limiter';
export async function assertServerActionRateLimit(): Promise<RateLimitDecision> {
const headerStore = await headers();
const forwarded = headerStore.get('x-forwarded-for');
const clientIp = forwarded ? forwarded.split(',')[0]?.trim() : '127.0.0.1';
return evaluateAgentRateLimit(`sa-ip:${clientIp}`);
}
Now let us build a clean RateLimitCooldownBanner.tsx component for our React chat interface that parses 429 errors, counts down the exact remaining seconds (retryAfterSeconds), and automatically unlocks the Retry button when the cooldown reaches zero:
// src/components/RateLimitCooldownBanner.tsx
'use client';
import { useEffect, useState } from 'react';
interface RateLimitCooldownBannerProps {
error: Error | undefined;
onRetry: () => void;
}
function extractCooldownSeconds(errorMessage: string): number {
try {
const parsed = JSON.parse(errorMessage) as { retryAfterSeconds?: number };
if (typeof parsed.retryAfterSeconds === 'number' && parsed.retryAfterSeconds > 0) {
return parsed.retryAfterSeconds;
}
} catch {
const match = errorMessage.match(/wait\s+(\d+)s/i);
if (match?.[1]) return Number(match[1]);
}
return 15;
}
export default function RateLimitCooldownBanner({
error,
onRetry,
}: RateLimitCooldownBannerProps) {
const [secondsLeft, setSecondsLeft] = useState<number>(0);
const isRateLimit =
Boolean(error) &&
(error?.message.includes('RATE_LIMIT_EXCEEDED') ||
error?.message.toLowerCase().includes('rate limit') ||
error?.message.includes('429'));
useEffect(() => {
if (!error || !isRateLimit) {
setSecondsLeft(0);
return;
}
const initial = extractCooldownSeconds(error.message);
setSecondsLeft(initial);
const timer = setInterval(() => {
setSecondsLeft((prev) => (prev <= 1 ? 0 : prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [error, isRateLimit]);
if (!error) return null;
return (
<div className='p-3.5 rounded-xl bg-amber-950/50 border border-amber-800/80 text-xs text-amber-100 flex items-center justify-between gap-4'>
<div className='space-y-1'>
<div className='font-semibold text-amber-300'>
{isRateLimit
? '⏳ Agent Rate Limit Cooldown Active'
: '⚠ Agent Request Error'}
</div>
<p className='text-slate-300'>
{isRateLimit
? secondsLeft > 0
? `Protecting API token budget. You can send your next query in ${secondsLeft}s.`
: 'Cooldown complete! You can now retry your turn.'
: error.message}
</p>
</div>
<button
type='button'
disabled={isRateLimit && secondsLeft > 0}
onClick={onRetry}
className='px-3 py-1.5 rounded-lg bg-amber-600 hover:bg-amber-500 disabled:opacity-40 text-white font-semibold shrink-0'
>
{isRateLimit && secondsLeft > 0 ? `Wait ${secondsLeft}s` : 'Retry Turn'}
</button>
</div>
);
}

A Real Production Lesson: Why My In-Memory Map() Rate Limiter Failed on Vercel
When I was deploying my web applications like Alur-Bosta and early Next.js AI agent prototypes on Vercel, I initially tried to save setup time by writing a 20-line rate limiter using a standard JavaScript const ipTracker = new Map() inside my API route. When I tested it on localhost:3000 with npm run dev, it worked like a charm: on the sixth click within a minute, ipTracker.get(ip) returned 5 and blocked the request with a 429.
Thinking I was ready for production, I pushed the code to Vercel and ran a simple load test script that fired 25 concurrent requests to my endpoint. Instead of blocking the last 20 requests, my serverless logs showed that 18 of the 25 requests sailed right through and invoked the LLM API!
Why did that happen? On serverless and Edge platforms like Vercel, your code does not run inside a single long-lived Node.js process. When concurrent requests arrive—or when a function cold-starts in a new isolate—the platform spins up multiple independent execution environments, each with its own fresh, empty new Map(). Even worse, because I had initially placed my middleware without a strict config.matcher, every prefetch request from Next.js <Link> components was triggering middleware invocations.
Switching to an external, atomic HTTP store (@upstash/ratelimit with Redis sliding windows), locking config.matcher strictly to ['/api/chat', '/api/agent/:path*'], and disabling the Retry button in the React UI until secondsLeft === 0 stopped the leak immediately. In local dev, the localDevMemoryStore fallback still lets you code offline, while production shares state atomically across every edge isolate.
Common Mistakes When Rate Limiting AI Agents in Next.js
Before moving on to Server-Sent Events (SSE) in Post 23, make sure your rate-limiting setup avoids these four production traps:
1. Relying on a Global JavaScript Map() in Serverless Production
As we saw in the lesson above, module-level variables in Next.js Middleware or Route Handlers are isolated per serverless instance and wiped on every cold start. Only use a Map() as a local development fallback; always back production rate limiters with an atomic external store like Upstash Redis.
2. Omitting config.matcher in src/middleware.ts
If you forget to export config.matcher (or set it to match all routes), a single browser page load will fire 10 to 15 requests for CSS, JS chunks, images, and prefetches—instantly exhausting your 5-requests-per-minute agent burst limit before the user even types a word! Always scope matcher to your exact AI endpoints.
3. Allowing Immediate 'Retry' Clicks on HTTP 429 Errors
If a user hits your rate limit and your UI shows a clickable 'Retry' button right away, frustrated users will click it five times in three seconds, resetting or extending their penalty window. Always parse retryAfterSeconds from the 429 response and disable the Retry button with a live countdown (Wait 14s).
4. Forgetting That Server Actions Bypass /api/chat Path Matchers
Next.js 'use server' Server Actions send POST requests to the route where the component is rendered (e.g., POST / or POST /dashboard) with a Next-Action header, not to /api/chat. If your app exposes both /api/chat and Server Actions, always call assertServerActionRateLimit() inside the Server Action body as well.
Frequently Asked Questions
What is a realistic rate limit for a production AI agent endpoint?
For an unauthenticated or free-tier AI agent where each turn can run up to 5 tool-calling steps, a Tier 1 burst limit of 5 turns per 60 seconds paired with a Tier 2 quota of 30 to 50 turns per 24 hours per IP or user ID strikes the right balance. It allows a human developer to have a natural debugging conversation (since reading an answer takes 15–30 seconds) while stopping automated scripts cold.
Does running Redis checks in Next.js Middleware add noticeable latency to my agent?
No. Upstash Redis uses connectionless HTTP/REST at the edge, typically resolving a sliding-window check in 8 to 20 milliseconds when colocated in the same region as your Vercel deployment. Compared to a 300ms LLM time-to-first-token, a 12ms edge check is imperceptible to users while saving you from expensive unauthorized LLM calls.
Should I rate limit by IP address or by authenticated userId?
In production, the best practice is a hybrid key: if the user is logged in (which we will wire up with full authentication later in this series), rate limit by user:${userId} so coworkers sharing a corporate office Wi-Fi IP do not block each other. For anonymous or public demo visitors, fall back to ip:${clientIp} extracted from x-forwarded-for.
Need a High-Performance Web App or Custom AI Solution?
I help founders, businesses, and engineering teams build lightning-fast web applications, resilient backend architectures, and intelligent AI workflows. Have an idea in mind? Let’s bring it to life.
Found this article helpful?
Give some claps to support more in-depth engineering logs!
Zahid Hasan Tonmoy
Author & Developer
MERN Full Stack Developer & AI Agent Developer based in Dhaka, Bangladesh. Writing about web development, React, PostgreSQL and my learning journey.