Skip to main content
Advertisement

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
}
Advertisement