Real-World Patterns
TL;DR
Production Nuxt apps need patterns beyond the core framework: i18n for multilingual support, feature flags for safe rollouts, multi-tenancy for serving multiple clients, WebSocket for real-time communication, and operational concerns like rate limiting and scheduled tasks. This chapter covers the standard approach for each — use these instead of reinventing the wheel.
Mental Model
These patterns are cross-cutting concerns that layer on top of Nuxt’s core features:
i18n → Module + middleware + composable
Feature flags → runtimeConfig + middleware + server evaluation
Multi-tenancy → Server middleware + dynamic config + data isolation
WebSocket → Nitro handler + client composable
Rate limiting → Server middleware + storage
Scheduled jobs → Nitro tasks + cron
Each pattern uses existing Nuxt primitives in a specific way. No magic — just coordinated usage of middleware, server routes, config, and state.
i18n with @nuxtjs/i18n
The standard solution for internationalization:
npx nuxi module add i18n
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [
{ code: 'en', name: 'English', file: 'en.json' },
{ code: 'id', name: 'Bahasa Indonesia', file: 'id.json' },
],
defaultLocale: 'en',
lazy: true, // Load locale files on demand
langDir: 'locales/', // Directory for locale files
strategy: 'prefix_except_default', // /about (en), /id/about (id)
},
})
// locales/en.json
{
"welcome": "Welcome to {name}",
"nav": {
"home": "Home",
"about": "About",
"contact": "Contact"
},
"auth": {
"login": "Sign In",
"logout": "Sign Out"
}
}
// locales/id.json
{
"welcome": "Selamat datang di {name}",
"nav": {
"home": "Beranda",
"about": "Tentang",
"contact": "Kontak"
},
"auth": {
"login": "Masuk",
"logout": "Keluar"
}
}
Use in components:
<script setup lang="ts">
const { t, locale, setLocale } = useI18n()
</script>
<template>
<h1>{{ t('welcome', { name: 'NuxtLearn' }) }}</h1>
<nav>
<NuxtLink :to="localePath('/')">{{ t('nav.home') }}</NuxtLink>
<NuxtLink :to="localePath('/about')">{{ t('nav.about') }}</NuxtLink>
</nav>
<!-- Language switcher -->
<select @change="setLocale(($event.target as HTMLSelectElement).value)">
<option value="en" :selected="locale === 'en'">English</option>
<option value="id" :selected="locale === 'id'">Bahasa Indonesia</option>
</select>
</template>
Route strategies:
prefix_except_default—/about(default lang),/id/about(other langs). Best for SEO.prefix—/en/about,/id/about. All languages prefixed. Most explicit.no_prefix—/aboutfor all languages. Language determined by cookie/header. Simplest URLs but worst for SEO.
SEO with i18n — automatic hreflang tags:
<script setup>
const { locales } = useI18n()
const route = useRoute()
useHead({
link: locales.value.map(locale => ({
rel: 'alternate',
hreflang: locale.code,
href: `https://mysite.com${localePath(route.path, locale.code)}`,
})),
})
</script>
Feature Flags
Simple approach: runtimeConfig-based flags
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
features: {
newDashboard: false,
betaSearch: false,
darkMode: true,
},
},
},
})
# Enable in production via env var
NUXT_PUBLIC_FEATURES_NEW_DASHBOARD=true
<script setup lang="ts">
const { features } = useRuntimeConfig().public
</script>
<template>
<NewDashboard v-if="features.newDashboard" />
<OldDashboard v-else />
</template>
Route-level feature gating with middleware:
// middleware/feature-gate.ts
export default defineNuxtRouteMiddleware((to) => {
const { features } = useRuntimeConfig().public
// Gate specific routes behind feature flags
if (to.path.startsWith('/beta') && !features.betaSearch) {
return abortNavigation(createError({
statusCode: 404,
statusMessage: 'Not Found',
}))
}
})
Advanced: Server-evaluated flags (per-user)
For percentage rollouts, A/B testing, or user-targeted flags:
// server/api/features.get.ts
export default defineEventHandler(async (event) => {
const user = event.context.user // From auth middleware
// Evaluate flags per-user (from a service or database)
const flags = await evaluateFlags(user?.id, {
newDashboard: { percentage: 20 }, // 20% of users
betaSearch: { allowList: ['admin'] }, // Only admins
darkMode: { enabled: true }, // Everyone
})
return flags
})
<script setup lang="ts">
// Client fetches personalized flags
const { data: features } = await useFetch('/api/features')
</script>
Multi-Tenancy
Subdomain-based tenancy:
// server/middleware/tenant.ts
export default defineEventHandler((event) => {
const host = getRequestHost(event)
// Extract tenant from subdomain: tenant.myapp.com
const subdomain = host.split('.')[0]
if (subdomain === 'www' || subdomain === 'myapp') {
event.context.tenant = 'default'
} else {
event.context.tenant = subdomain
}
})
// server/api/data.get.ts
export default defineEventHandler(async (event) => {
const tenant = event.context.tenant
// Query scoped to tenant
const data = await db.items.findMany({
where: { tenantId: tenant },
})
return data
})
Path-based tenancy (simpler, works without DNS config):
// server/middleware/tenant.ts
export default defineEventHandler((event) => {
const url = getRequestURL(event).pathname
const match = url.match(/^\/t\/([^/]+)/)
event.context.tenant = match?.[1] || 'default'
})
Per-tenant theming:
<script setup lang="ts">
// composables/useTenant.ts
export function useTenant() {
const tenant = useState<string>('tenant', () => 'default')
const theme = computed(() => {
const themes: Record<string, object> = {
'acme': { primary: '#ff6600', logo: '/tenants/acme/logo.png' },
'globex': { primary: '#0066ff', logo: '/tenants/globex/logo.png' },
'default': { primary: '#333333', logo: '/logo.png' },
}
return themes[tenant.value] || themes.default
})
return { tenant, theme }
}
</script>
WebSocket with Nitro
Nitro supports WebSocket via the crossws library:
// server/routes/_ws.ts
import type { Peer } from 'crossws'
const peers = new Set<Peer>()
export default defineWebSocketHandler({
open(peer) {
peers.add(peer)
peer.send(JSON.stringify({ type: 'connected', id: peer.id }))
console.log(`[ws] ${peer.id} connected (${peers.size} total)`)
},
message(peer, message) {
const data = JSON.parse(message.text())
// Broadcast to all other peers
for (const p of peers) {
if (p !== peer) {
p.send(JSON.stringify({ type: 'message', from: peer.id, data }))
}
}
},
close(peer) {
peers.delete(peer)
console.log(`[ws] ${peer.id} disconnected (${peers.size} total)`)
},
error(peer, error) {
console.error(`[ws] ${peer.id} error:`, error)
peers.delete(peer)
},
})
Client-side composable:
// composables/useWebSocket.ts
export function useWebSocket(path = '/_ws') {
const messages = ref<any[]>([])
const connected = ref(false)
let ws: WebSocket | null = null
function connect() {
if (import.meta.server) return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${protocol}//${window.location.host}${path}`)
ws.onopen = () => {
connected.value = true
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
messages.value.push(data)
}
ws.onclose = () => {
connected.value = false
// Auto-reconnect after 3 seconds
setTimeout(connect, 3000)
}
}
function send(data: any) {
ws?.send(JSON.stringify(data))
}
function disconnect() {
ws?.close()
}
onMounted(connect)
onUnmounted(disconnect)
return { messages, connected, send, disconnect }
}
<script setup>
const { messages, connected, send } = useWebSocket()
function sendMessage(text: string) {
send({ type: 'chat', text })
}
</script>
Rate Limiting
Server middleware for API rate limiting:
// server/middleware/rate-limit.ts
const limits = new Map<string, { count: number; resetAt: number }>()
const WINDOW_MS = 60_000 // 1 minute window
const MAX_REQUESTS = 60 // 60 requests per window
export default defineEventHandler((event) => {
const url = getRequestURL(event).pathname
if (!url.startsWith('/api')) return // Only limit API routes
const ip = getRequestIP(event) || 'unknown'
const key = `${ip}:${url}`
const now = Date.now()
let record = limits.get(key)
if (!record || now > record.resetAt) {
record = { count: 0, resetAt: now + WINDOW_MS }
limits.set(key)
}
record.count++
// Set rate limit headers
setHeader(event, 'X-RateLimit-Limit', MAX_REQUESTS.toString())
setHeader(event, 'X-RateLimit-Remaining', Math.max(0, MAX_REQUESTS - record.count).toString())
setHeader(event, 'X-RateLimit-Reset', Math.ceil(record.resetAt / 1000).toString())
if (record.count > MAX_REQUESTS) {
throw createError({
statusCode: 429,
statusMessage: 'Too Many Requests',
message: `Rate limit exceeded. Try again in ${Math.ceil((record.resetAt - now) / 1000)}s`,
})
}
})
Production: Use Redis for multi-instance deployments:
// server/middleware/rate-limit.ts
export default defineEventHandler(async (event) => {
const url = getRequestURL(event).pathname
if (!url.startsWith('/api')) return
const ip = getRequestIP(event) || 'unknown'
const key = `rate-limit:${ip}`
const storage = useStorage('redis')
const current = await storage.getItem<number>(key) || 0
if (current >= MAX_REQUESTS) {
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
}
await storage.setItem(key, current + 1, { ttl: 60 })
})
Background Tasks (Nitro Tasks)
// server/tasks/cleanup.ts
export default defineTask({
meta: {
name: 'cleanup',
description: 'Remove expired sessions and temp data',
},
async run() {
const deleted = await db.sessions.deleteMany({
where: { expiresAt: { lt: new Date() } },
})
console.log(`[cleanup] Removed ${deleted.count} expired sessions`)
return { result: `Cleaned ${deleted.count} sessions` }
},
})
Enable and schedule:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: {
tasks: true,
},
scheduledTasks: {
// Run cleanup every hour
'0 * * * *': ['cleanup'],
// Run report generation daily at 2am
'0 2 * * *': ['generate-report'],
},
},
})
Manual task execution (for testing/admin):
// server/api/admin/run-task.post.ts
export default defineEventHandler(async (event) => {
const { task } = await readBody(event)
const result = await runTask(task)
return result
})
Limitations: Tasks run in the same process. For long-running work (video encoding, ML inference), use a dedicated job queue (BullMQ, Temporal, AWS SQS).
Cron-Like Patterns
For simple recurring operations that don’t need a full task system:
// server/plugins/scheduler.ts
export default defineNitroPlugin((nitroApp) => {
// Warm cache every 5 minutes
if (import.meta.dev) return // Skip in dev
setInterval(async () => {
try {
await $fetch('/api/cache-warm', { method: 'POST' })
console.log('[scheduler] Cache warmed')
} catch (error) {
console.error('[scheduler] Cache warm failed:', error)
}
}, 5 * 60 * 1000)
})
Email Sending Pattern
// server/utils/email.ts
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendEmail(options: {
to: string
subject: string
html: string
}) {
return resend.emails.send({
from: 'noreply@myapp.com',
...options,
})
}
// server/api/auth/register.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Create user...
const user = await createUser(body)
// Send welcome email (fire and forget)
sendEmail({
to: user.email,
subject: 'Welcome to MyApp',
html: `<h1>Welcome, ${user.name}!</h1>`,
}).catch(err => console.error('Email failed:', err))
return { user }
})
Walkthrough
- Add i18n:
npx nuxi module add i18nConfigure two languages. Create locale files. Add a language switcher. Verify route prefixing.
- Add feature flags:
- Define flags in
runtimeConfig.public.features - Create a middleware that gates
/betabehind a flag - Toggle via env var, verify behavior changes
- Define flags in
- Add WebSocket:
- Create
server/routes/_ws.tshandler - Create
composables/useWebSocket.ts - Build a simple chat: type message, broadcast to all connected clients
- Create
- Add rate limiting:
- Create server middleware limiting
/api/**to 10 requests/minute - Test by hitting the API rapidly — verify 429 response
- Check rate limit headers in response
- Create server middleware limiting
Gotchas
- i18n with SSR: locale detection uses cookies/headers.
localStoragedoesn’t exist on the server. UseuseCookiefor persistent locale preference. - Feature flag stale state. If you change a flag server-side, connected clients still have the old value until they refresh or re-fetch. Plan a refresh strategy (poll, WebSocket push, page reload prompt).
- Multi-tenancy middleware order. Resolve the tenant BEFORE auth middleware runs. Auth might need to validate against tenant-specific data.
- WebSocket: deployment target matters. Not all Nitro presets support WebSocket (AWS Lambda doesn’t natively). Node.js server, Cloudflare Workers (Durable Objects), and Bun support it. Check before building.
- Background tasks aren’t suitable for long-running work. They run in the main process. A task that takes 30 seconds blocks other things. Use external job queues for heavy work.
- In-memory rate limiting breaks with multiple instances. Each instance has its own counter. Use Redis or another shared store in production.
- Cron in serverless: no persistent process. Serverless functions spin up on demand —
setIntervaldoesn’t work. Use platform-specific schedulers (CloudWatch Events, Vercel Cron, Cloudflare Cron Triggers). - i18n route changes can break. If you change the strategy (
prefix→prefix_except_default), all existing URLs change. Plan redirects.
Exercise
Build a multi-feature production app:
-
i18n — English + one additional language. Locale files with nested keys. Language switcher in header. Verify SSR renders correct language from cookie.
-
Feature flags — a “beta” page at
/betathat’s hidden unlessNUXT_PUBLIC_FEATURES_BETA_PAGE=true. Show a “Coming Soon” badge on navigation for flagged features. -
WebSocket — real-time notifications. When a user creates an item via API, broadcast a notification to all connected clients. Display a toast notification for connected users.
-
Rate limiting — 10 requests/minute on
/api/**. Return proper429withRetry-Afterheader. Test by scripting rapid requests. -
Scheduled task — every 5 minutes, count total items in the database and log it. (Simulated: just log the count from in-memory store.)