How to Set Up Rate Limiting in Nuxt
Rate limiting is one of those things that doesn't feel urgent—until someone hammers your login endpoint at 3am and you wake up to a flooded database and a locked-out user base. I added this to my Nuxt base layer after realising I'd shipped several projects with zero protection on auth routes. Not great. This post walks through the exact setup I now use: Redis-backed, an in-memory fallback when Redis is down, named presets for different sensitivity levels, and a 429 page that shows a live countdown instead of just dying on the user. The structure Three pieces, each with one job: createRateLimiter() — a factory that builds the limiter, using Redis with an in-memory fallback applyRateLimit() — what you call inside handlers to enforce a limit server/middleware/rateLimiter.ts — global middleware so every route gets a baseline for free 1. Install npm install rate-limiter-flexible ioredis rate-limiter-flexible does the heavy lifting: sliding windows, Redis integration, and the insurance fallback pattern we'll use. 2. The factory Create server/utils/rateLimiter.ts: import { RateLimiterRedis, RateLimiterMemory, type RateLimiterAbstract, } from 'rate-limiter-flexible' import { getRedisClient } from './redis' export interface RateLimiterConfig { keyPrefix: string // Must be unique per limiter, e.g. 'rl:auth' limit: number // Maximum requests within the window windowSeconds: number } export interface RateLimitResult { allowed: boolean limit: number remaining: number resetAt: number // Unix timestamp in seconds when the window resets retryAfter: number // Seconds until retry; 0 if allowed } function buildLimiter( config: RateLimiterConfig, ): RateLimiterAbstract { const insurance = new RateLimiterMemory({ keyPrefix: config.keyPrefix, points: config.limit, duration: config.windowSeconds, }) const redis = getRedisClient() if (!redis) { return insurance } return new RateLimiterRedis({ storeClient: redis, keyPrefix: config.keyPrefix, points: config.limit, duration: config.windowSeconds, insuranceLimiter: insurance, // Falls back to memory if Redis goes down }) } export function createRateLimiter( config: RateLimiterConfig, ) { let limiter: RateLimiterAbstract | null = null function getLimiter(): RateLimiterAbstract { if (!limiter) { limiter = buildLimiter(config) } return limiter } return async function check( key: string, ): Promise { try { const res = await getLimiter().consume(key) return { allowed: true, limit: config.limit, remaining: res.remainingPoints ?? 0, resetAt: Math.ceil(Date.now() / 1000) + Math.ceil((res.msBeforeNext ?? 0) / 1000), retryAfter: 0, } } catch (thrown: unknown) { // rate-limiter-flexible throws a RateLimiterRes object, // not an Error, when the limit is exceeded. // // If it throws something else, fail open. A broken limiter // should not block every user. const res = thrown as Record if (typeof res?.msBeforeNext !== 'number') { console.error( '[rate-limiter] unexpected error:', thrown, ) return { allowed: true, limit: config.limit, remaining: 0, resetAt: 0, retryAfter: 0, } } const retryAfter = Math.ceil( res.msBeforeNext / 1000, ) return { allowed: false, limit: config.limit, remaining: 0, resetAt: Math.ceil(Date.now() / 1000) + retryAfter, retryAfter, } } } } Two things I want to highlight here: Lazy initialization: the limiter builds itself on the first request, not at import time. This avoids initialization-order problems in environments where configuration or services may not be ready when modules are first loaded. Fail open: when Redis throws something unexpected, the request goes through. I would rather have a temporarily unprotected endpoint than have a limiter bug take down the whole application for every user. For an especially sensitive system, you may decide to fail closed instead. 3. Presets Not all routes deserve the same treatment. A page view and a password-reset request are very different risks. Add named presets at the bottom of the same file: function env( name: string, fallback: number, ): number { const value = process.env[name] const parsed = value ? Number.parseInt(value, 10) : Number.NaN return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback } // 60 requests per minute — general API traffic export const apiRateLimiter = createRateLimiter({ keyPrefix: 'rl:api', limit: env('NUXT_RATE_LIMITER_API_LIMIT', 60), windowSeconds: env( 'NUXT_RATE_LIMITER_API_WINDOW', 60, ), }) // 10 requests per 15 minutes — login, register, OTP export const authRateLimiter = createRateLimiter({ keyPrefix: 'rl:auth', limit: env('NUXT_RATE_LIMITER_AUTH_LIMIT', 10), windowSeconds: env( 'NUXT_RATE_LIMITER_AUTH_WINDOW', 15 * 60, ), }) // 5 requests per hour — password reset, email verification export const sensitiveRateLimiter = createRateLimiter({ keyPrefix: 'rl:sensitive', limit: env( 'NUXT_RATE_LIMITER_SENSITIVE_LIMIT', 5, ), windowSeconds: env( 'NUXT_RATE_LIMITER_SENSITIVE_WINDOW', 60 * 60, ), }) // 200 requests per minute — SSR page routes export const pageRateLimiter = createRateLimiter({ keyPrefix: 'rl:page', limit: env('NUXT_RATE_LIMITER_PAGE_LIMIT', 200), windowSeconds: env( 'NUXT_RATE_LIMITER_PAGE_WINDOW', 60, ), }) All limits are overridable through environment variables. You do not need to change the application code to tighten them in production. 4. The applyRateLimit() helper Create server/utils/applyRateLimit.ts: import type { H3Event } from 'h3' import type { RateLimitResult } from './rateLimiter' type LimiterFunction = ( key: string, ) => Promise export function getClientIp( event: H3Event, ): string { return ( getRequestHeader(event, 'cf-connecting-ip') || getRequestHeader(event, 'x-real-ip') || getRequestHeader(event, 'x-forwarded-for') ?.split(',')[0] ?.trim() || 'unknown' ) } function setRateLimitHeaders( event: H3Event, result: RateLimitResult, ): void { setResponseHeader( event, 'X-RateLimit-Limit', String(result.limit), ) setResponseHeader( event, 'X-RateLimit-Remaining', String(result.remaining), ) setResponseHeader( event, 'X-RateLimit-Reset', String(result.resetAt), ) if (!result.allowed && result.retryAfter > 0) { setResponseHeader( event, 'Retry-After', String(result.retryAfter), ) } } export async function applyRateLimit( event: H3Event, limiter: LimiterFunction, keyFunction?: (event: H3Event) => string, ): Promise { const bypassSecret = process.env.NUXT_RATE_LIMITER_BYPASS_SECRET const suppliedSecret = getRequestHeader( event, 'x-rate-limit-bypass', ) if ( bypassSecret && suppliedSecret === bypassSecret ) { return } const key = keyFunction ? keyFunction(event) : getClientIp(event) const result = await limiter(key) setRateLimitHeaders(event, result) if (!result.allowed) { throw createError({ statusCode: 429, data: { code: 'RATE_LIMITED', retryAfter: result.retryAfter, }, }) } } The IP-resolution order matters when your Nuxt application is behind Cloudflare, nginx, or another reverse proxy. event.node.req.socket.remoteAddress may contain only your proxy's IP, not the actual client's address. The helper checks common forwarding headers in priority order and falls back to 'unknown'. Only trust these headers when requests can reach your application through infrastructure you control. Otherwise, clients may be able to spoof them. The keyFunction parameter lets you limit requests using something other than an IP address when necessary. 5. Global middleware Create server/middleware/rateLimiter.ts: import { apiRateLimiter, pageRateLimiter, } from '../utils/rateLimiter' import { applyRateLimit } from '../utils/applyRateLimit' const SKIP_PREFIXES = [ '/_nuxt', '/__nuxt', '/api/_admin', '/_admin', '/img/', '/fonts/', '/js/', '/favicon', '/_ipx', '/robots.txt', '/sitemap', '/og-image', ] export default defineEventHandler(async event => { const path = getRequestURL(event).pathname if ( SKIP_PREFIXES.some(prefix => path.startsWith(prefix), ) ) { return } if ( process.env.NUXT_RATE_LIMITER_ENABLED === 'false' ) { return } if (path.startsWith('/api/')) { await applyRateLimit(event, apiRateLimiter) return } if ( event.method === 'GET' || event.method === 'HEAD' ) { await applyRateLimit(event, pageRateLimiter) } }) Every route now gets a baseline limit without touching its individual handler. Static assets and Nuxt internals are skipped. Page limiting applies only to GET and HEAD requests. 6. Layering limits on sensitive routes The global middleware is your floor. For sensitive endpoints, stack a second, tighter limit on top. Both limits count down independently, so a request has to pass both. // server/api/auth/login.post.ts export default defineEventHandler(async event => { await applyRateLimit( event, authRateLimiter, ) // Continue with authentication... }) For password resets and similar endpoints, I key the limiter by email rather than IP. An attacker can rotate IP addresses, but the target email remains the same: // server/api/auth/reset-password.post.ts export default defineEventHandler(async event => { const body = await readBody(event) await applyRateLimit( event, sensitiveRateLimiter, () => body.email.trim().toLowerCase(), ) // Continue with the password-reset flow... }) You can also combine the email address and IP: const email = body.email.trim().toLowerCase() const ip = getClientIp(event) await applyRateLimit( event, sensitiveRateLimiter, () => `${email}:${ip}`, ) The correct key depends on what you are protecting. 7. The 429 page Inside app/error.vue, pull retryAfter from the error data and show a countdown that reloads the page when it reaches zero: interface NuxtError { statusCode: number statusMessage?: string data?: { retryAfter?: number } } const props = defineProps() const countdown = ref( props.error.data?.retryAfter ?? 0, ) let interval: | ReturnType | undefined if ( props.error.statusCode === 429 && countdown.value > 0 ) { interval = setInterval(() => { countdown.value -= 1 if (countdown.value { if (interval) { clearInterval(interval) } }) function retry(): void { window.location.reload() } Too many requests Retrying in {{ countdown }} seconds… Retry now There is nothing else the user needs to do. When the request window resets, the page reloads automatically. 8. Environment variables # Set to "false" to disable rate limiting globally NUXT_RATE_LIMITER_ENABLED=true # Internal services can send this value through # the x-rate-limit-bypass header NUXT_RATE_LIMITER_BYPASS_SECRET= # Redis configuration # Leave the host empty to use the in-memory limiter NUXT_REDIS_HOST= NUXT_REDIS_PORT=6379 NUXT_REDIS_PASSWORD= # General API preset NUXT_RATE_LIMITER_API_LIMIT=60 NUXT_RATE_LIMITER_API_WINDOW=60 # Authentication preset NUXT_RATE_LIMITER_AUTH_LIMIT=10 NUXT_RATE_LIMITER_AUTH_WINDOW=900 # Sensitive-action preset NUXT_RATE_LIMITER_SENSITIVE_LIMIT=5 NUXT_RATE_LIMITER_SENSITIVE_WINDOW=3600 # Rendered-page preset NUXT_RATE_LIMITER_PAGE_LIMIT=200 NUXT_RATE_LIMITER_PAGE_WINDOW=60 Summary File Responsibility server/utils/rateLimiter.ts Factory and named presets server/utils/applyRateLimit.ts Per-request enforcement, IP extraction, and headers server/middleware/rateLimiter.ts Global baseline protection app/error.vue 429 experience with countdown and automatic reload The defaults here are conservative. Tune them according to your actual traffic. The in-memory fallback means you can ship this before adding Redis and upgrade later without changing the API used throughout your application. That is why I keep this implementation in my reusable Nuxt base layer: every new project starts with basic protection already available, instead of waiting until the first abusive request arrives. I originally published this tutorial on my personal blog, where I write about Nuxt, TypeScript, infrastructure, and engineering decisions taken from real projects: Read the original Nuxt rate-limiting article
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to