Skip to main content
Engineering7 min readMarch 3, 2026

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.

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.


Keep Reading