INTERMEDIATENEXTJSMIDDLEWARE

Rate-Limit a Next.js Route Handler with Upstash

A typed rate-limiter for Next.js route handlers using Upstash Redis. Sliding window, per-IP, with proper 429 responses and headers.

By Tested on Next.js 16, @upstash/ratelimit 2.x, @upstash/redis 1.35+
Published Jun 3, 2026

A drop-in rate limiter for Next.js route handlers using Upstash Redis and the official @upstash/ratelimit package. Identifies callers by IP, applies a sliding-window limit, and returns proper 429 Too Many Requests responses with the standard rate-limit headers. This is the pattern I use for sign-up endpoints and contact forms.

Tested on Next.js 16, @upstash/ratelimit 2.x, @upstash/redis 1.35+.

When to Use This

  • Protecting sign-up, password reset, or contact endpoints from abuse
  • Limiting expensive AI/LLM API calls per user
  • Rate-limiting webhook receivers from chatty third parties
  • Throttling search endpoints to control downstream cost

Don't use this when you need application-level quotas (per-user, per-plan — that's a billing concern, not rate limiting) or when you need request shaping (use a proper API gateway).

Code

// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
 
// Create the limiter once at module scope so it's reused across requests
export const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
  analytics: true,
  prefix: 'ratelimit:contact',
});
// app/api/contact/route.ts
import { NextResponse } from 'next/server';
import { ratelimit } from '@/lib/rate-limit';
 
export async function POST(request: Request) {
  // Get the caller's IP from the platform header
  const ip =
    request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
    request.headers.get('x-real-ip') ??
    '127.0.0.1';
 
  const { success, limit, remaining, reset } = await ratelimit.limit(ip);
 
  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests. Please try again later.' },
      {
        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)),
        },
      }
    );
  }
 
  // ... handle the actual request ...
  return NextResponse.json({ ok: true });
}

The ratelimit instance is module-scoped so it's created once per cold start, not per request. The sliding window is more accurate than a fixed window — it doesn't let users burst at the start of every minute.

Usage

The Upstash Redis credentials are auto-provisioned when you install the Upstash integration on Vercel. They show up as UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN in your environment, and Redis.fromEnv() reads them automatically.

Local development needs the same env vars in .env.local:

UPSTASH_REDIS_REST_URL="https://...upstash.io"
UPSTASH_REDIS_REST_TOKEN="AX..."

For multiple endpoints with different limits, create multiple Ratelimit instances with different prefixes:

// lib/rate-limit.ts
export const contactLimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 h'), // 5/hour
  prefix: 'rl:contact',
});
 
export const signupLimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(3, '1 h'), // 3/hour
  prefix: 'rl:signup',
});

The prefix keeps the counters separate even though they share one Redis instance.

Caveats

  • x-forwarded-for can contain multiple IPs. Always take the first one (split(',')[0]) — that's the original client. The others are intermediate proxies.
  • Trust the platform header, not the client. A direct request.ip accessor doesn't exist for Vercel-deployed Next.js. x-forwarded-for is what Vercel injects.
  • Don't use the user's email as the rate-limit key for unauthenticated endpoints. Anyone can submit any email, which lets attackers lock out other users. Always rate-limit by IP for unauthenticated traffic.
  • The Retry-After header is in seconds, not milliseconds. Convert from reset - Date.now() carefully.
  • Module-scoped Ratelimit instance is intentional. Don't new Ratelimit() inside the handler — that creates a new client per request and slows everything down.
  • Set analytics: true only if you'll use it. It costs extra Redis writes and shows up in your Upstash dashboard.

Frequently Asked Questions

Why use Upstash Redis for rate limiting instead of an in-memory store?

Serverless functions on Vercel run on different instances per request. An in-memory map only protects one instance and resets on every cold start. Upstash gives you a shared, persistent counter across every invocation, with millisecond latency and a generous free tier.

Should rate limiting live in proxy.ts or the route handler?

Both, depending on the threat model. Coarse limiting (e.g., 100 req/min per IP across the whole API) belongs in proxy.ts. Per-endpoint limiting (e.g., 5 sign-up attempts per hour) belongs in the route handler where you have more context about the user and action.

X (Twitter)LinkedIn