Rate Limiting

Rate limiting is provided as a built-in plugin to protect your API from abuse and ensure fair usage. It registers as pre-auth middleware on the auto-api pipeline: every generated route and every createEndpoint route. Plain Nitro handlers (defineEventHandler) are not covered.

Rate Limiting

Rate limiting is provided as a built-in plugin to protect your API from abuse and ensure fair usage. It registers as pre-auth middleware on the auto-api pipeline: every generated route and every createEndpoint route. Plain Nitro handlers (defineEventHandler) are not covered.

Installation

The rate limiting plugin is included with @websideproject/nuxt-auto-api. Enable it via the plugins array:

// nuxt.config.ts
import { createRateLimitPlugin } from '@websideproject/nuxt-auto-api/plugins'

export default defineNuxtConfig({
  autoApi: {
    plugins: [
      createRateLimitPlugin({
        windowMs: 60000,  // 1 minute
        max: 100,         // 100 requests per minute
        byIp: true,
        byUser: false
      })
    ]
  }
})

No additional Nitro plugin or manual middleware setup is needed. The plugin automatically registers pre-auth middleware that checks rate limits before authorization runs.

Migration note: The legacy extensions: [...] field is no longer read. Replace createRateLimitExtension with createRateLimitPlugin in plugins: [...] — the options are the same.

Configuration Options

interface RateLimitPluginOptions {
  // Window size in milliseconds (default: 60000 = 1 minute)
  windowMs?: number

  // Maximum requests per window (default: 100)
  max?: number

  // Rate limit by the client's IP address (default: true) — see "Which address" below
  byIp?: boolean

  // Behind your own reverse proxy: how many proxies append to X-Forwarded-For (true = 1). Default: false
  trustProxy?: boolean | number

  // Rate limit by user ID (default: false)
  byUser?: boolean

  // Custom key generator (receives the HandlerContext)
  keyGenerator?: (ctx: any) => string

  // Skip rate limiting for certain requests (receives the HandlerContext)
  skip?: (ctx: any) => boolean

  // Custom error message
  message?: string

  // Counter-based backing store (default: InMemoryRateLimitStore). Gives full headers.
  store?: RateLimitStore

  // Decision-based limiter (e.g. Cloudflare RateLimit binding). Takes precedence over `store`.
  // Pass the binding directly, or a resolver that reads it from the request context.
  limiter?: RateLimitLimiter | ((ctx: any) => RateLimitLimiter | undefined)

  // On store/limiter error, allow the request (fail open). `false` answers 429 instead. Default: true.
  failOpen?: boolean
}

// Counter store — full X-RateLimit-* headers
interface RateLimitStore {
  increment(key: string, windowMs: number): Promise<{ count: number, resetAt: number }>
  reset?(key: string): Promise<void>
}

// Decision limiter — shape matches the Cloudflare Workers RateLimit binding exactly
interface RateLimitLimiter {
  limit(args: { key: string }): Promise<{ success: boolean }>
}

Which address

byIp keys on an address a client cannot choose:

