Skip to main content

Rate Limiting in Next.js with Upstash Redis: Protecting Your API Routes

June 2, 2026

Every public-facing API route in a Next.js application is a potential target for abuse. Without rate limiting, a single bad actor can flood your authentication endpoint with millions of login attempts, exhaust your database connection pool, generate enormous cloud compute bills, or degrade service for every legitimate user.

Rate limiting is not optional for production applications. This post shows you how to implement production-grade sliding window rate limiting using Upstash Redis — a serverless Redis-compatible service that works perfectly with Next.js Edge Runtime and Vercel deployment.


Why Upstash for Rate Limiting?

Traditional Redis requires a persistent server connection. Vercel's edge functions and serverless environments establish new connections on every request, making traditional Redis impractical.

Upstash provides:

  • Serverless Redis — HTTP-based, no persistent connections needed.
  • Global replication — Low latency rate limit checks from any edge region.
  • Per-request pricing — No idle costs.
  • Native @upstash/ratelimit library — Built specifically for this use case.

Setup

# Install the required packages
pnpm add @upstash/ratelimit @upstash/redis

Create an Upstash Redis database at console.upstash.com and add the credentials to your environment:

# .env.local
UPSTASH_REDIS_REST_URL=https://your-database.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token-here

Creating the Rate Limiter

// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = Redis.fromEnv();

// Different limiters for different endpoint types
export const rateLimiters = {
  // Authentication: strict — 5 attempts per minute per IP
  auth: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(5, '1 m'),
    analytics: true,
    prefix: 'rl:auth',
  }),

  // General API: moderate — 60 requests per minute per user
  api: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(60, '1 m'),
    analytics: true,
    prefix: 'rl:api',
  }),

  // Contact/form submissions: very strict — 3 per hour per IP
  forms: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(3, '1 h'),
    analytics: true,
    prefix: 'rl:forms',
  }),

  // Password reset: 3 per 15 minutes per email
  passwordReset: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(3, '15 m'),
    analytics: true,
    prefix: 'rl:password-reset',
  }),
};

// Helper to get a consistent identifier for rate limiting
export function getRateLimitKey(request: Request, userId?: string): string {
  if (userId) return `user:${userId}`;

  // Use IP address for unauthenticated requests
  const forwarded = request.headers.get('x-forwarded-for');
  const ip = forwarded ? forwarded.split(',')[0].trim() : 'anonymous';
  return `ip:${ip}`;
}

Applying Rate Limits to API Routes

Authentication Endpoint

// app/api/auth/login/route.ts
import { NextResponse } from 'next/server';
import { rateLimiters, getRateLimitKey } from '@/lib/rate-limit';

export async function POST(request: Request) {
  // Rate limit by IP before processing anything
  const key = getRateLimitKey(request);
  const { success, limit, remaining, reset } = await rateLimiters.auth.limit(key);

  if (!success) {
    return NextResponse.json(
      {
        error: 'Too many login attempts. Please try again later.',
        retryAfter: Math.ceil((reset - Date.now()) / 1000),
      },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': reset.toString(),
          'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
        },
      }
    );
  }

  // Proceed with authentication logic
  const { email, password } = await request.json();
  // ... validate credentials ...

  return NextResponse.json(
    { success: true },
    {
      headers: {
        'X-RateLimit-Limit': limit.toString(),
        'X-RateLimit-Remaining': remaining.toString(),
        'X-RateLimit-Reset': reset.toString(),
      },
    }
  );
}

Reusable Rate Limit Middleware

For applying rate limits across multiple routes, create a middleware helper:

// lib/with-rate-limit.ts
import { NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { getRateLimitKey } from './rate-limit';

type Handler = (request: Request, ...args: unknown[]) => Promise<Response>;

export function withRateLimit(limiter: Ratelimit, handler: Handler): Handler {
  return async (request: Request, ...args: unknown[]) => {
    const key = getRateLimitKey(request);
    const { success, limit, remaining, reset } = await limiter.limit(key);

    if (!success) {
      return NextResponse.json(
        { error: 'Rate limit exceeded. Please slow down.' },
        {
          status: 429,
          headers: {
            'X-RateLimit-Limit': limit.toString(),
            'X-RateLimit-Remaining': '0',
            'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
          },
        }
      );
    }

    const response = await handler(request, ...args);

    // Add rate limit headers to every response
    const headers = new Headers(response.headers);
    headers.set('X-RateLimit-Limit', limit.toString());
    headers.set('X-RateLimit-Remaining', remaining.toString());
    headers.set('X-RateLimit-Reset', reset.toString());

    return new Response(response.body, { status: response.status, headers });
  };
}

// Usage:
export const POST = withRateLimit(
  rateLimiters.auth,
  async (request) => {
    // Your handler logic here
    return NextResponse.json({ success: true });
  }
);

Next.js Middleware for Global Rate Limiting

Apply rate limiting to all API routes using Next.js middleware:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = Redis.fromEnv();

const globalLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(100, '1 m'),
  prefix: 'rl:global',
});

export async function middleware(request: NextRequest) {
  // Only apply to API routes
  if (!request.nextUrl.pathname.startsWith('/api/')) {
    return NextResponse.next();
  }

  const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? 'anonymous';
  const { success } = await globalLimiter.limit(ip);

  if (!success) {
    return new NextResponse('Rate limit exceeded', {
      status: 429,
      headers: { 'Retry-After': '60' },
    });
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

Testing Rate Limit Behavior

// tests/rate-limiting.spec.ts
import { test, expect } from '@playwright/test';

test('login endpoint rejects after 5 failed attempts', async ({ request }) => {
  const endpoint = '/api/auth/login';
  const badCredentials = { email: 'test@example.com', password: 'wrong' };

  // Make 5 failed attempts (the limit)
  for (let i = 0; i < 5; i++) {
    await request.post(endpoint, { data: badCredentials });
  }

  // The 6th attempt should be rate-limited
  const response = await request.post(endpoint, { data: badCredentials });

  expect(response.status()).toBe(429);
  expect(response.headers()['retry-after']).toBeDefined();

  const body = await response.json();
  expect(body.error).toContain('Too many login attempts');
});

Rate Limit Algorithms Compared

AlgorithmHow It WorksBest For
Fixed WindowResets count every N secondsSimple use cases
Sliding WindowCounts requests in a rolling time windowAuthentication, forms
Token BucketFills tokens at a rate; bursts allowedAPIs with burst tolerance
Leaky BucketProcesses at a fixed rate; excess queuedSmoothing traffic

Upstash supports all four. Sliding window is the right default for security-sensitive endpoints because it prevents burst attacks at the boundary between fixed windows.


Conclusion

Rate limiting is a 30-minute implementation that protects against an entire class of attacks — brute force authentication, form spam, and denial-of-service abuse. Upstash Redis makes this trivial in serverless Next.js deployments because it requires no persistent connections and scales automatically. Apply the auth limiter to every authentication endpoint, use the global middleware for broad API protection, and return proper Retry-After headers so legitimate clients can backoff gracefully.

Recommended Posts