Skip to main content
All articles
7 min read

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:

  1. Brute force on login: an attacker tries 10,000 passwords in 30 seconds
  2. Per-user API abuse: a customer loop-tests your freemium limit
  3. 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 prod

Monitoring

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" });
}
EndpointLimitAlgorithmKey
/api/auth/signin5 / 15minsliding windowIP
/api/auth/register3 / 1hsliding windowIP
/api/auth/forgot-password3 / 1hsliding windowemail
tRPC protected100 / 1minsliding windowuserId+path
tRPC AI30 / 1hsliding windoworgId
Public webhooks100 burst, 50/10stoken bucketIP
Public API (API key)1000 / 1minsliding windowapiKey

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

  1. 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.
  2. One single global key: an attacker saturating one endpoint saturates all of them. Use different prefix values per endpoint.
  3. No Retry-After header: your frontend has no idea when to retry. Always return reset in the response.
  4. 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.
  5. Hardcoding the Redis key in code: use env.js and 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

Share

Ready to launch your SaaS?

HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.

30-day money-back guarantee
Rate Limiting in Next.js with Upstash Redis | HeartCo