Where the app runsAddress used
Cloudflare Workers / Pages (Nitro's Cloudflare presets)CF-Connecting-IP — Cloudflare sets it and overwrites any value the client sent
Behind your own proxy, with trustProxy: nthe X-Forwarded-For entry n hops from the right — the one your outermost proxy saw
Anything elsethe connection's address

X-Forwarded-For is never read without trustProxy: with no proxy in front, a client puts a new value in it on every request and gets a fresh bucket each time. clientAddress(event, trustProxy) (from @websideproject/nuxt-auto-api/plugins) is the same resolution, for your own keyGenerator.

The keyGenerator and skip callbacks receive the HandlerContext (with ctx.user, ctx.requestMeta, ctx.resource, ctx.event), not a raw H3 event. The middleware runs pre-auth — after the auth/billing context extenders — so plan and user data are already available.

The default in-memory store keeps counters in process memory. On Cloudflare Workers each request may hit a different isolate with its own memory, so a global "100/min" limit becomes "100 × N isolates" — effectively unenforced. Use one of the two distributed options.

Option A — native RateLimit binding (best)

Cloudflare has a first-class RateLimit API: configure the limit in wrangler.toml, and the binding handles distributed counting for you (no Durable Object). The limiter option's shape matches the binding exactly, so you pass it straight through.

# wrangler.toml
[[unsafe.bindings]]
name = "RATE_LIMITER"
type = "ratelimit"
namespace_id = "1"
simple = { limit = 100, period = 60 }
createRateLimitPlugin({
  byUser: true,                                  // bucket key per user
  max: 100, windowMs: 60_000,                    // advisory — used for headers only
  limiter: (ctx) => ctx.event.context.cloudflare?.env?.RATE_LIMITER,
})

The binding returns only { success }, so on this path the plugin sets X-RateLimit-Limit and (on 429) Retry-After from windowMs; precise Remaining/Reset are only available on the counter-store path.

Plan-aware limits (billing integration)

Because the middleware runs pre-auth — after the auth and billing context extenders — ctx.requestMeta.billing and ctx.user are already populated. Bind one limiter per tier and select by plan:

# wrangler.toml
[[ratelimits]]
name = "FREE_USER_RATE_LIMITER"
namespace_id = "1001"
simple = { limit = 100, period = 60 }

[[ratelimits]]
name = "PAID_USER_RATE_LIMITER"
namespace_id = "1002"
simple = { limit = 1000, period = 60 }
createRateLimitPlugin({
  byUser: true,
  limiter: (ctx) => {
    const env = ctx.event.context.cloudflare?.env
    const b = ctx.requestMeta?.billing                // set by the billing-context plugin
    const isPaid = b?.isActive || b?.isTrialing
    return isPaid ? env?.PAID_USER_RATE_LIMITER : env?.FREE_USER_RATE_LIMITER
  },
})

The actual numbers live in wrangler.toml, so changing a tier's limit is a Worker config change. If you'd rather drive limits from your app/plan config without redeploying Workers, use the counter-store (KV) path below, where max/windowMs come from your code.

Option B — Cloudflare KV counter store

A counter store over KV gives cross-isolate counting and full X-RateLimit-* headers (KV is eventually consistent, ~60s):

class KvRateLimitStore implements RateLimitStore {
  constructor(private kv: KVNamespace) {}
  async increment(key: string, windowMs: number) {
    const now = Date.now()
    const raw = await this.kv.get(key, 'json') as { count: number, resetAt: number } | null
    if (!raw || now > raw.resetAt) {
      const fresh = { count: 1, resetAt: now + windowMs }
      await this.kv.put(key, JSON.stringify(fresh), { expirationTtl: Math.ceil(windowMs / 1000) })
      return fresh
    }
    raw.count++
    await this.kv.put(key, JSON.stringify(raw), { expirationTtl: Math.ceil((raw.resetAt - now) / 1000) })
    return raw
  }
  async reset(key: string) { await this.kv.delete(key) }
}

createRateLimitPlugin({
  windowMs: 60_000, max: 100, byUser: true,
  store: new KvRateLimitStore(env.RATE_LIMIT_KV),
})

(A store is an instance: register this one from a server file, not inline in nuxt.config — see below.)


Examples

Which address

byIp keys on an address a client cannot choose:

Where the app runsAddress used
Cloudflare Workers / Pages (Nitro's Cloudflare presets)CF-Connecting-IP — Cloudflare sets it and overwrites any value the client sent
Behind your own proxy, with trustProxy: nthe X-Forwarded-For entry n hops from the right — the one your outermost proxy saw
Anything elsethe connection's address

X-Forwarded-For is never read without trustProxy: with no proxy in front, a client puts a new value in it on every request and gets a fresh bucket each time. clientAddress(event, trustProxy) (from @websideproject/nuxt-auto-api/plugins) is the same resolution, for your own keyGenerator.

The keyGenerator and skip callbacks receive the HandlerContext (ctx.user, ctx.resource, ctx.requestMeta, ctx.event), since the plugin runs as pre-auth middleware — not a raw H3 event.

Stricter limit for specific resources

createRateLimitPlugin applies one window globally; to vary by resource, branch inside skip/keyGenerator, or run two plugins and skip the inverse set. Example — a tight limit only on exports/auth, normal elsewhere:

autoApi: {
  plugins: [
    // Strict: only the sensitive resources, low max
    createRateLimitPlugin({
      windowMs: 60_000, max: 5, byIp: true,
      skip: (ctx) => !['exports', 'authTokens'].includes(ctx.resource),
    }),
    // Normal: everything else
    createRateLimitPlugin({
      windowMs: 60_000, max: 100, byIp: true,
      skip: (ctx) => ['exports', 'authTokens'].includes(ctx.resource),
    }),
  ],
}

For genuinely different numeric limits per route on Cloudflare, bind one RateLimit binding per route/tier and select it in limiter (see Cloudflare Workers) — the limits live in wrangler.toml.

Rate Limit by User

createRateLimitPlugin({
  windowMs: 60000,
  max: 100,
  byIp: false,
  byUser: true  // Rate limit per authenticated user (keys by user:id)
})

Combined IP and User

createRateLimitPlugin({
  windowMs: 60000,
  max: 100,
  byIp: true,
  byUser: true  // Key combines both — same user from a new IP gets a fresh bucket
})

Custom Key Generator

createRateLimitPlugin({
  windowMs: 60000,
  max: 100,
  keyGenerator: (ctx) => {
    // Rate limit by org so a whole workspace shares one bucket
    return `org:${ctx.user?.organizationId ?? 'anon'}`
  }
})

Skip Certain Requests

createRateLimitPlugin({
  windowMs: 60000,
  max: 100,
  skip: (ctx) => {
    // Don't rate limit global admins or internal/cron calls
    return !!ctx.user?.roles?.includes('admin')
  }
})

Custom Error Message

createRateLimitPlugin({
  windowMs: 60000,
  max: 100,
  message: 'You have exceeded the request limit. Please try again in a minute.'
})

Response Headers

Rate limit information is included in response headers:

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1704067200000

When limit is exceeded:

HTTP/1.1 429 Too Many Requests
Retry-After: 42
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1704067200000

{
  "statusCode": 429,
  "statusMessage": "Too Many Requests",
  "message": "Too many requests, please try again later"
}

Retry-After (seconds) is always set on 429. X-RateLimit-Remaining/Reset are precise on the counter-store path; on the decision-limiter path (CF binding) only Limit + Retry-After are set.

Storage Backends

In-Memory (Default)

Suitable for single-server deployments:

createRateLimitPlugin({
  // Uses in-memory store by default
})

Pros:

  • Fast
  • No external dependencies
  • Automatic cleanup

Cons:

  • Not shared across multiple servers
  • Lost on server restart
  • Limited to server memory

Cloudflare (Workers)

See Cloudflare Workers above — the native RateLimit binding (limiter) or a KV-backed counter store.

Redis (multi-server, non-Workers)

Implement the RateLimitStore interface — increment(key, windowMs) must return { count, resetAt }:

// server/utils/redis-rate-limit-store.ts
import { Redis } from 'ioredis'
import type { RateLimitStore } from '@websideproject/nuxt-auto-api/plugins'

const redis = new Redis(process.env.REDIS_URL)

export class RedisRateLimitStore implements RateLimitStore {
  async increment(key: string, windowMs: number) {
    const count = await redis.incr(key)
    if (count === 1) {
      await redis.pexpire(key, windowMs)            // set window TTL on first hit
    }
    const pttl = await redis.pttl(key)
    return { count, resetAt: Date.now() + Math.max(0, pttl) }
  }

  async reset(key: string) {
    await redis.del(key)
  }
}

A store is an instance, so register this plugin from a server file — an instance listed in nuxt.config cannot reach the server and fails the build (see Plugin Registration):

// nuxt.config.ts
export default defineNuxtConfig({
  autoApi: { plugins: '~/server/autoapi-plugins' },
})
// server/autoapi-plugins.ts
import { createRateLimitPlugin } from '@websideproject/nuxt-auto-api/plugins'
import { RedisRateLimitStore } from './utils/redis-rate-limit-store'

export default [
  createRateLimitPlugin({ windowMs: 60_000, max: 100, store: new RedisRateLimitStore() }),
]

Pros:

  • Shared across multiple servers
  • Persists across restarts
  • Full X-RateLimit-* headers

Cons:

  • Requires Redis server (not available on Cloudflare Workers — use KV/binding there)

Advanced Patterns

createRateLimitPlugin registers pre-auth middleware with a single window. To vary behavior, register multiple plugins (each runs in turn) and use skip/keyGenerator/limiter — all of which receive the full HandlerContext.

Different Limits for Different Plans

The plugin's max is fixed per registration, so vary it by registering one plugin per tier and skipping the others. ctx.requestMeta.billing is populated (the billing-context plugin runs before this middleware):

const isPaid = (ctx: any) => ctx.requestMeta?.billing?.isActive || ctx.requestMeta?.billing?.isTrialing

autoApi: {
  plugins: [
    createRateLimitPlugin({                       // paid: 1000/min
      windowMs: 60_000, max: 1000, byUser: true,
      skip: (ctx) => !isPaid(ctx),
    }),
    createRateLimitPlugin({                       // free/anon: 100/min
      windowMs: 60_000, max: 100, byUser: true,
      skip: (ctx) => isPaid(ctx),
    }),
  ],
}

On Cloudflare, prefer one RateLimit binding per tier selected in limiter (limits in wrangler.toml) — see Cloudflare Workers. That avoids running two middlewares.

Burst + Sustained

Register two plugins with different windows and namespaced keys — both must pass:

autoApi: {
  plugins: [
    createRateLimitPlugin({ windowMs: 10_000, max: 20,  byIp: true, keyGenerator: (ctx) => `burst:${ctx.user?.id ?? clientAddress(ctx.event)}` }),
    createRateLimitPlugin({ windowMs: 60_000, max: 100, byIp: true, keyGenerator: (ctx) => `sustained:${ctx.user?.id ?? clientAddress(ctx.event)}` }),
  ],
}
// import { clientAddress } from '@websideproject/nuxt-auto-api/plugins' — the plugin's own address resolution

Usage-based / progressive limits

Bake the decision into a custom store (or limiter) — it owns counting, so it can read a per-key allowance. E.g. a store whose increment looks up the user's tier allowance and reports count/resetAt accordingly, or a limiter that calls different CF bindings by recent usage. Keep the dynamic logic in the backend, not in the plugin options.

Monitoring

The plugin throws a 429 when over limit. Observe hits without re-implementing counting by wrapping your store/limiter, or via a response-status hook.

Wrap the store/limiter (plugin-native)

class ObservedLimiter implements RateLimitLimiter {
  constructor(private inner: RateLimitLimiter, private onBlock: (key: string) => void) {}
  async limit(args: { key: string }) {
    const r = await this.inner.limit(args)
    if (!r.success) this.onBlock(args.key)
    return r
  }
}

createRateLimitPlugin({
  limiter: (ctx) => new ObservedLimiter(
    ctx.event.context.cloudflare.env.RATE_LIMITER,
    (key) => console.warn('rate-limit blocked', { key, resource: ctx.resource, userId: ctx.user?.id }),
  ),
})

Response-status hook (counts 429s)

// server/plugins/rate-limit-metrics.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('afterResponse', (event) => {
    if (getResponseStatus(event) === 429) {
      // increment a counter / emit an event — e.g. Prometheus, Umami, or the events module
      console.warn('rate-limit 429', { path: event.path, userId: event.context.user?.id })
    }
  })
})

