Nuxt Server Patterns
Versions: Nuxt uses h3 v1 and nitropack v2. Patterns from h3 v2 or nitro v3 docs won't work.
When to Use
Working with server/ directory - API routes, server middleware, server utilities.
Server Directory Structure
server/
├── api/ # API endpoints
│ ├── users.get.ts # GET /api/users
│ ├── users.post.ts # POST /api/users
│ └── users/
│ └── [id].get.ts # GET /api/users/:id
├── routes/ # Non-API routes
│ └── healthz.get.ts # GET /healthz
├── middleware/ # Server middleware
│ └── log.ts
└── utils/ # Server utilities (auto-imported)
└── db.tsAPI Routes
File naming determines HTTP method and route:
users.get.ts→ GET /api/usersusers.post.ts→ POST /api/usersusers/[userId].get.ts→ GET /api/users/:userIdusers/[userId].delete.ts→ DELETE /api/users/:userId
REQUIRED: Use descriptive param names: [userId].get.ts NOT [id].get.ts
Red Flags - Stop and Check Skill
If you're thinking any of these, STOP and re-read this skill:
- "I'll use event.context.params like before"
- "Generic [id] is fine for params"
- "Don't need .get.ts suffix"
- "I remember how Nuxt 3 API routes worked"
All of these mean: You're using outdated patterns. Use Nuxt 4 patterns instead.
Basic API Route
// server/api/users.get.ts
export default defineEventHandler(async (event) => {
const users = await fetchUsers()
return users
})Route with Params
// server/api/users/[userId].get.ts
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'userId')
if (!userId) {
throw createError({
statusCode: 400,
message: 'User ID is required'
})
}
const user = await fetchUserById(userId)
if (!user) {
throw createError({
statusCode: 404,
message: 'User not found'
})
}
return user
})Route with Query Params
// server/api/users.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const limit = Number(query.limit) || 10
const users = await fetchUsers({ page, limit })
return users
})Route with Body
// server/api/users.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Validate body
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
message: 'Missing required fields: name, email'
})
}
const user = await createUser(body)
setResponseStatus(event, 201)
return user
})Validation with Valibot
Use readValidatedBody and getValidatedQuery for schema validation:
// server/api/users.post.ts
import * as v from 'valibot'
const UserSchema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
email: v.pipe(v.string(), v.email())
})
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, v.parser(UserSchema))
// body is typed as { name: string, email: string }
const user = await createUser(body)
setResponseStatus(event, 201)
return user
})// server/api/users.get.ts
import * as v from 'valibot'
const QuerySchema = v.object({
page: v.optional(v.pipe(v.string(), v.transform(Number)), '1'),
limit: v.optional(v.pipe(v.string(), v.transform(Number)), '10')
})
export default defineEventHandler(async (event) => {
const { page, limit } = await getValidatedQuery(event, v.parser(QuerySchema))
return fetchUsers({ page, limit })
})Error Handling
Use createError for HTTP errors:
throw createError({
statusCode: 400,
statusMessage: 'Bad Request',
message: 'Invalid input',
data: { field: 'email' } // Optional additional data
})Server Middleware
Runs on every server request:
// server/middleware/log.ts
export default defineEventHandler((event) => {
console.log(`${event.method} ${event.path}`)
})Named middleware for specific patterns:
// server/middleware/auth.ts
export default defineEventHandler((event) => {
const token = getRequestHeader(event, 'authorization')
if (!token) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
})
}
// Attach user to event context
event.context.user = await verifyToken(token)
})Server Utils
Reusable server functions (auto-imported):
// server/utils/db.ts
import { db } from './database'
export async function fetchUsers(options: { page: number, limit: number }) {
return await db.select().from('users').limit(options.limit).offset((options.page - 1) * options.limit)
}
export async function fetchUserById(id: string) {
return await db.select().from('users').where({ id }).first()
}Auto-imported in all server routes and middleware.
Import server utils from client (Nuxt 4.3+):
// Use #server alias for type-safe server-only imports
import type { User } from '#server/utils/db'Note: Only types are imported; actual server code never bundles into client.
Cached Functions
Use defineCachedFunction for caching expensive operations in server utils:
// server/utils/github.ts
export const fetchRepo = defineCachedFunction(
async (owner: string, repo: string) => {
return await $fetch(`https://api.github.com/repos/${owner}/${repo}`)
},
{
maxAge: 60 * 5, // Cache for 5 minutes
swr: true, // Stale-while-revalidate
name: 'github-repo',
getKey: (owner, repo) => `${owner}/${repo}`,
}
)Cached Event Handlers
Use defineCachedEventHandler for ISR-style caching on API routes:
// server/api/products/[productId].get.ts
export default defineCachedEventHandler(
async (event) => {
const productId = getRouterParam(event, 'productId')
return await fetchProductById(productId)
},
{
maxAge: 3600, // Cache for 1 hour
swr: true, // Serve stale while revalidating
getKey: event => getRouterParam(event, 'productId') ?? '',
}
)Generic Error Handler
Centralize error handling for H3 errors, validation errors, and fallbacks:
// server/utils/error-handler.ts
import { isError, createError } from 'h3'
import * as v from 'valibot'
export function handleApiError(error: unknown, fallback: { statusCode?: number, message: string }): never {
// Re-throw existing H3 errors
if (isError(error)) throw error
// Handle Valibot validation errors
if (v.isValiError(error)) {
throw createError({ statusCode: 400, message: error.issues[0].message })
}
// Generic fallback
throw createError({ statusCode: fallback.statusCode ?? 502, message: fallback.message })
}Usage in routes:
export default defineEventHandler(async (event) => {
try {
const data = await fetchExternalApi()
return data
} catch (error) {
handleApiError(error, { statusCode: 502, message: 'Failed to fetch data' })
}
})Request Helpers
// Get params
const userId = getRouterParam(event, 'userId')
// Get query
const query = getQuery(event)
// Get body
const body = await readBody(event)
// Get headers
const auth = getRequestHeader(event, 'authorization')
// Get cookies
const token = getCookie(event, 'token')
// Get method
const method = getMethod(event)
// Get IP
const ip = getRequestIP(event)Response Helpers
// Set status code
setResponseStatus(event, 201)
// Set headers
setResponseHeader(event, 'X-Custom', 'value')
setResponseHeaders(event, { 'X-Custom': 'value', 'X-Another': 'value' })
// Set cookies
setCookie(event, 'token', 'value', {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 1 week
})
// Redirect
return sendRedirect(event, '/login', 302)
// Stream
return sendStream(event, stream)
// No content
return sendNoContent(event)Background Tasks
Use event.waitUntil() for async tasks that shouldn't block the response (Nuxt 4+):
// server/api/analytics.post.ts
export default defineEventHandler(async (event) => {
const data = await readBody(event)
// Don't block response with analytics logging
event.waitUntil(
logAnalytics(data)
)
return { success: true }
})Use cases: logging, caching, background processing, async cleanup.
Best Practices
- Use descriptive param names -
[userId]not[id] - Keep routes thin - delegate to server utils
- Validate input at route level
- Use typed errors with createError
- Handle errors gracefully - don't expose internals
- Use server utils for DB/external APIs
- Don't expose sensitive data in responses
- Set proper status codes - 201 for created, 204 for no content
- Use event.waitUntil() for background tasks that shouldn't block responses
Common Mistakes
| ❌ Wrong | ✅ Right |
|---|---|
event.context.params.id | getRouterParam(event, 'id') |
return res.json(data) | return data |
[id].get.ts | [userId].get.ts |
users-id.get.ts | users/[id].get.ts |
| Throw generic errors | Use createError with status |
WebSocket
// server/routes/_ws.ts
export default defineWebSocketHandler({
open(peer) {
console.log('Client connected:', peer.id)
},
message(peer, message) {
peer.send(`Echo: ${message.text()}`)
// Broadcast to all: peer.publish('channel', message)
},
close(peer) {
console.log('Client disconnected:', peer.id)
}
})Enable in config:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: { websocket: true }
}
})Server-Sent Events (Experimental)
// server/api/stream.get.ts
export default defineEventHandler(async (event) => {
const stream = createEventStream(event)
const interval = setInterval(async () => {
await stream.push({ data: JSON.stringify({ time: Date.now() }) })
}, 1000)
stream.onClosed(() => {
clearInterval(interval)
})
return stream.send()
})Resources
- Nuxt server: https://nuxt.com/docs/guide/directory-structure/server
- h3 (Nitro engine): https://v1.h3.dev/
- Nitro: https://nitro.build/
For database/storage APIs: see
nuxthubskill