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/ratelimitlibrary — Built specifically for this use case.
Setup
# Install the required packages
pnpm add @upstash/ratelimit @upstash/redisCreate 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-hereCreating 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
| Algorithm | How It Works | Best For |
|---|---|---|
| Fixed Window | Resets count every N seconds | Simple use cases |
| Sliding Window | Counts requests in a rolling time window | Authentication, forms |
| Token Bucket | Fills tokens at a rate; bursts allowed | APIs with burst tolerance |
| Leaky Bucket | Processes at a fixed rate; excess queued | Smoothing 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.