15.4 Server Side — Nitro Server, API Routes, Server Middleware
What Is Nitro?
Nuxt 3 comes with a built-in next-generation server engine called Nitro. Nitro is a universal JavaScript server engine developed by the Nuxt team that handles not just the development server but also production deployments.
Key features of Nitro:
- Universal deployment: Runs on Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge Functions, and more
- File-based routing: Creating a file under
server/automatically creates an API endpoint - Automatic bundling: Bundles server code into an optimized single file
- Hot Module Replacement (HMR): Server code changes are reflected instantly during development
- Storage abstraction: Access the filesystem, Redis, databases, and more through a unified interface
Nitro handles server-side rendering (SSR), the API server, and serverless functions for your Nuxt application.
Server Directory Structure
The server/ directory in Nuxt 3 is organized as follows:
server/
├── api/ # API endpoints (under /api/* path)
│ ├── users.get.ts # GET /api/users
│ ├── users.post.ts # POST /api/users
│ └── users/
│ └── [id].ts # GET/POST /api/users/:id
├── routes/ # Custom server routes (non-API paths)
│ └── sitemap.xml.ts # /sitemap.xml
├── middleware/ # Server middleware (runs on every request)
│ └── auth.ts
├── plugins/ # Nitro plugins (run once on server start)
│ └── database.ts
└── utils/ # Server utility functions (auto-imported)
└── db.ts
API Routes Basics
Your First API Endpoint
Create a file at server/api/hello.ts and a /api/hello endpoint is instantly available.
// server/api/hello.ts
export default defineEventHandler((event) => {
return {
message: 'Hello from Nitro!',
timestamp: new Date().toISOString(),
}
})
Call /api/hello from a browser or via useFetch to receive a JSON response.
Endpoints Per HTTP Method
Append the HTTP method to the filename to handle only that specific method:
server/api/users.get.ts → GET /api/users
server/api/users.post.ts → POST /api/users
server/api/users.put.ts → PUT /api/users
server/api/users.delete.ts → DELETE /api/users
// server/api/users.get.ts
export default defineEventHandler(async (event) => {
// In a real app, query from a database
const users = [
{ id: 1, name: 'Alice Kim', email: 'alice@example.com' },
{ id: 2, name: 'Bob Lee', email: 'bob@example.com' },
]
return users
})
// server/api/users.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Input validation
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
statusMessage: 'name and email are required.',
})
}
// In a real app, save to a database
const newUser = {
id: Date.now(),
name: body.name,
email: body.email,
createdAt: new Date().toISOString(),
}
setResponseStatus(event, 201)
return newUser
})
Dynamic Routes
Use square brackets in filenames to accept URL parameters:
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id || isNaN(Number(id))) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid user ID.',
})
}
// In a real app, query a specific user from the database
const user = { id: Number(id), name: 'Alice Kim', email: 'alice@example.com' }
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'User not found.',
})
}
return user
})
Handling Query Parameters
// server/api/search.ts
export default defineEventHandler((event) => {
const query = getQuery(event)
// /api/search?q=nuxt&page=1&limit=10
const { q, page = '1', limit = '10' } = query
if (!q) {
throw createError({
statusCode: 400,
statusMessage: 'Please provide a search query (q).',
})
}
const pageNum = parseInt(page as string)
const limitNum = parseInt(limit as string)
// Actual search logic
return {
query: q,
page: pageNum,
limit: limitNum,
results: [],
total: 0,
}
})
Real-World Example: A Complete REST API
Let's implement a full CRUD API for blog posts.
Setting Up the Data Layer
// server/utils/db.ts
// In a real app, use an ORM like Prisma or Drizzle
// Here we use an in-memory store for demonstration
export interface Post {
id: number
title: string
content: string
authorId: number
createdAt: string
updatedAt: string
published: boolean
}
const posts: Post[] = [
{
id: 1,
title: 'Getting Started with Nuxt 3',
content: 'Nuxt 3 is a full-stack framework based on Vue 3...',
authorId: 1,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
published: true,
},
]
let nextId = 2
export const db = {
posts: {
findAll: () => posts,
findById: (id: number) => posts.find((p) => p.id === id),
findPublished: () => posts.filter((p) => p.published),
create: (data: Omit<Post, 'id' | 'createdAt' | 'updatedAt'>) => {
const now = new Date().toISOString()
const post: Post = {
...data,
id: nextId++,
createdAt: now,
updatedAt: now,
}
posts.push(post)
return post
},
update: (id: number, data: Partial<Post>) => {
const index = posts.findIndex((p) => p.id === id)
if (index === -1) return null
posts[index] = {
...posts[index],
...data,
updatedAt: new Date().toISOString(),
}
return posts[index]
},
delete: (id: number) => {
const index = posts.findIndex((p) => p.id === id)
if (index === -1) return false
posts.splice(index, 1)
return true
},
},
}
List and Create
// server/api/posts/index.ts
// GET /api/posts — list posts
// POST /api/posts — create a post
export default defineEventHandler(async (event) => {
const method = getMethod(event)
if (method === 'GET') {
const query = getQuery(event)
const publishedOnly = query.published === 'true'
const posts = publishedOnly ? db.posts.findPublished() : db.posts.findAll()
return {
data: posts,
total: posts.length,
}
}
if (method === 'POST') {
// Auth check (or let middleware handle it)
const body = await readBody(event)
if (!body.title || !body.content) {
throw createError({
statusCode: 422,
statusMessage: 'title and content are required.',
})
}
const post = db.posts.create({
title: body.title,
content: body.content,
authorId: body.authorId ?? 1,
published: body.published ?? false,
})
setResponseStatus(event, 201)
return post
}
throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
})
Read, Update, Delete
// server/api/posts/[id].ts
// GET /api/posts/:id — read one post
// PUT /api/posts/:id — update a post
// DELETE /api/posts/:id — delete a post
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'))
const method = getMethod(event)
if (isNaN(id)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid ID format.' })
}
if (method === 'GET') {
const post = db.posts.findById(id)
if (!post) {
throw createError({ statusCode: 404, statusMessage: 'Post not found.' })
}
return post
}
if (method === 'PUT') {
const body = await readBody(event)
const updated = db.posts.update(id, body)
if (!updated) {
throw createError({ statusCode: 404, statusMessage: 'Post not found.' })
}
return updated
}
if (method === 'DELETE') {
const deleted = db.posts.delete(id)
if (!deleted) {
throw createError({ statusCode: 404, statusMessage: 'Post not found.' })
}
setResponseStatus(event, 204)
return null
}
throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
})
Server Middleware
Server middleware runs before every request (or requests matching specific paths). Use it for authentication, logging, request transformation, and more.
Request Logging Middleware
// server/middleware/logger.ts
export default defineEventHandler((event) => {
const start = Date.now()
const url = getRequestURL(event)
const method = getMethod(event)
// Response hook — runs after request processing completes
event.node.res.on('finish', () => {
const duration = Date.now() - start
const status = event.node.res.statusCode
console.log(`[${new Date().toISOString()}] ${method} ${url.pathname} → ${status} (${duration}ms)`)
})
})
Authentication Middleware
// server/middleware/auth.ts
export default defineEventHandler((event) => {
// Skip auth for public paths
const publicPaths = ['/api/auth/login', '/api/posts']
const url = getRequestURL(event)
const isPublic = publicPaths.some((path) =>
url.pathname === path || (url.pathname.startsWith('/api/posts') && getMethod(event) === 'GET')
)
if (isPublic) return
// Extract token from Authorization header
const authorization = getHeader(event, 'authorization')
if (!authorization?.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required.',
})
}
const token = authorization.slice(7)
// In a real app, use a JWT verification library
if (token !== 'valid-token') {
throw createError({
statusCode: 401,
statusMessage: 'Invalid token.',
})
}
// Store user info in context for downstream handlers
event.context.user = { id: 1, name: 'Alice Kim', role: 'admin' }
})
CORS Middleware
// server/middleware/cors.ts
export default defineEventHandler((event) => {
// List of allowed origins
const allowedOrigins = ['https://myapp.com', 'https://admin.myapp.com']
const origin = getHeader(event, 'origin')
if (origin && allowedOrigins.includes(origin)) {
setHeader(event, 'Access-Control-Allow-Origin', origin)
}
setHeader(event, 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization')
setHeader(event, 'Access-Control-Max-Age', '86400')
// Handle OPTIONS preflight request
if (getMethod(event) === 'OPTIONS') {
setResponseStatus(event, 204)
return ''
}
})
Nitro Plugins
Plugins run once when the server starts. Use them to initialize database connections, set up schedulers, and more.
// server/plugins/database.ts
export default defineNitroPlugin(async (nitroApp) => {
console.log('Initializing database connection...')
// In a real app, initialize Prisma, MongoDB, etc.
// await prisma.$connect()
// Clean up on server shutdown
nitroApp.hooks.hookOnce('close', async () => {
console.log('Closing database connection...')
// await prisma.$disconnect()
})
console.log('Database connection established.')
})
Server Storage (Nitro Storage)
Nitro provides a unified storage API. You can easily switch from the filesystem in development to Redis or a KV store in production.
Storage Configuration in nuxt.config.ts
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
storage: {
// Development: use filesystem
cache: {
driver: 'fs',
base: './.nitro/cache',
},
// Production: example with Redis
// cache: {
// driver: 'redis',
// url: process.env.REDIS_URL,
// },
},
},
})
Using Storage in an API Handler
// server/api/cache-demo.ts
export default defineEventHandler(async (event) => {
const storage = useStorage('cache')
const cacheKey = 'expensive-data'
// Try to read from cache first
const cached = await storage.getItem(cacheKey)
if (cached) {
return { data: cached, source: 'cache' }
}
// Cache miss — perform the real computation (e.g., call an external API)
await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate heavy work
const data = { result: 'Expensive computation result', timestamp: Date.now() }
// Save to cache (TTL: 60 seconds)
await storage.setItem(cacheKey, data, { ttl: 60 })
return { data, source: 'computed' }
})
Calling External APIs and Proxying
// server/api/proxy/github.ts
// Call an external API server-side so API keys are never exposed to the client
export default defineEventHandler(async (event) => {
const username = getRouterParam(event, 'username') ?? getQuery(event).username
if (!username) {
throw createError({ statusCode: 400, statusMessage: 'username is required.' })
}
// Use the API key server-side to call the external API
const data = await $fetch(`https://api.github.com/users/${username}`, {
headers: {
Authorization: `token ${process.env.GITHUB_TOKEN}`,
'User-Agent': 'MyNuxtApp/1.0',
},
})
// Return only the fields you need
return {
login: (data as any).login,
name: (data as any).name,
avatar_url: (data as any).avatar_url,
public_repos: (data as any).public_repos,
followers: (data as any).followers,
}
})
Validation and Type Safety
Use zod to validate request bodies in a type-safe way.
npm install zod
// server/api/posts/index.post.ts
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title is required.').max(200, 'Title cannot exceed 200 characters.'),
content: z.string().min(10, 'Content must be at least 10 characters.'),
published: z.boolean().optional().default(false),
tags: z.array(z.string()).optional().default([]),
})
export default defineEventHandler(async (event) => {
const rawBody = await readBody(event)
// Validate with Zod
const result = CreatePostSchema.safeParse(rawBody)
if (!result.success) {
throw createError({
statusCode: 422,
statusMessage: 'Invalid input.',
data: result.error.flatten(),
})
}
const { title, content, published, tags } = result.data
// Process with validated data
const post = {
id: Date.now(),
title,
content,
published,
tags,
createdAt: new Date().toISOString(),
}
setResponseStatus(event, 201)
return post
})
H3 Utility Functions Reference
Nitro is built on the h3 library, which provides a wide range of built-in utilities:
// server/api/utils-demo.ts
export default defineEventHandler(async (event) => {
// Reading request info
const method = getMethod(event) // 'GET', 'POST', etc.
const url = getRequestURL(event) // URL object
const query = getQuery(event) // Query parameters
const body = await readBody(event) // Request body (auto-parsed as JSON)
const headers = getHeaders(event) // All headers
const cookie = getCookie(event, 'token') // A specific cookie
const ip = getRequestIP(event) // Client IP address
// Setting response
setResponseStatus(event, 200)
setHeader(event, 'X-Custom-Header', 'value')
setCookie(event, 'session', 'abc123', {
httpOnly: true,
secure: true,
maxAge: 60 * 60 * 24, // 24 hours
sameSite: 'lax',
})
deleteCookie(event, 'old-token')
return { ok: true }
})
Pro Tips
1. Control Caching with Route Rules
You can declaratively configure caching strategies for specific paths in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// Static assets — cache for 1 year
'/assets/**': { headers: { 'cache-control': 'max-age=31536000' } },
// API responses — cache for 1 minute (including CDN)
'/api/posts': { cache: { maxAge: 60 } },
// Personalized data — no caching
'/api/user/**': { headers: { 'cache-control': 'no-store' } },
// SSG pages
'/blog/**': { prerender: true },
// SPA mode
'/dashboard/**': { ssr: false },
},
})
2. Type-Safe Server Environment Variables
// server/utils/env.ts
// Type-safe access to environment variables via runtime config
export function useServerEnv() {
const config = useRuntimeConfig()
return {
databaseUrl: config.databaseUrl as string,
jwtSecret: config.jwtSecret as string,
redisUrl: config.redisUrl as string,
}
}
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Server-only (never exposed to the client)
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
redisUrl: process.env.REDIS_URL,
// Also exposed to the client
public: {
apiBase: process.env.API_BASE_URL ?? '/api',
appName: 'My Nuxt App',
},
},
})
3. Event Streaming (Server-Sent Events)
// server/api/sse.ts
export default defineEventHandler(async (event) => {
// Set SSE headers
setHeader(event, 'Content-Type', 'text/event-stream')
setHeader(event, 'Cache-Control', 'no-cache')
setHeader(event, 'Connection', 'keep-alive')
const stream = new ReadableStream({
async start(controller) {
let count = 0
const interval = setInterval(() => {
if (count >= 10) {
controller.enqueue('data: [DONE]\n\n')
controller.close()
clearInterval(interval)
return
}
controller.enqueue(`data: ${JSON.stringify({ count: ++count, time: Date.now() })}\n\n`)
}, 1000)
},
})
return sendStream(event, stream)
})
4. WebSockets (Nitro WebSocket)
// server/routes/_ws.ts
// Supported in Nuxt 3.10+ / Nitro 2.9+
export default defineWebSocketHandler({
open(peer) {
console.log('Client connected:', peer.id)
peer.send(JSON.stringify({ type: 'welcome', message: 'You are connected!' }))
},
message(peer, message) {
console.log('Message received:', message.text())
// Echo server
peer.send(JSON.stringify({ type: 'echo', data: message.text() }))
},
close(peer) {
console.log('Client disconnected:', peer.id)
},
error(peer, error) {
console.error('WebSocket error:', peer.id, error)
},
})
5. Sharing Types Between Server and Client
// shared/types/post.ts — Types shared between server and client
// In Nuxt 3, the shared/ directory is auto-imported on both server and client
export interface Post {
id: number
title: string
content: string
published: boolean
createdAt: string
}
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
limit: number
totalPages: number
}