DEV Community

Cover image for How to Set Up Rate Limiting in Nuxt
Sadeq Sheikhi
Sadeq Sheikhi

Posted on • Originally published at sadeq.dev

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
Enter fullscreen mode Exit fullscreen mode

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<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, 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<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,
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  ),
})
Enter fullscreen mode Exit fullscreen mode

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<RateLimitResult>

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<void> {
  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,
      },
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
})
Enter fullscreen mode Exit fullscreen mode

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...
})
Enter fullscreen mode Exit fullscreen mode

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<{
    email: string
  }>(event)

  await applyRateLimit(
    event,
    sensitiveRateLimiter,
    () => body.email.trim().toLowerCase(),
  )

  // Continue with the password-reset flow...
})
Enter fullscreen mode Exit fullscreen mode

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}`,
)
Enter fullscreen mode Exit fullscreen mode

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:

<script setup lang="ts">
interface NuxtError {
  statusCode: number
  statusMessage?: string
  data?: {
    retryAfter?: number
  }
}

const props = defineProps<{
  error: NuxtError
}>()

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

let interval:
  | ReturnType<typeof setInterval>
  | undefined

if (
  props.error.statusCode === 429 &&
  countdown.value > 0
) {
  interval = setInterval(() => {
    countdown.value -= 1

    if (countdown.value <= 0) {
      if (interval) {
        clearInterval(interval)
      }

      window.location.reload()
    }
  }, 1000)
}

onUnmounted(() => {
  if (interval) {
    clearInterval(interval)
  }
})

function retry(): void {
  window.location.reload()
}
</script>

<template>
  <main v-if="error.statusCode === 429">
    <h1>Too many requests</h1>

    <p v-if="countdown > 0">
      Retrying in {{ countdown }} seconds…
    </p>

    <button type="button" @click="retry">
      Retry now
    </button>
  </main>
</template>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)