
Learn to build powerful API routes with Nuxt 3's Nitro server. Complete guide covering CRUD operations, authentication, file uploads, and production deployment.

Get notified when we release new tutorials, lessons, and other expert Nuxt content.
Building full-stack applications used to mean juggling multiple technologies, managing separate servers, and dealing with complex deployment pipelines. Nuxt 3's Nitro server changes this paradigm entirely, offering Vue.js and Nuxt developers a unified approach to creating both frontend and backend functionality within a single Nuxt codebase.
In this comprehensive guide, we'll explore how to harness the power of Nitro to build robust, scalable API routes that integrate seamlessly with your Nuxt applications. Whether you're building a simple contact form or a complex REST API, you'll discover how Nitro's innovative architecture makes server-side development both intuitive and powerful.
Nitro represents a fundamental shift in how we think about server-side development in the JavaScript ecosystem. Unlike traditional server frameworks that require extensive configuration, Nitro provides a zero-config approach that works out of the box while remaining highly customizable.
Key advantages that make Nitro special:
The beauty of Nitro lies in its simplicity. Let's see how easy it is to get started.
Every API journey begins with a single route. In Nuxt 3, creating an API endpoint is as simple as adding a file to the server/api/ directory. Let's start with a basic example:
// server/api/hello.js
export default defineEventHandler((event) => {
return {
message: 'Welcome to Nitro API development',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV
}
})
This simple route demonstrates several important concepts:
/api/hello)defineEventHandler function wraps your route logicWhen you navigate to /api/hello, Nitro automatically handles the HTTP request, executes your function, and returns the JSON response with appropriate headers.
Real-world applications need to handle dynamic URLs. Nitro uses a file-naming convention with square brackets (eg. id.js) to create dynamic route segments:
// server/api/users/[id].js
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const user = await getUserById(id)
if (!user) {
throw createError({
statusCode: 404,
statusMessage: `User with ID ${id} not found`
})
}
return {
user,
requestedId: id,
retrievedAt: new Date().toISOString()
}
})
This pattern is incredibly powerful because:
/api/users/123, /api/users/john-doe, and /api/users/abc-xyz all workgetRouterParam safely extracts the dynamic segmentcreateError function provides consistent error responsesModern APIs need to handle various HTTP methods. While you can create method-specific files (like users.post.js), it's often more maintainable to handle multiple methods in a single file:
// server/api/users/index.js
export default defineEventHandler(async (event) => {
const method = getMethod(event)
switch (method) {
case 'GET':
return await handleGetUsers(event)
case 'POST':
return await handleCreateUser(event)
case 'PUT':
return await handleUpdateUser(event)
case 'DELETE':
return await handleDeleteUser(event)
default:
throw createError({
statusCode: 405,
statusMessage: `Method ${method} not allowed`
})
}
})
async function handleGetUsers(event) {
const users = await getAllUsers()
return {
users,
total: users.length,
retrievedAt: new Date().toISOString()
}
}
async function handleCreateUser(event) {
const userData = await readBody(event)
const newUser = await createUser(userData)
return {
user: newUser,
message: 'User created successfully'
}
}
This approach provides several benefits:
Effective API development requires robust handling of various input types. Nitro provides intuitive utilities for accessing request data:
// server/api/contact.post.js
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Validate required fields
const requiredFields = ['name', 'email', 'message']
const missingFields = requiredFields.filter(field => !body[field])
if (missingFields.length > 0) {
throw createError({
statusCode: 400,
statusMessage: `Missing required fields: ${missingFields.join(', ')}`
})
}
// Process the contact submission
const submission = {
...body,
submittedAt: new Date().toISOString(),
id: generateUniqueId()
}
await saveContactSubmission(submission)
return {
success: true,
submissionId: submission.id,
message: 'Contact form submitted successfully'
}
})
Query parameters are essential for filtering, pagination, and search functionality:
// server/api/products/search.js
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const {
q,
category = 'all',
page = 1,
limit = 20,
sortBy = 'name',
sortOrder = 'asc'
} = query
// Validate search query
if (!q || q.trim().length < 2) {
throw createError({
statusCode: 400,
statusMessage: 'Search query must be at least 2 characters long'
})
}
// Build search parameters
const searchParams = {
query: q.trim(),
category,
pagination: {
page: Math.max(1, parseInt(page)),
limit: Math.min(100, parseInt(limit)) // Prevent excessive results
},
sorting: {
field: sortBy,
order: sortOrder
}
}
const results = await searchProducts(searchParams)
return {
query: q,
results: results.items,
pagination: {
currentPage: searchParams.pagination.page,
totalPages: Math.ceil(results.total / searchParams.pagination.limit),
totalItems: results.total,
itemsPerPage: searchParams.pagination.limit
},
appliedFilters: {
category,
sortBy,
sortOrder
}
}
})
Understanding how to work with headers and cookies is crucial for authentication, caching, and user experience:
// server/api/auth/profile.js
export default defineEventHandler(async (event) => {
// Extract authentication information
const authToken = getHeader(event, 'authorization')?.replace('Bearer ', '')
const sessionId = getCookie(event, 'session-id')
const preferredLanguage = getHeader(event, 'accept-language')
// Validate authentication
if (!authToken) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication token required'
})
}
try {
const user = await verifyAuthToken(authToken)
// Update last activity
const lastActivity = new Date().toISOString()
setCookie(event, 'last-activity', lastActivity, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 30 // 30 days
})
// Set response headers
setHeader(event, 'Cache-Control', 'private, no-cache')
setHeader(event, 'Content-Language', user.preferredLanguage || 'en')
return {
user: {
id: user.id,
name: user.name,
email: user.email,
preferences: user.preferences
},
session: {
id: sessionId,
lastActivity,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
}
}
} catch (error) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid authentication token'
})
}
})
Middleware functions run before your API routes and provide a powerful way to implement cross-cutting concerns like authentication, logging, and request validation:
// server/middleware/auth.js
export default defineEventHandler(async (event) => {
// Only process API routes
if (!event.node.req.url?.startsWith('/api/')) {
return
}
// Define public routes that don't require authentication
const publicRoutes = [
'/api/auth/login',
'/api/auth/register',
'/api/health',
'/api/public/'
]
const isPublicRoute = publicRoutes.some(route =>
event.node.req.url?.startsWith(route)
)
if (isPublicRoute) {
return
}
// Extract and validate authentication token
const authHeader = getHeader(event, 'authorization')
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
try {
const user = await validateAuthToken(token)
// Attach user information to the event context
event.context.user = user
event.context.authToken = token
// Log authenticated request
console.log(`Authenticated request: ${event.node.req.method} ${event.node.req.url} - User: ${user.id}`)
} catch (error) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid or expired authentication token'
})
}
})
Modern applications require robust data persistence. Here's how to integrate popular database solutions with Nitro:
// server/api/posts/index.js
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default defineEventHandler(async (event) => {
const method = getMethod(event)
if (method === 'GET') {
const query = getQuery(event)
const { page = 1, limit = 10, author, tag } = query
// Build dynamic query conditions
const whereConditions = {}
if (author) whereConditions.authorId = author
if (tag) whereConditions.tags = { has: tag }
const [posts, totalCount] = await Promise.all([
prisma.post.findMany({
where: whereConditions,
include: {
author: {
select: {
id: true,
name: true,
email: true
}
},
_count: {
select: {
comments: true,
likes: true
}
}
},
orderBy: {
createdAt: 'desc'
},
skip: (page - 1) * limit,
take: parseInt(limit)
}),
prisma.post.count({ where: whereConditions })
])
return {
posts,
pagination: {
currentPage: parseInt(page),
totalPages: Math.ceil(totalCount / limit),
totalItems: totalCount,
itemsPerPage: parseInt(limit)
},
filters: {
author,
tag
}
}
}
if (method === 'POST') {
const body = await readBody(event)
const userId = event.context.user?.id
if (!userId) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required to create posts'
})
}
const post = await prisma.post.create({
data: {
title: body.title,
content: body.content,
excerpt: body.excerpt || body.content.substring(0, 200),
authorId: userId,
tags: body.tags || [],
published: body.published || false
},
include: {
author: {
select: {
id: true,
name: true,
email: true
}
}
}
})
return {
post,
message: 'Post created successfully'
}
}
})
Effective caching can dramatically improve your API's performance. Nitro provides built-in caching mechanisms that are both powerful and easy to use:
// server/api/analytics/dashboard.js
export default defineCacheEventHandler({
// Cache for 5 minutes (in milliseconds)
maxAge: 1000 * 60 * 5,
// Use SWR (stale-while-revalidate) for better UX
swr: true,
// Vary cache by authorization header (per user)
varies: ['authorization'],
// Optionally, give the cache a custom name (per user)
getKey: event => {
const userId = event.context.user?.id
return userId ? `dashboard-${userId}` : 'dashboard-anon'
},
async handler(event) {
const userId = event.context.user?.id
if (!userId) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
// Expensive operations
const [userStats, recentActivity, performanceMetrics] = await Promise.all([
getUserStatistics(userId),
getRecentActivity(userId, 10),
getPerformanceMetrics(userId)
])
return {
user: {
id: userId,
stats: userStats
},
recentActivity,
performance: performanceMetrics,
generatedAt: new Date().toISOString(),
cached: true,
cacheInfo: {
strategy: 'stale-while-revalidate',
ttl: 300 // 5 minutes
}
}
}
})
File uploads are a common requirement in modern applications. Here's how to handle them securely and efficiently:
// server/api/uploads/images.post.js
import { promises as fs } from 'fs'
import path from 'path'
import { createHash } from 'crypto'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
export default defineEventHandler(async (event) => {
const files = await readMultipartFormData(event)
if (!files || files.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'No files provided'
})
}
const userId = event.context.user?.id
if (!userId) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required for file uploads'
})
}
const uploadResults = []
for (const file of files) {
if (file.type !== 'file') continue
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
throw createError({
statusCode: 400,
statusMessage: `File type ${file.type} not allowed. Supported types: ${ALLOWED_TYPES.join(', ')}`
})
}
// Validate file size
if (file.data.length > MAX_FILE_SIZE) {
throw createError({
statusCode: 400,
statusMessage: `File size exceeds maximum limit of ${MAX_FILE_SIZE / 1024 / 1024}MB`
})
}
// Generate secure filename
const fileHash = createHash('md5').update(file.data).digest('hex')
const extension = path.extname(file.filename || '')
const filename = `${userId}-${Date.now()}-${fileHash}${extension}`
// Ensure upload directory exists
const uploadDir = path.join(process.cwd(), 'uploads', 'images')
await fs.mkdir(uploadDir, { recursive: true })
// Save file
const filepath = path.join(uploadDir, filename)
await fs.writeFile(filepath, file.data)
// Store file metadata
const fileMetadata = {
id: generateUniqueId(),
filename,
originalName: file.filename,
size: file.data.length,
type: file.type,
uploadedBy: userId,
uploadedAt: new Date().toISOString(),
hash: fileHash
}
await saveFileMetadata(fileMetadata)
uploadResults.push({
id: fileMetadata.id,
filename,
originalName: file.filename,
size: file.data.length,
url: `/uploads/images/${filename}`
})
}
return {
success: true,
files: uploadResults,
message: `Successfully uploaded ${uploadResults.length} file(s)`
}
})
Robust error handling is essential for production applications. Here's how to implement comprehensive error management:
// server/api/users/[id].put.js
import Joi from 'joi'
// Define validation schema
const updateUserSchema = Joi.object({
name: Joi.string().min(2).max(100).trim(),
email: Joi.string().email().lowercase(),
age: Joi.number().integer().min(13).max(150),
preferences: Joi.object({
theme: Joi.string().valid('light', 'dark', 'auto'),
notifications: Joi.boolean(),
language: Joi.string().length(2)
})
})
export default defineEventHandler(async (event) => {
const startTime = Date.now()
try {
const id = getRouterParam(event, 'id')
const body = await readBody(event)
const currentUser = event.context.user
// Authorization check
if (currentUser.id !== id && !currentUser.isAdmin) {
throw createError({
statusCode: 403,
statusMessage: 'Insufficient permissions to update this user'
})
}
// Validate input data
const { error, value } = updateUserSchema.validate(body, {
abortEarly: false,
stripUnknown: true
})
if (error) {
throw createError({
statusCode: 400,
statusMessage: 'Validation failed',
data: {
errors: error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}))
}
})
}
// Check if user exists
const existingUser = await getUserById(id)
if (!existingUser) {
throw createError({
statusCode: 404,
statusMessage: `User with ID ${id} not found`
})
}
// Check for email conflicts
if (value.email && value.email !== existingUser.email) {
const emailExists = await checkEmailExists(value.email)
if (emailExists) {
throw createError({
statusCode: 409,
statusMessage: 'Email address already in use'
})
}
}
// Update user
const updatedUser = await updateUser(id, value)
// Log the update
await logUserActivity({
userId: id,
action: 'profile_updated',
updatedBy: currentUser.id,
changes: Object.keys(value),
timestamp: new Date().toISOString()
})
return {
user: {
id: updatedUser.id,
name: updatedUser.name,
email: updatedUser.email,
age: updatedUser.age,
preferences: updatedUser.preferences,
updatedAt: updatedUser.updatedAt
},
message: 'User updated successfully',
processingTime: Date.now() - startTime
}
} catch (error) {
// Log error for monitoring
console.error('User update error:', {
userId: getRouterParam(event, 'id'),
error: error.message,
stack: error.stack,
processingTime: Date.now() - startTime
})
// Re-throw known errors
if (error.statusCode) {
throw error
}
// Handle unexpected errors
throw createError({
statusCode: 500,
statusMessage: 'Internal server error occurred while updating user'
})
}
})
When deploying your Nitro API to production, consider these essential configurations:
// server/api/health.js
export default defineEventHandler(() => {
const config = useRuntimeConfig()
const uptime = process.uptime()
return {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: Math.floor(uptime),
environment: config.public.environment,
version: config.public.appVersion || '1.0.0',
database: {
connected: true, // Add actual database health check
latency: '< 50ms'
},
memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024)
}
}
})
Nuxt 3's Nitro server represents a paradigm shift in full-stack development, offering developers a powerful, flexible, and intuitive way to build APIs. By following the patterns and practices outlined in this guide, you'll be well-equipped to create production-ready APIs that are both maintainable and performant.
The key to mastering Nitro lies in understanding its conventions, leveraging its built-in features, and following established best practices. Whether you're building a simple API for a personal project or a complex system for enterprise use, Nitro provides the foundation you need to succeed.
Remember that great APIs are built iteratively. Start with the basics, add features as needed, and always prioritize security, performance, and maintainability. With Nitro's powerful features at your disposal, you have everything you need to build exceptional server-side applications.
