security2023-08-30·11 min·342/348

Rate Limiting for Next.js API Routes

How to implement rate limiting in Next.js API Routes and Route Handlers to protect against abuse

Rate Limiting for Next.js API Routes

Introduction

Rate limiting is a fundamental security measure for any API. Without it, a single malicious actor can exhaust your server resources, increase your hosting costs, or perform denial-of-service attacks. Next.js API Routes and Route Handlers do not include rate limiting out of the box.

After a bot crawled my API endpoints and generated a $2,000 hosting bill in a single night, I implemented a comprehensive rate limiting strategy. This post covers three approaches from simple to production-ready.

Environment

  • OS: Windows 11
  • Node.js: v20.10.0
  • Next.js: 14.0.4
  • Rate Limiter: Upstash Redis 1.25.0

Problem

Without rate limiting, the API was vulnerable:

# Bot making 1000 requests per second
for i in {1..10000}; do
  curl -s https://api.example.com/data &
done

# Server response time degraded from 50ms to 5000ms
# Memory usage spiked from 200MB to 2GB
# Vercel function timeout errors appeared

The API route had no protection:

// app/api/data/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  const data = await expensiveDatabaseQuery()
  return NextResponse.json(data)
}

Solution

Approach 1: Simple in-memory rate limiter

// lib/rate-limit.ts
interface RateLimitEntry {
  count: number
  resetTime: number
}

const rateLimitMap = new Map()

interface RateLimitConfig {
  windowMs: number    // Time window in milliseconds
  maxRequests: number  // Max requests per window
}

export function rateLimit(
  key: string,
  config: RateLimitConfig
): { success: boolean; remaining: number; resetTime: number } {
  const { windowMs, maxRequests } = config
  const now = Date.now()

  const entry = rateLimitMap.get(key)

  if (!entry || now > entry.resetTime) {
    // New window
    rateLimitMap.set(key, {
      count: 1,
      resetTime: now + windowMs,
    })
    return { success: true, remaining: maxRequests - 1, resetTime: now + windowMs }
  }

  if (entry.count >= maxRequests) {
    // Rate limit exceeded
    return { success: false, remaining: 0, resetTime: entry.resetTime }
  }

  // Increment counter
  entry.count++
  return { success: true, remaining: maxRequests - entry.count, resetTime: entry.resetTime }
}

// Clean up expired entries periodically
setInterval(() => {
  const now = Date.now()
  for (const [key, entry] of rateLimitMap) {
    if (now > entry.resetTime) {
      rateLimitMap.delete(key)
    }
  }
}, 60000) // Clean up every minute
// app/api/data/route.ts
import { NextResponse } from 'next/server'
import { rateLimit } from '@/lib/rate-limit'

export async function GET(request: Request) {
  const ip = request.headers.get('x-forwarded-for') || 'anonymous'

  const { success, remaining, resetTime } = rateLimit(ip, {
    windowMs: 60 * 1000, // 1 minute
    maxRequests: 60,      // 60 requests per minute
  })

  if (!success) {
    return NextResponse.json(
      { error: 'Rate limit exceeded' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': '60',
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': String(Math.ceil(resetTime / 1000)),
          'Retry-After': String(Math.ceil((resetTime - Date.now()) / 1000)),
        },
      }
    )
  }

  const data = await expensiveDatabaseQuery()

  return NextResponse.json(data, {
    headers: {
      'X-RateLimit-Limit': '60',
      'X-RateLimit-Remaining': String(remaining),
      'X-RateLimit-Reset': String(Math.ceil(resetTime / 1000)),
    },
  })
}

Approach 2: Upstash Redis rate limiter (production)

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

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})

// Sliding window rate limiter
export const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(60, '60 s'), // 60 requests per 60 seconds
  analytics: true,
  prefix: 'upstash_ratelimit',
})

// Different limits for different endpoints
export const apiLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '60 s'),
  analytics: true,
  prefix: 'api_limit',
})

export const strictLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '60 s'), // 5 requests per minute
  analytics: true,
  prefix: 'strict_limit',
})
// app/api/auth/login/route.ts
import { NextResponse } from 'next/server'
import { strictLimiter } from '@/lib/rate-limit-redis'

export async function POST(request: Request) {
  const ip = request.headers.get('x-forwarded-for') || 'anonymous'

  const { success, remaining, reset } = await strictLimiter.limit(ip)

  if (!success) {
    return NextResponse.json(
      { error: 'Too many login attempts' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Remaining': String(remaining),
          'X-RateLimit-Reset': String(reset),
        },
      }
    )
  }

  // Process login...
  return NextResponse.json({ success: true })
}

Approach 3: Middleware-based rate limiting

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

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '60 s'),
})

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

  const ip = request.headers.get('x-forwarded-for') || 'anonymous'
  const { success, remaining, reset } = await ratelimit.limit(ip)

  if (!success) {
    return NextResponse.json(
      { error: 'Rate limit exceeded' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': '100',
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': String(Math.ceil(reset / 1000)),
        },
      }
    )
  }

  const response = NextResponse.next()
  response.headers.set('X-RateLimit-Remaining', String(remaining))
  return response
}

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

Lessons Learned

  1. In-memory rate limiting is for development only. It does not work across multiple server instances and loses state on restart.

  2. Use Upstash Redis for production. It provides distributed rate limiting with minimal setup and works perfectly with Vercel.

  3. Rate limit by IP and API key separately. Authenticated users should have higher limits than anonymous users.

  4. Always return rate limit headers. Clients can use X-RateLimit-Remaining to implement client-side throttling.

  5. Combine multiple strategies. Use middleware for global rate limiting and route-level limiting for sensitive endpoints like authentication.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.