Blog
Jun 12, 2026
How to Set Up Rate Limiting in Nuxt

How to Set Up Rate Limiting in Nuxt

Sadeq Sheikhi

Sadeq Sheikhi

A production-grade rate limiting setup for Nuxt — Redis-backed with in-memory fallback, named presets, and a 429 error page with a live countdown.

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, 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() — factory that builds the limiter (Redis with in-memory fallback)
  • applyRateLimit() — what you actually 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       // max requests within the window
  windowSeconds: number
}

export interface RateLimitResult {
  allowed: boolean
  limit: number
  remaining: number
  resetAt: number     // unix timestamp (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<RateLimitResult> {
    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) on limit exceeded.
      // if it's something else, we fail open — a broken limiter should never block everyone.
      const res = thrown as Record<string, unknown>
      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 init — the limiter builds itself on the first request, not at import time. Nitro doesn't guarantee process.env is fully ready when modules load, so this avoids a class of subtle bugs.
  • Fail open — if Redis throws something unexpected, the request goes through. I'd rather have a temporarily unprotected endpoint than have a limiter bug take down the whole app for every user.

3. Presets

Not all routes deserve the same treatment. A page view and a password reset are very different risks. Add named presets at the bottom of the same file:

function env(name: string, fallback: number): number {
  const v = process.env[name]
  const n = v ? parseInt(v, 10) : NaN
  return Number.isFinite(n) && n > 0 ? n : fallback
}

// 60 req / min — 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 req / 15 min — 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 req / hr — password reset, email verify
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 req / min — 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 via env vars — no code changes needed to tighten things in production.

4. The applyRateLimit() helper

Create server/utils/applyRateLimit.ts:

import type { H3Event } from 'h3'
import type { RateLimitResult } from './rateLimiter'

type LimiterFn = (key: string) => Promise<RateLimitResult>

export function getClientIp(event: H3Event): string {
  return (
    getRequestHeader(event, 'cf-connecting-ip') ||           // Cloudflare
    getRequestHeader(event, 'x-real-ip') ||                  // nginx
    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: LimiterFn,
  keyFn?: (event: H3Event) => string,
): Promise<void> {
  const bypassSecret = process.env.NUXT_RATE_LIMITER_BYPASS_SECRET
  if (bypassSecret && getRequestHeader(event, 'x-rate-limit-bypass') === bypassSecret) {
    return
  }

  const key = keyFn ? keyFn(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 if you're behind a proxy (Cloudflare, nginx, etc.) — event.node.req.socket.remoteAddress will just be your proxy's IP, not the actual client. We check headers in priority order and fall back to 'unknown'.

The keyFn param lets you key on something other than IP when you need it. More on that below.

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((p) => path.startsWith(p))) return

  if (process.env.NUXT_RATE_LIMITER_ENABLED === 'false') return

  if (path.startsWith('/api/')) {
    await applyRateLimit(event, apiRateLimiter)
  } else if (event.method === 'GET' || event.method === 'HEAD') {
    await applyRateLimit(event, pageRateLimiter)
  }
})

Every route gets a baseline limit without touching individual handlers. Static assets and Nuxt internals are skipped. Page limiting is GET/HEAD only — there's no reason to rate limit mutations at this layer.

6. Layering limits on sensitive routes

The global middleware is your floor. For sensitive endpoints, you stack a second tighter limit on top. Both limits count down independently — a request has to pass both.

// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
  await applyRateLimit(event, authRateLimiter) // 10 req / 15 min, on top of global 60/min
  // ...
})

For password reset and similar endpoints, I key on email instead of IP. An attacker can rotate IPs, but the target email stays 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)
  // ...
})

7. The 429 page

In app/error.vue, pull retryAfter out of the error data and show a countdown that auto-reloads when it hits zero:

<script setup lang="ts">
const props = defineProps<{ error: { statusCode: number; data?: { retryAfter?: number } } }>()

const countdown = ref(props.error.data?.retryAfter ?? 0)

if (props.error.statusCode === 429 && countdown.value > 0) {
  const interval = setInterval(() => {
    countdown.value--
    if (countdown.value <= 0) {
      clearInterval(interval)
      window.location.reload()
    }
  }, 1000)
  onUnmounted(() => clearInterval(interval))
}
</script>

<template>
  <div v-if="error.statusCode === 429">
    <h1>Too many requests</h1>
    <p v-if="countdown > 0">Retrying in {{ countdown }}s…</p>
    <button @click="$router.go(0)">Retry now</button>
  </div>
</template>

Nothing the user needs to do — when the window resets, the page reloads itself.

8. Env vars

# Set to "false" to disable globally — handy in local dev
NUXT_RATE_LIMITER_ENABLED=true

# Internal services send this as x-rate-limit-bypass to skip limits
NUXT_RATE_LIMITER_BYPASS_SECRET=

# Redis (leave empty to fall back to in-memory)
NUXT_REDIS_HOST=
NUXT_REDIS_PORT=6379
NUXT_REDIS_PASSWORD=

# Per-preset overrides
NUXT_RATE_LIMITER_API_LIMIT=60
NUXT_RATE_LIMITER_API_WINDOW=60
NUXT_RATE_LIMITER_AUTH_LIMIT=10
NUXT_RATE_LIMITER_AUTH_WINDOW=900
NUXT_RATE_LIMITER_SENSITIVE_LIMIT=5
NUXT_RATE_LIMITER_SENSITIVE_WINDOW=3600
NUXT_RATE_LIMITER_PAGE_LIMIT=200
NUXT_RATE_LIMITER_PAGE_WINDOW=60

Summary

FileResponsibility
server/utils/rateLimiter.tsFactory + named presets
server/utils/applyRateLimit.tsPer-request enforcement, IP extraction, headers
server/middleware/rateLimiter.tsGlobal baseline on every route
app/error.vue429 UX with countdown + auto-reload

The defaults here are conservative — tune them to your actual traffic. The in-memory fallback means you can ship this without Redis and upgrade later without changing any application code.

Sadeq Sheikhi
Sadeq Sheikhi

Hi, Im a seniour software engineer building web platforms. I write about Nuxt, Typescript, DevOps, AI and engineering decisions behind real products, based on my real experience.

بیشتر بخوانید

Why I Chose Coolify to Self-Host Everything

A cost-conscious developer's case for self-hosting — and why Coolify is the tool that makes it sustainable for a single person.

Setting Up Histoire in a Nuxt Monorepo + Bugs That I Encountered

A complete guide to getting Histoire working inside a Nuxt + pnpm monorepo with i18n, Nuxt UI, and Iconify — including four crashes you will hit and exactly how to fix them.