Nuxt API Routes With Nitro: Building Your Backend in the Same Repo
A practical guide to Nuxt server routes powered by Nitro — file-based routing, middleware, validation, database access, and deploying a full-stack application from one codebase.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
One of the most underappreciated features in Nuxt is the Nitro server — the universal JavaScript server runtime that powers server/ directory routes. With Nitro, you can build a complete backend API alongside your frontend in a single repository, with shared TypeScript types, shared utilities, and a single deployment artifact.
This is not a toy pattern. I have shipped full-stack Nuxt applications handling thousands of requests per day with Nitro handling all the backend logic. The developer experience is excellent and the performance is solid.
The server/ Directory Structure
Nitro uses file-based routing that mirrors your API structure:
server/
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
middleware/
auth.ts → Runs on every request
cors.ts
utils/
prisma.ts → Shared utilities
auth.ts
routes/
health.get.ts → GET /health (non-api routes)
The HTTP method is part of the filename. users.get.ts handles GET, users.post.ts handles POST. You can use index.ts (handles all methods) for cases where you want to handle routing manually.
Your First API Route
// server/api/users/index.get.ts
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page ?? 1)
const limit = Number(query.limit ?? 20)
const skip = (page - 1) * limit
const [users, total] = await Promise.all([
prisma.user.findMany({
skip,
take: limit,
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
}),
prisma.user.count(),
])
return {
data: users,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
}
})
Nitro handles JSON serialization automatically. Throw a typed error for error cases:
throw createError({
statusCode: 404,
statusMessage: 'User not found',
data: { userId: id },
})
Reading Request Data
// server/api/users/index.post.ts
export default defineEventHandler(async (event) => {
// Read and parse JSON body
const body = await readBody(event)
// Read query parameters
const query = getQuery(event)
// Read route parameters (from [id].get.ts)
const params = getRouterParams(event)
const userId = params.id
// Read specific headers
const authHeader = getHeader(event, 'authorization')
// Read cookies
const sessionToken = getCookie(event, 'session')
})
Input Validation With Zod
Never trust client-provided data. Validate every input with Zod:
// server/api/users/index.post.ts
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8).max(100),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const parsed = createUserSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation failed',
data: parsed.error.flatten(),
})
}
const { name, email, password, role } = parsed.data
// ... create user
})
Create a reusable validation utility:
// server/utils/validate.ts
import { z, ZodSchema } from 'zod'
export async function validate<T>(event: H3Event, schema: ZodSchema<T>): Promise<T> {
const body = await readBody(event)
const parsed = schema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation failed',
data: parsed.error.flatten(),
})
}
return parsed.data
}
Now your route handlers stay clean:
export default defineEventHandler(async (event) => {
const data = await validate(event, createUserSchema)
// data is fully typed
})
Server Middleware
Server middleware in server/middleware/ runs before every request. Use it for CORS, authentication, logging, and request context setup:
// server/middleware/cors.ts
export default defineEventHandler((event) => {
setResponseHeaders(event, {
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN ?? '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
})
if (getMethod(event) === 'OPTIONS') {
event.node.res.statusCode = 204
return 'OK'
}
})
// server/middleware/logger.ts
export default defineEventHandler((event) => {
const start = Date.now()
const url = getRequestURL(event)
// After response
event.node.res.on('finish', () => {
const duration = Date.now() - start
console.log(`${getMethod(event)} ${url.pathname} ${event.node.res.statusCode} ${duration}ms`)
})
})
Database Integration
Initialize Prisma as a singleton to avoid connection pool exhaustion:
// server/utils/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
In development, this prevents creating a new Prisma client on every hot reload, which would exhaust your database connection limit quickly.
Shared TypeScript Types
The big win of full-stack Nuxt is sharing types between frontend and backend. Define your API response types once:
// types/api.ts
export interface User {
id: string
name: string
email: string
role: 'admin' | 'editor' | 'viewer'
createdAt: string
}
export interface PaginatedResponse<T> {
data: T[]
pagination: {
page: number
limit: number
total: number
pages: number
}
}
export interface ApiError {
statusCode: number
statusMessage: string
data?: unknown
}
Your API routes return these types and your frontend composables consume them — with full TypeScript inference end-to-end.
Rate Limiting
Add rate limiting to protect your endpoints:
// server/utils/rateLimit.ts
const requests = new Map<string, { count: number; resetAt: number }>()
export function rateLimit(ip: string, limit = 100, windowMs = 60000) {
const now = Date.now()
const record = requests.get(ip)
if (!record || record.resetAt < now) {
requests.set(ip, { count: 1, resetAt: now + windowMs })
return true
}
if (record.count >= limit) {
return false
}
record.count++
return true
}
// In your API route
export default defineEventHandler(async (event) => {
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
if (!rateLimit(ip, 100, 60000)) {
throw createError({ statusCode: 429, statusMessage: 'Too Many Requests' })
}
// ... handle request
})
Caching Responses
Nitro has built-in caching utilities for expensive operations:
// server/api/stats.get.ts
export default defineCachedEventHandler(
async (event) => {
// This function runs at most once per minute
const stats = await computeExpensiveStats()
return stats
},
{
maxAge: 60, // seconds
name: 'site-stats',
group: 'api',
}
)
For fine-grained cache control, use useStorage:
const cache = useStorage('cache')
const cached = await cache.getItem('my-key')
if (cached) return cached
const fresh = await fetchFreshData()
await cache.setItem('my-key', fresh, { ttl: 300 })
return fresh
Testing Your API Routes
Use Vitest with @nuxt/test-utils for API route tests:
import { describe, it, expect } from 'vitest'
import { setup, $fetch } from '@nuxt/test-utils'
describe('Users API', async () => {
await setup({ server: true })
it('returns paginated users', async () => {
const result = await $fetch('/api/users')
expect(result).toHaveProperty('data')
expect(result).toHaveProperty('pagination')
expect(Array.isArray(result.data)).toBe(true)
})
it('returns 422 for invalid user creation', async () => {
await expect(
$fetch('/api/users', {
method: 'POST',
body: { email: 'not-an-email' },
})
).rejects.toMatchObject({ status: 422 })
})
})
Nitro makes building a backend alongside your Nuxt frontend genuinely pleasant. The file-based routing is intuitive, the TypeScript integration is excellent, and the deployment story is clean — one codebase, one build, one deployment. For projects that do not need a separate dedicated backend, this is a compelling architecture.
Building a full-stack Nuxt application and want help designing your API architecture or database schema? Let's talk through it: calendly.com/jamesrossjr.