The Real Cost of Unprotected Next.js APIs
Next.js applications running on Vercel are billed by execution duration and invocation count. A single malicious actor hitting an expensive endpoint can generate thousands of function invocations in minutes. One production incident I reviewed showed a scraping bot generating $380 in compute costs over 48 hours before limits were added.
Rate limiting prevents brute-force login attempts, protects AI inference routes that call GPT-4o or Claude, and stops data-extraction attacks on public APIs. Beyond cost, it also helps maintain predictable performance for legitimate users and satisfies basic compliance requirements for many B2B SaaS products.
The most effective implementation for serverless environments is the sliding window algorithm backed by a globally distributed Redis. Upstash provides exactly this combination with single-digit millisecond latency from any Vercel edge location.
Rate Limiting Algorithms and Trade-offs
Fixed-window counters are easiest to implement but suffer from the "boundary burst" problem. A client can send the full quota in the last second of one window and again in the first second of the next, effectively doubling the intended rate.
Sliding window uses a Redis sorted set to store timestamps of each request. On every incoming request the implementation removes timestamps older than the window and counts the remainder. This gives precise control at the cost of storing one member per request in the sorted set.
Token bucket offers burst tolerance but requires additional logic for refill rates. For most Next.js applications the sliding window algorithm provides the best accuracy-to-complexity ratio. The @upstash/ratelimit package implements this efficiently using Lua scripts that execute atomically inside Redis.
Concrete numbers: a sliding window of 100 requests per 60 seconds stores at most 100 members per identifier. With proper prefixing and automatic expiration, memory usage stays predictable even at 50k unique users per day.
Creating an Upstash Redis Database
Sign up at upstash.com, navigate to the Redis section and click "Create Database". Select the region closest to your primary user base (usually us-east-1 or eu-west-1 for North America and Europe). Choose the Free tier initially. After creation, copy the REST URL and REST Token.
Add these values to your .env.local file exactly as follows:
UPSTASH_REDIS_REST_URL=https://your-database.upstash.io
UPSTASH_REDIS_REST_TOKEN=xxxxxxxxxxxxxxxx
The REST interface is deliberately chosen because it works inside Vercel Edge Runtime where TCP connections are not available. The official @upstash/redis client abstracts the HTTP calls and retry logic.
Installing and Configuring the Ratelimit SDK
Run this exact command in your project root:
npm install @upstash/redis@1.34.0 @upstash/ratelimit@2.0.3
Create a new file at lib/rate-limit.ts with the following configuration:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
export const redis = Redis.fromEnv();
export const generalRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "10 s"),
analytics: true,
prefix: "ratelimit:general",
});
export const authRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(60, "1 m"),
analytics: true,
prefix: "ratelimit:auth",
});
export const aiRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(15, "5 m"),
analytics: true,
prefix: "ratelimit:ai",
});
Three separate limiters allow different policies per endpoint type. The AI limiter is intentionally stricter because each request may trigger an LLM call costing real money.
Applying Global Limits with Middleware
Create or update middleware.ts in the project root:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { generalRatelimit } from "./lib/rate-limit";
export async function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith("/api")) {
return NextResponse.next();
}
const ip = request.ip ?? request.headers.get("x-forwarded-for") ?? "anonymous";
const { success, limit, remaining, reset } = await generalRatelimit.limit(ip);
const response = NextResponse.next();
response.headers.set("X-RateLimit-Limit", limit.toString());
response.headers.set("X-RateLimit-Remaining", remaining.toString());
response.headers.set("X-RateLimit-Reset", new Date(reset).toISOString());
if (!success) {
return new NextResponse(
JSON.stringify({
error: "Too many requests",
retryAfter: Math.ceil((reset - Date.now()) / 1000),
}),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
},
}
);
}
return response;
}
export const config = {
matcher: ["/api/:path*"],
};
This middleware runs on the edge, adds the three standard rate-limit headers, and returns a structured 429 response with retry timing. The matcher excludes static files automatically.
Per-User Limits Inside Route Handlers
For authenticated routes you should key limits by user ID rather than IP. Here is a complete API route example using the App Router:
import { NextResponse } from "next/server";
import { authRatelimit, aiRatelimit } from "@/lib/rate-limit";
import { getCurrentUser } from "@/lib/auth";
export async function POST(request: Request) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const limiter = user.plan === "paid" ? aiRatelimit : authRatelimit;
const identifier = `user_${user.id}`;
const { success, remaining, reset } = await limiter.limit(identifier);
if (!success) {
return NextResponse.json(
{
error: "Rate limit exceeded",
retryAfterSeconds: Math.ceil((reset - Date.now()) / 1000),
remaining: 0,
},
{ status: 429 }
);
}
// Proceed with actual work
const body = await request.json();
// ... business logic
return NextResponse.json({
success: true,
rateLimitRemaining: remaining,
});
}
The getCurrentUser function can come from NextAuth, Clerk, Supabase, or any session library. Paid users receive the higher aiRatelimit quota automatically.
Propagating Rate Limit Data to the Frontend
Frontend code should read the rate-limit headers and surface information to users. Here is a React hook example using TanStack Query:
function useRateLimitInfo() {
const queryClient = useQueryClient();
const handleResponse = (response: Response) => {
const remaining = response.headers.get("X-RateLimit-Remaining");
const reset = response.headers.get("X-RateLimit-Reset");
if (remaining) {
queryClient.setQueryData(["rateLimit"], {
remaining: parseInt(remaining),
resetTime: reset,
});
}
if (response.status === 429) {
const data = response.json();
// Show toast: `Limit reached. Retry in ${data.retryAfterSeconds}s`
}
};
return { handleResponse };
}
This pattern prevents users from hitting the limit unexpectedly and improves perceived reliability of your application.
Tiered Plans and Business Logic Integration
Store plan information in your database (Supabase, PlanetScale, or Postgres). On user creation default to the free tier. When a user upgrades via Stripe webhook, update their plan column. The route handler shown earlier reads this value on every request.
Recommended starting limits for a typical SaaS in 2024:
- Free tier: 60 requests per minute on general endpoints, 15 AI generations per 5 minutes
- Pro tier: 300 requests per minute, 120 AI generations per 5 minutes
- Enterprise: custom limits stored per organization in Redis itself
These numbers balance protection with usability. Adjust after observing actual usage patterns via Upstash analytics.
Testing the Implementation Thoroughly
Use autocannon for load testing. Install it globally:
npm install -g autocannon
Then run a test against your rate-limited endpoint:
autocannon -c 20 -a 200 -m POST -H "content-type: application/json" \
-b '{"prompt":"test"}' http://localhost:3000/api/generate
The test should return 429 responses after the quota is exhausted. Verify that different user IDs receive independent quotas by including an Authorization header with different tokens.
Unit test the limiter directly by mocking the Redis client or using a test instance. The @upstash/ratelimit package exposes a limit method that can be called repeatedly in a Jest test to assert correct remaining counts and reset times.
Monitoring and Alerting on Rate Limit Events
Enable analytics in the Ratelimit constructor (already shown). Upstash provides a dashboard showing top blocked identifiers, request patterns over time, and geographic distribution.
For production observability, log blocked attempts to Vercel Logs or a dedicated logging service. Example code inside the 429 handler:
console.warn(`Rate limit exceeded for ${identifier} from ${ip}`);
Connect a monitoring tool such as Better Stack or Sentry to create alerts when rate-limit events exceed 5% of total traffic in a 10-minute window. This often indicates either a new attack vector or an overly aggressive limit that needs adjustment.
Common Pitfalls and How to Avoid Them
Using the same identifier for all unauthenticated users (e.g. "anonymous") creates a single shared quota that legitimate users can exhaust. Always use IP addresses or fingerprinting for public routes.
Another mistake is forgetting to exclude internal cron jobs or webhooks from rate limiting. Add a specific matcher or bypass header for these paths.
Clock skew between your application and Redis is negligible with sliding window because it uses Redis server time. Fixed-window implementations are more vulnerable to this issue.
Finally, do not store sensitive data in the Redis sorted sets. The only values stored are timestamps and identifiers. This keeps the implementation compliant with most privacy regulations.
Scaling to Higher Traffic Volumes
The implementation shown scales to tens of thousands of requests per minute because each limit call performs only two Redis operations: ZREMRANGEBYSCORE and ZADD/ZCARD inside a single Lua script. Upstash handles replication and global distribution automatically.
When you exceed the free tier, Upstash charges $0.20 per additional 100k commands. At 10 requests per user with sliding window you can support roughly 500 concurrent active users per dollar spent on Redis.
For applications exceeding 100k requests per minute, consider sharding identifiers across multiple Redis instances using consistent hashing, though this is rarely needed for indie or small-business products.
Combine rate limiting with circuit breakers for downstream services (OpenAI, Stripe, database) to create defense in depth. The rate limiter protects your infrastructure; circuit breakers protect your wallet when external APIs degrade.
Extending the Pattern for Specialized Use Cases
For file upload endpoints, limit by both requests and total bytes processed. The @upstash/ratelimit package supports custom scripts if you need to track multiple metrics simultaneously.
AI-powered products can rate limit by estimated token usage instead of raw requests. Store a lightweight token estimate in the sorted set member score and adjust the limit logic accordingly.
Finally, consider adding progressive rate limiting: start with generous quotas and tighten them automatically when abuse patterns are detected using simple heuristics on the analytics data.
This complete implementation provides enterprise-grade protection while remaining only 60 lines of configuration and middleware code. Deploy it early, monitor the analytics dashboard for two weeks, then tune the window sizes based on real usage data. Your cost charts and error logs will thank you.