Testing

Disable in Development

createRateLimitPlugin({
  skip: () => process.env.NODE_ENV === 'development', // `skip` receives the HandlerContext
})

Test Rate Limiting

Through the real routes, with a fixture app that lists the plugin (max: 100) — see Testing:

// test/rate-limiting.test.ts
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
import { setup, fetch } from '@nuxt/test-utils/e2e'

describe('rate limiting', async () => {
  await setup({ rootDir: fileURLToPath(new URL('./fixtures/api', import.meta.url)) })

  it('answers 429 after the limit', async () => {
    for (let i = 0; i < 100; i++) await fetch('/api/posts') // sequential: each one is counted in order
    const res = await fetch('/api/posts')
    expect(res.status).toBe(429)
    expect(res.headers.get('Retry-After')).toBeTruthy()
  })
})

Client-Side Handling

Respect Rate Limits

// app/utils/fetchWithRateLimit.ts
export async function fetchWithRateLimit<T>(url: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
  try {
    return await $fetch<T>(url, options)
  }
  catch (error: any) {
    if (error?.statusCode !== 429) throw error
    // The server sets `Retry-After` (seconds) on every 429
    const retryAfter = Number(error.response?.headers.get('Retry-After')) || 60
    console.warn(`Rate limited. Retrying in ${retryAfter} seconds...`)
    await new Promise(resolve => setTimeout(resolve, retryAfter * 1000))
    return await $fetch<T>(url, options)
  }
}

