Server Routes & API
Nuxt 3 includes a built-in server engine (Nitro) that turns the server/ directory into a full backend. API endpoints, server middleware, and utility functions all live alongside the frontend code. This makes Nuxt a full-stack framework: the same project serves the Vue frontend and handles API requests, database queries, and server-side logic.
The server/api/ Directory
Files inside server/api/ become API endpoints. The file path maps to the URL path, similar to how pages/ maps to frontend routes.
server/
api/
hello.ts -> GET /api/hello
users/
index.ts -> GET /api/users
[id].ts -> GET /api/users/:id
Basic Endpoint
// server/api/hello.ts
export default defineEventHandler(() => {
return { message: 'Hello from the server' }
})
GET /api/hello
Response: { "message": "Hello from the server" }
Every file exports a default defineEventHandler. The return value is automatically serialized to JSON.
HTTP Methods
Suffix the filename with the HTTP method to handle specific verbs:
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
// server/api/users/index.get.ts
export default defineEventHandler(async () => {
const users = await db.users.findMany()
return users
})
// server/api/users/index.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const user = await db.users.create({ data: body })
return user
})
Reading Request Data
Nitro provides utilities to extract data from incoming requests.
Route Parameters
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const user = await db.users.findById(id)
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
return user
})
Request Body
// server/api/posts/index.post.ts
interface CreatePostBody {
title: string
content: string
published: boolean
}
export default defineEventHandler(async (event) => {
const body = await readBody<CreatePostBody>(event)
if (!body.title || !body.content) {
throw createError({
statusCode: 400,
message: 'Title and content are required'
})
}
const post = await db.posts.create({
data: {
title: body.title,
content: body.content,
published: body.published ?? false,
createdAt: new Date()
}
})
return post
})
Query Parameters
// server/api/products/index.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const category = query.category as string | undefined
const where = category ? { category } : {}
const products = await db.products.findMany({
where,
skip: (page - 1) * limit,
take: limit
})
const total = await db.products.count({ where })
return {
data: products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
}
})
Request from the frontend:
<script setup lang="ts">
const page = ref(1)
const { data } = await useFetch('/api/products', {
query: { page, limit: 20, category: 'electronics' }
})
</script>
Returning JSON
Event handlers return plain objects, arrays, strings, or numbers. Nitro serializes them to JSON automatically and sets the Content-Type: application/json header:
// Returning an object
export default defineEventHandler(() => {
return { status: 'ok', timestamp: Date.now() }
})
// Returning an array
export default defineEventHandler(() => {
return [1, 2, 3]
})
For custom status codes or headers:
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const user = await db.users.create({ data: body })
setResponseStatus(event, 201)
return user
})
Connecting to Databases
Server routes run in a Node.js environment with full access to npm packages. Here is a practical example with Drizzle ORM and PostgreSQL:
// server/utils/db.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from '../database/schema'
const pool = new Pool({
connectionString: process.env.DATABASE_URL
})
export const db = drizzle(pool, { schema })
// server/api/tasks/index.get.ts
import { db } from '~/server/utils/db'
import { tasks } from '~/server/database/schema'
import { eq } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const status = query.status as string | undefined
if (status) {
return db.select().from(tasks).where(eq(tasks.status, status))
}
return db.select().from(tasks)
})
// server/api/tasks/index.post.ts
import { db } from '~/server/utils/db'
import { tasks } from '~/server/database/schema'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const [task] = await db.insert(tasks).values({
title: body.title,
description: body.description,
status: 'pending'
}).returning()
setResponseStatus(event, 201)
return task
})
Files in server/utils/ are auto-imported throughout the server directory, so db is available without explicit imports in other server files.
Server Middleware
Server middleware runs on every server request before route handlers. It is used for logging, authentication, CORS headers, and other cross-cutting concerns.
// server/middleware/log.ts
export default defineEventHandler((event) => {
const method = event.method
const url = getRequestURL(event)
console.log(`[${method}] ${url.pathname}`)
// Do not return anything -- the request continues to the route handler
})
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
const protectedPaths = ['/api/admin', '/api/account']
const path = getRequestURL(event).pathname
const isProtected = protectedPaths.some((p) => path.startsWith(p))
if (!isProtected) return
const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
if (!token) {
throw createError({ statusCode: 401, message: 'Authentication required' })
}
try {
const session = await verifyToken(token)
event.context.user = session.user
} catch {
throw createError({ statusCode: 401, message: 'Invalid token' })
}
})
Key differences from route middleware (in middleware/):
- Server middleware runs on the server only, for every HTTP request.
- Route middleware (in the top-level
middleware/directory) runs in the Vue app context for page navigations. - Server middleware modifies
event.context, which is available in subsequent handlers.
Accessing Context in Route Handlers
// server/api/account/profile.get.ts
export default defineEventHandler((event) => {
const user = event.context.user
// user was set by server/middleware/auth.ts
return { id: user.id, email: user.email, name: user.name }
})
The Full-Stack Pattern
Nuxt as a full-stack framework means one project, one deployment, and one type system shared between frontend and backend:
<!-- pages/tasks.vue -->
<script setup lang="ts">
interface Task {
id: number
title: string
status: string
}
const { data: tasks, refresh } = await useFetch<Task[]>('/api/tasks')
async function addTask(title: string) {
await $fetch('/api/tasks', {
method: 'POST',
body: { title, description: '' }
})
await refresh()
}
async function deleteTask(id: number) {
await $fetch(`/api/tasks/${id}`, { method: 'DELETE' })
await refresh()
}
</script>
<template>
<div>
<h1>Tasks</h1>
<form @submit.prevent="addTask($event.target.title.value)">
<input name="title" placeholder="New task" required />
<button type="submit">Add</button>
</form>
<ul>
<li v-for="task in tasks" :key="task.id">
{{ task.title }} ({{ task.status }})
<button @click="deleteTask(task.id)">Delete</button>
</li>
</ul>
</div>
</template>
The frontend calls /api/tasks which resolves to server/api/tasks/index.get.ts in the same project. No separate backend server, no CORS configuration, no separate deployment pipeline.
Common Pitfalls
- Returning undefined from middleware. Server middleware should not return a value unless it is terminating the request. Returning an object from middleware sends it as the response and skips the route handler entirely.
- Not validating request bodies. Never trust client input. Always validate body contents and return 400 errors for malformed requests. Use a validation library like Zod for structured validation.
- Exposing database errors. Catching database exceptions and returning their raw messages leaks implementation details. Wrap database calls in try/catch and return generic error messages.
- Forgetting HTTP method suffixes. A file named
users.tshandles all HTTP methods. If you want GET-only, name itusers.get.ts. An unsuffixed file receiving a POST request will still execute. - Blocking the event loop. Server handlers run in Node.js. CPU-intensive synchronous operations (large JSON parsing, image processing) block all other requests. Offload heavy work to worker threads or external services.
- Accessing Vue composables in server code. Server routes have no Vue context.
useState,useRoute, and other Vue composables do not exist on the server. Useevent.contextand Nitro utilities instead.
Key Takeaways
server/api/maps files to API endpoints the same waypages/maps to frontend routes. HTTP method suffixes (.get.ts,.post.ts) control which verbs a handler responds to.defineEventHandleris the foundation. UsereadBody,getQuery, andgetRouterParamto extract request data. Return objects for automatic JSON serialization.- Server middleware in
server/middleware/runs on every request and is used for logging, auth, and cross-cutting concerns. It setsevent.contextfor downstream handlers. - Files in
server/utils/are auto-imported across all server code, making them ideal for database connections and shared helpers. - The full-stack pattern keeps frontend and backend in one project with shared TypeScript types, no CORS, and a single deployment.