Rate Limiting
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 that runs automatically on every API request.
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: If you were using the legacy
createRateLimitExtensionwithextensions: [...], switch tocreateRateLimitPluginwithplugins: [...]. The configuration options are the same.
Configuration Options
interface RateLimitConfig {
// Window size in milliseconds (default: 60000 = 1 minute)
windowMs?: number
// Maximum requests per window (default: 100)
max?: number
// Rate limit by IP address (default: true)
byIp?: boolean
// Rate limit by user ID (default: false)
byUser?: boolean
// Custom key generator
keyGenerator?: (event: any) => string
// Skip rate limiting for certain requests
skip?: (event: any) => boolean
// Custom error message
message?: string
// Custom storage backend
store?: RateLimitStore
}
Examples
Different Limits by Endpoint
// server/plugins/rate-limit.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', async (event) => {
if (event.path.startsWith('/api/auth/')) {
// Stricter limit for auth endpoints
await checkRateLimitWithConfig(event, {
windowMs: 60000,
max: 5 // Only 5 attempts per minute
})
} else if (event.path.startsWith('/api/')) {
// Normal limit for other endpoints
await checkRateLimit(event)
}
})
})
Rate Limit by User
createRateLimitExtension({
windowMs: 60000,
max: 100,
byIp: false,
byUser: true // Rate limit per authenticated user
})
Combined IP and User
createRateLimitExtension({
windowMs: 60000,
max: 100,
byIp: true,
byUser: true // Both IP and user must be within limits
})
Custom Key Generator
createRateLimitExtension({
windowMs: 60000,
max: 100,
keyGenerator: (event) => {
// Rate limit by API key
const apiKey = event.node.req.headers['x-api-key']
return `apiKey:${apiKey}`
}
})
Skip Certain Requests
createRateLimitExtension({
windowMs: 60000,
max: 100,
skip: (event) => {
// Don't rate limit admins
return event.context.user?.role === 'admin'
}
})
Custom Error Message
createRateLimitExtension({
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
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",
"data": {
"limit": 100,
"windowMs": 60000,
"retryAfter": 60
}
}
Storage Backends
In-Memory (Default)
Suitable for single-server deployments:
createRateLimitExtension({
// 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
Redis (Recommended for Production)
For multi-server deployments:
// server/utils/redis-rate-limit-store.ts
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
export class RedisRateLimitStore {
async increment(key: string): Promise<number> {
const count = await redis.incr(key)
// Set TTL on first increment
if (count === 1) {
await redis.expire(key, 60) // 60 seconds
}
return count
}
async reset(key: string): Promise<void> {
await redis.del(key)
}
async get(key: string): Promise<number> {
const count = await redis.get(key)
return count ? parseInt(count, 10) : 0
}
}
// nuxt.config.ts
import { createRateLimitExtension } from '@websideproject/nuxt-auto-api/extensions/rate-limiting'
import { RedisRateLimitStore } from './server/utils/redis-rate-limit-store'
export default defineNuxtConfig({
autoApi: {
extensions: [
createRateLimitExtension({
store: new RedisRateLimitStore()
})
]
}
})
Pros:
- Shared across multiple servers
- Persists across restarts
- Scalable
Cons:
- Requires Redis server
- Slightly slower than in-memory
Advanced Patterns
Different Limits for Different Users
// server/plugins/rate-limit.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', async (event) => {
if (!event.path.startsWith('/api/')) return
const user = event.context.user
let limit = 100 // Default
if (user?.plan === 'premium') {
limit = 1000
} else if (user?.plan === 'enterprise') {
limit = 10000
}
await checkRateLimitWithConfig(event, {
windowMs: 60000,
max: limit,
keyGenerator: () => `user:${user?.id || 'anonymous'}`
})
})
})
Burst vs Sustained Rate
// Allow bursts but limit sustained rate
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', async (event) => {
if (!event.path.startsWith('/api/')) return
// Short-term burst limit: 20 requests per 10 seconds
await checkRateLimitWithConfig(event, {
windowMs: 10000,
max: 20,
keyGenerator: (e) => `burst:${getClientIP(e)}`
})
// Long-term sustained limit: 100 requests per minute
await checkRateLimitWithConfig(event, {
windowMs: 60000,
max: 100,
keyGenerator: (e) => `sustained:${getClientIP(e)}`
})
})
})
Progressive Rate Limiting
// Increase limits based on usage
const getUserRateLimit = async (userId: string) => {
const usage = await db.query.usage.findFirst({
where: eq(usage.userId, userId)
})
if (!usage) return 100
// Increase limit for active users
if (usage.requestCount > 10000) {
return 500
} else if (usage.requestCount > 1000) {
return 200
}
return 100
}
Per-Endpoint Limits
const ENDPOINT_LIMITS = {
'/api/search': { windowMs: 60000, max: 10 },
'/api/export': { windowMs: 3600000, max: 5 },
'/api/upload': { windowMs: 60000, max: 20 },
default: { windowMs: 60000, max: 100 }
}
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', async (event) => {
const config = ENDPOINT_LIMITS[event.path] || ENDPOINT_LIMITS.default
await checkRateLimitWithConfig(event, config)
})
})
Monitoring
Track Rate Limit Hits
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', async (event) => {
try {
await checkRateLimit(event)
} catch (error) {
if (error.statusCode === 429) {
// Log rate limit hit
console.warn('Rate limit exceeded:', {
ip: getClientIP(event),
path: event.path,
userId: event.context.user?.id
})
// Track in analytics
await trackEvent('rate_limit_exceeded', {
ip: getClientIP(event),
path: event.path
})
}
throw error
}
})
})
Prometheus Metrics
import { Counter } from 'prom-client'
const rateLimitCounter = new Counter({
name: 'rate_limit_exceeded_total',
help: 'Total number of rate limit exceeded errors',
labelNames: ['path', 'user_type']
})
// In your rate limit check
if (error.statusCode === 429) {
rateLimitCounter.inc({
path: event.path,
user_type: event.context.user ? 'authenticated' : 'anonymous'
})
}
Testing
Disable in Development
createRateLimitExtension({
skip: (event) => {
return process.env.NODE_ENV === 'development'
}
})
Test Rate Limiting
// test/rate-limiting.test.ts
import { describe, it, expect } from 'vitest'
describe('Rate Limiting', () => {
it('should block requests after limit', async () => {
const requests = []
// Make 101 requests (limit is 100)
for (let i = 0; i < 101; i++) {
requests.push(
$fetch('/api/users').catch(e => e)
)
}
const results = await Promise.all(requests)
// Last request should be rate limited
expect(results[100].statusCode).toBe(429)
})
})
Client-Side Handling
Respect Rate Limits
// composables/useAutoApiWithRateLimit.ts
export const useAutoApiWithRateLimit = (resource: string) => {
const { fetch, ...rest } = useAutoApiFetch(resource)
const fetchWithRetry = async (options: any) => {
try {
return await fetch(options)
} catch (error) {
if (error.statusCode === 429) {
const retryAfter = error.data?.retryAfter || 60
// Show user-friendly message
console.warn(`Rate limited. Retrying in ${retryAfter} seconds...`)
// Wait and retry
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000))
return await fetch(options)
}
throw error
}
}
return { ...rest, fetch: fetchWithRetry }
}
Display Rate Limit Info
<script setup>
const rateLimitInfo = ref(null)
onMounted(() => {
// Check headers after request
const { data, response } = await useAutoApiFetch('users')
if (response.headers) {
rateLimitInfo.value = {
limit: response.headers.get('X-RateLimit-Limit'),
remaining: response.headers.get('X-RateLimit-Remaining'),
reset: new Date(parseInt(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>
Best Practices
- Start conservative: Begin with lower limits and increase based on usage
- Monitor closely: Track 429 errors to find the right limits
- Use Redis in production: In-memory store doesn't scale
- Differentiate users: Premium users get higher limits
- Protect expensive endpoints: Lower limits for resource-intensive operations
- Clear error messages: Help users understand what happened
- Provide headers: Include X-RateLimit-* headers
- Test thoroughly: Ensure rate limiting doesn't break normal usage
- Document limits: Tell users what the limits are
- Consider quotas: Combine with daily/monthly quotas for paid tiers
Troubleshooting
Rate Limit Not Working
Check that:
- Extension is registered in nuxt.config.ts
- Nitro plugin is applying checkRateLimit
- Redis is connected (if using Redis store)
- Skip function isn't excluding all requests
Too Many False Positives
Adjust limits:
createRateLimitExtension({
windowMs: 60000,
max: 200 // Increase limit
})
Or use per-user limits instead of per-IP:
createRateLimitExtension({
byIp: false,
byUser: true
})
Memory Issues
Switch to Redis:
- In-memory store grows with traffic
- Redis provides bounded memory usage
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 = getClientIP(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 extension
createRateLimitExtension({
max: 100,
byIp: true
})
Benefits:
- Automatic window management
- Response headers
- Multiple storage backends
- Better error messages
- Configuration options
Validation
@websideproject/nuxt-auto-api uses Zod for validation with automatic schema generation from Drizzle tables via drizzle-zod.
Request Metadata Plugin
The Request Metadata Plugin automatically captures request metadata (IP address, geolocation, user-agent, etc.) and makes it available throughout your API handlers. It supports both context-only access (for hooks and authorization) and optional database persistence.