Display Rate Limit Info

$fetch.raw returns the response with its headers:

<script setup lang="ts">
const rateLimitInfo = ref<{ limit: string | null, remaining: string | null, reset: Date } | null>(null)

onMounted(async () => {
  const response = await $fetch.raw('/api/users')
  rateLimitInfo.value = {
    limit: response.headers.get('X-RateLimit-Limit'),
    remaining: response.headers.get('X-RateLimit-Remaining'),
    reset: new Date(Number(response.headers.get('X-RateLimit-Reset'))),
  }
})
</script>

<template>
  <div v-if="rateLimitInfo">
    {{ rateLimitInfo.remaining }} / {{ rateLimitInfo.limit }} requests remaining
    (resets at {{ rateLimitInfo.reset.toLocaleTimeString() }})
  </div>
</template>

X-RateLimit-Remaining / -Reset are only sent on the counter-store path (see above).

Best Practices

  1. Start conservative: Begin with lower limits and increase based on usage
  2. Monitor closely: Track 429 errors to find the right limits
  3. Use a shared backend in production: in-memory leaks across isolates/servers — Cloudflare RateLimit binding or KV on Workers, Redis elsewhere
  4. Differentiate users: paid tiers get higher limits (select by ctx.requestMeta.billing)
  5. Protect expensive endpoints: Lower limits for resource-intensive operations
  6. Clear error messages: Help users understand what happened
  7. Provide headers: Include X-RateLimit-* headers
  8. Test thoroughly: Ensure rate limiting doesn't break normal usage
  9. Document limits: Tell users what the limits are
  10. Consider quotas: Combine with daily/monthly quotas for paid tiers

