Skip to content
Codeloom
Next.js

Rate Limiting API Routes in Next.js

Implement rate limiting for Next.js API routes and route handlers using in-memory stores, Redis, and middleware patterns to protect your endpoints.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Why rate limiting matters for API routes
  • Implementing a sliding window rate limiter from scratch
  • Using Redis for distributed rate limiting across serverless instances
  • Applying rate limits via Next.js middleware
  • Returning proper rate limit headers and error responses

Prerequisites

  • Familiarity with [Next.js route handlers](/blog/nextjs-route-handlers-vs-api-routes)
  • Basic understanding of [Next.js middleware](/blog/nextjs-middleware-basics)

Every public API endpoint needs rate limiting. Without it, a single user or bot can overwhelm your server, burn through third-party API quotas, or scrape your data. Next.js does not include rate limiting out of the box, but implementing it is straightforward with either an in-memory store for single-server deployments or Redis for serverless and multi-instance setups.

A simple in-memory rate limiter

For development and single-server deployments, an in-memory Map works well:

// lib/rate-limit.ts
type RateLimitEntry = {
  count: number;
  resetTime: number;
};

const store = new Map<string, RateLimitEntry>();

export function rateLimit(options: {
  windowMs: number;
  maxRequests: number;
}) {
  const { windowMs, maxRequests } = options;

  return function check(identifier: string): {
    success: boolean;
    remaining: number;
    resetTime: number;
  } {
    const now = Date.now();
    const entry = store.get(identifier);

    if (!entry || now > entry.resetTime) {
      // First request or window expired
      const resetTime = now + windowMs;
      store.set(identifier, { count: 1, resetTime });
      return { success: true, remaining: maxRequests - 1, resetTime };
    }

    if (entry.count >= maxRequests) {
      return { success: false, remaining: 0, resetTime: entry.resetTime };
    }

    entry.count += 1;
    return {
      success: true,
      remaining: maxRequests - entry.count,
      resetTime: entry.resetTime,
    };
  };
}

Use it in a route handler:

// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';

const limiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  maxRequests: 30,
});

export async function GET(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
  const { success, remaining, resetTime } = limiter(ip);

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

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

The problem with in-memory stores

In-memory rate limiting has a critical limitation: serverless functions do not share memory. If your Next.js app runs on Vercel, each invocation might be a different instance. The rate limiter resets every time a new instance spins up, making it ineffective.

For production serverless deployments, use an external store like Redis.

Redis-based rate limiting

Using @upstash/ratelimit with Upstash Redis (designed for serverless):

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

export const rateLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(30, '1 m'),
  analytics: true,
  prefix: 'api-ratelimit',
});
// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { rateLimiter } from '@/lib/rate-limit';

export async function GET(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
  const { success, limit, remaining, reset } = await rateLimiter.limit(ip);

  const headers = {
    'X-RateLimit-Limit': String(limit),
    'X-RateLimit-Remaining': String(remaining),
    'X-RateLimit-Reset': String(reset),
  };

  if (!success) {
    return NextResponse.json(
      { error: 'Rate limit exceeded' },
      { status: 429, headers: { ...headers, 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) } }
    );
  }

  return NextResponse.json({ data: 'Your data here' }, { headers });
}

Set the environment variables:

UPSTASH_REDIS_REST_URL=your-redis-url
UPSTASH_REDIS_REST_TOKEN=your-redis-token

Sliding window vs. fixed window vs. token bucket

Upstash Ratelimit supports multiple algorithms:

// Fixed window: simple, but allows burst at window boundaries
Ratelimit.fixedWindow(30, '1 m');

// Sliding window: smooth, prevents boundary bursts
Ratelimit.slidingWindow(30, '1 m');

// Token bucket: allows controlled bursts
Ratelimit.tokenBucket(10, '1 m', 30); // 10 tokens/min, max 30

Fixed window resets the counter at fixed intervals. A user can make 30 requests at 11:59:59 and 30 more at 12:00:01, effectively 60 requests in two seconds.

Sliding window spreads the count across a rolling window, preventing boundary bursts. This is the best default choice.

Token bucket allows short bursts up to the bucket size while enforcing a steady refill rate. Good for APIs where occasional bursts are acceptable.

Rate limiting in middleware

Apply rate limiting globally via middleware instead of per-route:

// 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(60, '1 m'),
});

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') ?? '127.0.0.1';
  const { success, limit, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': String(limit),
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': String(reset),
          'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
        },
      }
    );
  }

  const response = NextResponse.next();
  response.headers.set('X-RateLimit-Limit', String(limit));
  response.headers.set('X-RateLimit-Remaining', String(remaining));
  response.headers.set('X-RateLimit-Reset', String(reset));

  return response;
}

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

Per-user rate limiting with authentication

Rate limit by user ID instead of IP for authenticated endpoints:

export async function GET(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;
  let identifier: string;

  if (token) {
    const payload = await verifyToken(token);
    identifier = payload?.sub ?? request.headers.get('x-forwarded-for') ?? 'anon';
  } else {
    identifier = request.headers.get('x-forwarded-for') ?? 'anon';
  }

  const { success, remaining, reset } = await rateLimiter.limit(identifier);

  if (!success) {
    return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
  }

  return NextResponse.json({ data: 'protected data' });
}

Tiered rate limits

Different limits for different user tiers:

// lib/rate-limit.ts
const limiters = {
  free: new Ratelimit({
    redis: Redis.fromEnv(),
    limiter: Ratelimit.slidingWindow(10, '1 m'),
    prefix: 'ratelimit:free',
  }),
  pro: new Ratelimit({
    redis: Redis.fromEnv(),
    limiter: Ratelimit.slidingWindow(100, '1 m'),
    prefix: 'ratelimit:pro',
  }),
  enterprise: new Ratelimit({
    redis: Redis.fromEnv(),
    limiter: Ratelimit.slidingWindow(1000, '1 m'),
    prefix: 'ratelimit:enterprise',
  }),
};

export function getLimiter(tier: 'free' | 'pro' | 'enterprise') {
  return limiters[tier];
}

Cleaning up the in-memory store

If you use the in-memory approach, prevent memory leaks by periodically purging expired entries:

// Run cleanup every 5 minutes
setInterval(() => {
  const now = Date.now();
  for (const [key, entry] of store.entries()) {
    if (now > entry.resetTime) {
      store.delete(key);
    }
  }
}, 5 * 60 * 1000);

Common mistakes

Trusting x-forwarded-for blindly. This header can be spoofed. If you are behind a trusted proxy (like Vercel or Cloudflare), use the platform-specific header instead. On Vercel, use request.ip.

Not returning rate limit headers. Clients need Retry-After, X-RateLimit-Remaining, and X-RateLimit-Reset to handle rate limiting gracefully without hammering your server.

Using the same limit for all endpoints. Login endpoints should have stricter limits than read endpoints. Apply different limiters to different routes.

Forgetting to rate limit server actions. Server actions are endpoints too. Apply rate limiting inside them using the same pattern.

Rate limiting is a fundamental production concern. Start with the in-memory approach during development, then switch to Redis-backed limiting before deploying. The Upstash Ratelimit library makes the transition trivial and handles the algorithm complexity for you.