Without rate limiting, your Next.js API routes are open to abuse — anyone can hammer your Stripe checkout endpoint and create thousands of orphaned sessions, enumerate user accounts through your auth endpoints, or burn through your OpenAI credits in minutes. Here's how to add sliding window rate limiting with Upstash Redis in under 30 lines, using the same pattern deployed in GetLaunchpad.
Why rate limiting matters for SaaS APIs
Three attack surfaces appear in almost every SaaS product:
- Stripe checkout endpoint. Each call to
stripe.checkout.sessions.create creates a real Stripe object. A bot that calls your POST /api/stripe/checkoutendpoint 10,000 times will create 10,000 orphaned sessions in your Stripe dashboard. They do not cost money, but they pollute your logs and can trigger Stripe's own abuse detection. - Authentication endpoints. Brute-force password guessing against a
POST /api/auth/login route is trivial without rate limiting. Even with Clerk handling auth, a rate limit on your own routes prevents credential stuffing attacks against any route that validates user-supplied data. - AI inference endpoints. If you expose a route that calls OpenAI, Anthropic, or any other token-based API, a single unthrottled user can exhaust your monthly budget in hours. Rate limiting is the only reliable cost control at the API layer.
Install the Upstash packages
npm install @upstash/redis @upstash/ratelimit
Create a Redis database in the Upstash console and copy the REST URL and token into your environment:
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token_here
Create the Redis client
// lib/redis.ts
import "server-only";
import { Redis } from "@upstash/redis";
export const redis = Redis.fromEnv();
Redis.fromEnv() reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN automatically. The server-only import prevents this file from being bundled into client components, keeping your Redis token out of the browser.
Sliding window vs fixed window
Upstash Ratelimit offers three algorithms. Use sliding window for API rate limiting:
- Fixed window resets the counter at a hard clock boundary. A user can send 10 requests at 11:59:59 and another 10 at 12:00:01 — 20 requests in 2 seconds — and never trigger the limit. This is the burst problem.
- Sliding window tracks requests over a rolling time period. If the limit is 10 per minute, the user can never exceed 10 requests in any 60-second window, regardless of when the minute boundary falls. This is what you actually want.
- Token bucket allows short bursts up to the bucket capacity, then refills at a steady rate. Useful for APIs that should tolerate brief spikes but not sustained load.
Implement the rate limiter
// lib/ratelimit.ts
import "server-only";
import { Ratelimit } from "@upstash/ratelimit";
import { redis } from "./redis";
// 5 requests per 60 seconds per identifier
export const checkoutRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "60 s"),
analytics: true, // records metrics in Upstash console
});
Apply rate limiting to your checkout route
// app/api/stripe/checkout/route.ts
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { checkoutRatelimit } from "@/lib/ratelimit";
export async function POST(request: NextRequest) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Use the authenticated user ID as the rate limit key
const { success, limit, remaining, reset } = await checkoutRatelimit.limit(userId);
if (!success) {
return NextResponse.json(
{ error: "Too many requests" },
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
},
},
);
}
// ... create Stripe Checkout Session
}
Using the authenticated userId as the key means each user has their own counter. An unauthenticated request is rejected at the auth check before the rate limiter is consulted — which also saves a Redis round-trip.
Return a proper 429 with Retry-After
The Retry-Afterheader tells clients how many seconds to wait before retrying. RFC 6585 defines this as a standard header for 429 responses. Well-behaved API clients and SDKs will back off automatically if you include it — and it gives your frontend the information it needs to show a user-friendly "Please wait X seconds" message rather than a generic error.
The reset value from Upstash is a Unix timestamp in milliseconds. Subtract the current time and divide by 1000 to get seconds, then ceil to avoid returning zero.
Testing locally
Upstash Redis works over HTTP, so the same client that runs in production also works on your local machine — no Docker, no local Redis installation required. Set the env vars in .env.local and run your dev server:
npm run dev
# Then in another terminal, hammer the endpoint:
for i in {1..10}; do curl -X POST http://localhost:3000/api/stripe/checkout; done
After the 5th request within 60 seconds you should see a 429 response with the Retry-Afterheader set. The Upstash console's analytics tab will show the request counters updating in near-real-time.
The complete rate limiting setup — Upstash Redis client, sliding window limiter, and the 429 handler with Retry-After — is pre-wired in GetLaunchpad, a Next.js 16 SaaS boilerplate. Your checkout endpoint ships protected on day one.