Nuxt 5 server-side development with Nitro v3, h3 v2, API routes, middleware, and database integration. Use when creating server routes, integrating D1/Drizzle, or migrating from Nitro v2.
Server routes, API patterns, and backend development with Nitro v3.
Use when: creating server API routes, implementing server middleware, integrating databases (D1, PostgreSQL, Drizzle), handling file uploads, migrating from Nitro v2/h3 v1 to Nitro v3/h3 v2, or building backend logic.
| Area | Nuxt 4 (Nitro v2) | Nuxt 5 (Nitro v3) |
|---|---|---|
| Package | nitropack | nitro |
| h3 imports | import { ... } from 'h3' | import { ... } from 'nitro/h3' |
| Error creation | createError({statusCode}) | new HTTPError({status}) |
| Event path |
event.path |
event.url.pathname |
| Event method | event.method | event.req.method |
| Status code | event.node.res.statusCode | event.res.status |
| Response headers | setResponseHeader(event, ...) | event.res.headers.set(...) |
| Runtime config | useRuntimeConfig(event) | useRuntimeConfig() |
| Route rules redirect | statusCode | status |
server/
├── api/ # API endpoints (/api/*)
│ ├── users/
│ │ ├── index.get.ts → GET /api/users
│ │ ├── index.post.ts → POST /api/users
│ │ ├── [id].get.ts → GET /api/users/:id
│ │ ├── [id].put.ts → PUT /api/users/:id
│ │ └── [id].delete.ts → DELETE /api/users/:id
│ └── health.get.ts → GET /api/health
├── routes/ # Non-API routes
│ └── sitemap.xml.get.ts → GET /sitemap.xml
├── middleware/ # Server middleware
├── plugins/ # Nitro plugins
└── utils/ # Server utilities
| Suffix | HTTP Method |
|---|---|
.get.ts | GET |
.post.ts | POST |
.put.ts | PUT |
.patch.ts | PATCH |
.delete.ts | DELETE |
.ts | All methods |
Load references/server.md when:
// server/api/users/index.get.ts
export default defineEventHandler(async (event) => {
return {
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
}
})
// Nuxt 5 uses Web Standard APIs
export default defineEventHandler((event) => {
// Path
const path = event.url.pathname // was: event.path
const search = event.url.search // URLSearchParams
// Method
const method = event.req.method // was: event.method
// Headers (Web Headers API)
const auth = event.req.headers.get('authorization') // was: getHeader(event, 'authorization')
const allHeaders = event.req.headers
return { path, search, method, auth }
})
export default defineEventHandler((event) => {
// Set status
event.res.status = 201 // was: setResponseStatus(event, 201)
// Set headers (Web Headers API)
event.res.headers.set('X-Custom', 'value') // was: setHeader(event, 'X-Custom', 'value')
event.res.headers.append('Set-Cookie', 'val') // was: appendResponseHeader(event, ...)
return { message: 'Created' }
})
The h3 v1 helper functions (auto-imported) still work for request reading:
// These continue to work (auto-imported)
const id = getRouterParam(event, 'id')
const query = getQuery(event)
const body = await readBody(event)
const cookie = getCookie(event, 'name')
But for setting responses, prefer the Web Standard API:
// Prefer v5 style
event.res.status = 201
event.res.headers.set('Cache-Control', 'max-age=3600')
// Still works but deprecated for setting
setResponseStatus(event, 201)
setHeader(event, 'Cache-Control', 'max-age=3600')
In server routes, use HTTPError instead of createError:
// Nuxt 5 server routes
import { HTTPError } from 'nitro/h3'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw new HTTPError({ status: 400, statusText: 'User ID is required' })
}
const user = await findUser(id)
if (!user) {
throw new HTTPError({ status: 404, statusText: 'Not Found' })
}
return user
})
In the Vue part of your app (app/ directory), createError continues to work:
// app/ code - createError still works
throw createError({
statusCode: 404,
statusMessage: 'Page Not Found',
fatal: true
})
import { z } from 'zod'
import { HTTPError } from 'nitro/h3'
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email()
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const result = createUserSchema.safeParse(body)
if (!result.success) {
throw new HTTPError({
status: 400,
statusText: 'Validation failed',
})
}
return { success: true, data: result.data }
})
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw new HTTPError({ status: 400, statusText: 'User ID is required' })
}
return { id }
})
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const limit = Number(query.limit) || 10
const search = query.search as string | undefined
return { page, limit, search }
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
if (!body.name || !body.email) {
throw new HTTPError({ status: 400, statusText: 'Name and email are required' })
}
return { success: true, user: { id: 1, ...body } }
})
export default defineEventHandler(async (event) => {
// Read cookie (helper still works)
const sessionId = getCookie(event, 'session_id')
// Set cookie
setCookie(event, 'session_id', 'abc123', {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
})
// Delete cookie
deleteCookie(event, 'old_cookie')
return { sessionId }
})
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
const publicRoutes = ['/api/auth/login', '/api/health']
// v5: Use event.url.pathname instead of event.path
if (publicRoutes.includes(event.url.pathname)) {
return
}
const token = event.req.headers.get('authorization')?.replace('Bearer ', '')
if (!token) {
throw new HTTPError({ status: 401, statusText: 'Authentication required' })
}
const user = await verifyToken(token)
event.context.user = user
})
// Nuxt 5: useRuntimeConfig() no longer accepts event
export default defineEventHandler(() => {
const config = useRuntimeConfig()
return { apiBase: config.public.apiBase }
})
// Nuxt 4 (deprecated in v5):
// const config = useRuntimeConfig(event)
// server/utils/db.ts
import { drizzle } from 'drizzle-orm/d1'
import * as schema from '~/server/database/schema'
export function useDB(event: H3Event) {
const { DB } = event.context.cloudflare.env
return drizzle(DB, { schema })
}
// server/api/users/index.get.ts
export default defineEventHandler(async (event) => {
const db = useDB(event)
const users = await db.select().from(schema.users).limit(10)
return { users }
})
// server/api/users/index.post.ts
import { users } from '~/server/database/schema'
export default defineEventHandler(async (event) => {
const db = useDB(event)
const body = await readBody(event)
const [user] = await db.insert(users)
.values({ name: body.name, email: body.email })
.returning()
return { user }
})
// server/api/users/[id].delete.ts
import { eq } from 'drizzle-orm'
import { users } from '~/server/database/schema'
import { HTTPError } from 'nitro/h3'
export default defineEventHandler(async (event) => {
const db = useDB(event)
const id = getRouterParam(event, 'id')
if (!id) {
throw new HTTPError({ status: 400, statusText: 'ID is required' })
}
await db.delete(users).where(eq(users.id, Number(id)))
return { success: true }
})
// server/api/upload.post.ts
import { HTTPError } from 'nitro/h3'
export default defineEventHandler(async (event) => {
const formData = await readMultipartFormData(event)
if (!formData) {
throw new HTTPError({ status: 400, statusText: 'No file uploaded' })
}
const file = formData.find(f => f.name === 'file')
if (!file) {
throw new HTTPError({ status: 400, statusText: 'File field is required' })
}
const { R2 } = event.context.cloudflare.env
const key = `uploads/${Date.now()}-${file.filename}`
await R2.put(key, file.data)
return { key, filename: file.filename, type: file.type }
})
// server/utils/auth.ts
import { HTTPError } from 'nitro/h3'
export function requireAuth(event: H3Event) {
const user = event.context.user
if (!user) {
throw new HTTPError({ status: 401, statusText: 'Authentication required' })
}
return user
}
export function requireRole(event: H3Event, role: string) {
const user = requireAuth(event)
if (user.role !== role) {
throw new HTTPError({ status: 403, statusText: 'Insufficient permissions' })
}
return user
}
// nuxt.config.ts - redirect status property renamed
export default defineNuxtConfig({
routeRules: {
'/old-page': {
redirect: { to: '/new-page', status: 302 } // was: statusCode
},
'/api/**': { cors: true },
'/blog/**': { swr: 3600 }
}
})
// WRONG in v5 server routes - use HTTPError
import { createError } from 'h3'
throw createError({ statusCode: 404, statusMessage: 'Not found' })
// CORRECT in v5 server routes
import { HTTPError } from 'nitro/h3'
throw new HTTPError({ status: 404, statusText: 'Not found' })
// WRONG - deprecated in v5
const path = event.path
// CORRECT
const path = event.url.pathname
// WRONG - no longer accepts event in v5
const config = useRuntimeConfig(event)
// CORRECT
const config = useRuntimeConfig()
// WRONG - Returns error as data with 200 status
if (!user) {
return { error: 'Not found' }
}
// CORRECT - Throw error
if (!user) {
throw new HTTPError({ status: 404, statusText: 'Not found' })
}
Import errors from 'h3':
import { ... } from 'h3' to import { ... } from 'nitro/h3'defineEventHandler, getQuery, readBody) continue to work404 on API Routes:
server/api/.get.ts for GET)Body is Empty:
await readBody(event) not readBody(event)D1 Binding Not Found:
[[d1_databases]] configuredevent.context.cloudflare.env.DBVersion: 5.0.0 | Last Updated: 2026-03-30 | License: MIT