Rate Limiting in Next.js with Upstash Redis: Protect Your API in 10 Minutes
Implement production-ready rate limiting in Next.js and tRPC with serverless Upstash Redis. Patterns by route, by user, by organization.
Why you need this (even at an early stage)
Without rate limiting, your SaaS is exposed to 3 common attacks:
- Brute force on login: an attacker tries 10,000 passwords in 30 seconds
- Per-user API abuse: a customer loop-tests your freemium limit
- AI cost spikes: a script calls your Mistral route 50,000 times and burns $800
Rate limiting is one of the highest impact-to-effort security measures there is. 10 minutes to wire up Upstash, zero maintenance after.
Why Upstash (and not self-hosted Redis)
Upstash Redis is serverless: pay-per-request pricing ($1/100k requests), no cluster to maintain, sub-10ms latency in the EU.
For rate limiting a Next.js SaaS on Vercel, it's the perfect combo:
- Vercel Functions are serverless and stateless โ you need an external Redis
- Upstash is serverless too, with no persistent connection (HTTP/REST)
- No connection pool to manage
pnpm add @upstash/redis @upstash/ratelimit// src/lib/redis.ts
import { Redis } from "@upstash/redis";
import { env } from "~/env";
export const redis = new Redis({
url: env.UPSTASH_REDIS_REST_URL,
token: env.UPSTASH_REDIS_REST_TOKEN,
});Pattern 1: rate limit by IP on login
The most critical one. Brute-forcing /api/auth/signin is attack #1.
// src/lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { redis } from "./redis";
export const loginLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "15 m"), // 5 attempts / 15 min
analytics: true,
prefix: "ratelimit:login",
});// src/app/api/auth/[...nextauth]/route.ts
import { loginLimiter } from "~/lib/rate-limit";
export async function POST(req: Request) {
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "anonymous";
const { success, limit, remaining, reset } = await loginLimiter.limit(ip);
if (!success) {
return new Response(
JSON.stringify({
error: "Too many attempts. Try again in a few minutes.",
}),
{
status: 429,
headers: {
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": String(remaining),
"X-RateLimit-Reset": String(reset),
"Retry-After": String(Math.ceil((reset - Date.now()) / 1000)),
},
},
);
}
// ... rest of the auth handler
}Tip
Use slidingWindow rather than fixedWindow. With a fixed window, an
attacker can send 10 requests at 23:59:59 and 10 more at 00:00:00. A sliding
window closes that gap.
Pattern 2: rate limit by user in tRPC
For authenticated routes, rate-limit by user ID, not by IP (a user can change IP, and multiple users can share one IP behind a corporate NAT).
// src/lib/rate-limit.ts
export const trpcLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 req/min per user
prefix: "ratelimit:trpc",
});// src/server/api/trpc.ts
import { trpcLimiter } from "~/lib/rate-limit";
export const protectedProcedure = t.procedure.use(
async ({ ctx, next, path }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// Rate limit by user + endpoint
const key = `${ctx.session.user.id}:${path}`;
const { success, reset } = await trpcLimiter.limit(key);
if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Try again in ${Math.ceil((reset - Date.now()) / 1000)}s`,
});
}
return next({ ctx: { ...ctx, session: ctx.session } });
},
);Pattern 3: rate limit by organization for AI
AI routes are expensive. One user, malicious or just running a runaway script, can burn their entire organization's monthly quota in 10 minutes.
export const aiLimiter = new Ratelimit({
redis,
// 30 AI calls / hour / organization
limiter: Ratelimit.slidingWindow(30, "1 h"),
prefix: "ratelimit:ai",
});
// Inside a tRPC procedure
generateContent: requirePermission("ai:use")
.input(z.object({ prompt: z.string() }))
.mutation(async ({ ctx, input }) => {
const orgId = ctx.session.user.organizationId;
const { success } = await aiLimiter.limit(orgId);
if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Hourly AI limit reached for your organization",
});
}
return generateWithMistral(input.prompt);
}),Combine with the freemium guard
The freemium check enforces the monthly quota (5/100/500), the rate limit stops a hostile burst (30/hour max).
Pattern 4: rate limit on public webhooks
Your /api/webhooks/stripe and /api/webhooks/resend endpoints must never be saturated by spam.
export const webhookLimiter = new Ratelimit({
redis,
limiter: Ratelimit.tokenBucket(50, "10 s", 100), // burst 100, refill 50/10s
prefix: "ratelimit:webhook",
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "unknown";
const { success } = await webhookLimiter.limit(`stripe:${ip}`);
if (!success) return new Response("Rate limited", { status: 429 });
// ... HMAC signature verification + processing
}Token bucket is the right algorithm for webhooks: it allows legitimate bursts (Stripe can send 50 events in a second after a migration) while still blocking sustained spam.
Pattern 5: whitelisting and bypass
For E2E tests or admin accounts, you'll want to bypass rate limiting.
const WHITELIST_IPS = new Set(env.RATE_LIMIT_WHITELIST?.split(",") ?? []);
export async function checkRateLimit(
limiter: Ratelimit,
identifier: string,
ip: string,
) {
if (WHITELIST_IPS.has(ip)) return { success: true };
return limiter.limit(identifier);
}Environment variables:
# .env.local: for dev
RATE_LIMIT_WHITELIST="127.0.0.1,::1"
# Vercel Production
RATE_LIMIT_WHITELIST="" # empty in prodMonitoring
The Upstash Console has built-in graphs (analytics: true). To go further, export your own metrics to Prometheus or Vercel Analytics:
import { track } from "@vercel/analytics/server";
if (!success) {
await track("rate_limit_hit", {
limiter: "trpc",
userId: ctx.session.user.id,
});
throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
}Recommended configuration for a B2B SaaS
| Endpoint | Limit | Algorithm | Key |
|---|---|---|---|
/api/auth/signin | 5 / 15min | sliding window | IP |
/api/auth/register | 3 / 1h | sliding window | IP |
/api/auth/forgot-password | 3 / 1h | sliding window | |
| tRPC protected | 100 / 1min | sliding window | userId+path |
| tRPC AI | 30 / 1h | sliding window | orgId |
| Public webhooks | 100 burst, 50/10s | token bucket | IP |
| Public API (API key) | 1000 / 1min | sliding window | apiKey |
Typical Upstash cost
For a SaaS with 1,000 active users:
- ~500 req/sec at peak
- ~50M req/month across the rate limiters
Upstash charges $0.2/100k commands โ roughly $100/month. If that sounds steep, the "pay-as-you-go" plan gives you 10k free requests/day, then $0.2/100k after.
For most SaaS products, $5-20/month is plenty.
Mistakes to avoid
- Bypassing rate limiting locally out of laziness: test your limits in dev, that's where you catch the UI that spams the API in a loop.
- One single global key: an attacker saturating one endpoint saturates all of them. Use different
prefixvalues per endpoint. - No
Retry-Afterheader: your frontend has no idea when to retry. Always returnresetin the response. - Rate limiting only API routes: Next.js Server Actions are exposed on predictable URLs too. Rate-limit sensitive Actions (payment, sending email) exactly like API routes.
- Hardcoding the Redis key in code: use
env.jsand T3 Env to validate the variables are present at boot.
Conclusion
10 minutes to wire up Upstash, 30 minutes to configure the 5-7 limiters your SaaS actually needs, and you're protected against 90% of common abuse. The cost is negligible, and the UX impact is zero: real users never hit these thresholds.
HeartCo Starter ships with Upstash pre-wired, in the form of a single flexible checkRateLimit(key, max, windowMs) helper (in src/lib/rate-limit.ts) that caches its Ratelimit instances and fails closed in production if Redis isn't configured, rather than a fixed set of named limiters. You get every pattern above by calling it with the key, count, and window that fits your route, without having to maintain five separate exports.
Go further
Related articles
Testing Multi-Tenant Isolation in a SaaS
Twelve test families pulled from real code: IDOR, webhooks, rate limiting, sessions. What they prove, and what they don't.
ReadMulti-Tenant Isolation with Prisma and tRPC: What the Automatic Filter Covers, and What Doesn't
Isolate each organization's data in Next.js with Prisma $extends and tRPC: what the automatic filter covers, and what stays explicit.
ReadMulti-Tenant Authentication with NextAuth v5
Implement robust authentication with roles, granular permissions, and multi-tenant isolation for your SaaS, using NextAuth v5.
ReadReady to launch your SaaS?
HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.