Troubleshooting

Rate Limit Not Working

Check that:

  1. createRateLimitPlugin(...) is in autoApi.plugins in nuxt.config.ts
  2. Your skip function isn't excluding all requests
  3. The shared store/limiter (KV namespace / CF binding / Redis) is configured and reachable
  4. On Cloudflare with the default in-memory store, limits won't hold across isolates — use the binding or a KV store

Too Many False Positives

Adjust limits:

createRateLimitPlugin({
  windowMs: 60000,
  max: 200  // Increase limit
})

Or use per-user limits instead of per-IP:

createRateLimitPlugin({
  byIp: false,
  byUser: true
})

Memory Issues

Switch to a shared store:

  • In-memory store grows with traffic and is per-isolate on Workers
  • Cloudflare KV / RateLimit binding (Workers) or Redis (servers) provide bounded, shared state

Alternatives

  • Cloudflare: Rate limiting at the edge
  • nginx: Rate limiting at reverse proxy
  • API Gateway: AWS API Gateway, Kong, etc.
  • Custom middleware: Full control but more work

Migration Path

From custom rate limiting:

// Before: Custom implementation
const requests = new Map()

export default defineEventHandler(async (event) => {
  const ip = getRequestIP(event)
  const count = requests.get(ip) || 0

  if (count > 100) {
    throw createError({ statusCode: 429, message: 'Too many requests' })
  }

  requests.set(ip, count + 1)
  // ... rest of handler
})

// After: Use the plugin
createRateLimitPlugin({
  max: 100,
  byIp: true
})

Benefits:

  • Automatic window management
  • Response headers
  • Multiple storage backends
  • Better error messages
  • Configuration options

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.