3 min read
On this page

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.ts handles all HTTP methods. If you want GET-only, name it users.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. Use event.context and Nitro utilities instead.

Key Takeaways

  • server/api/ maps files to API endpoints the same way pages/ maps to frontend routes. HTTP method suffixes (.get.ts, .post.ts) control which verbs a handler responds to.
  • defineEventHandler is the foundation. Use readBody, getQuery, and getRouterParam to 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 sets event.context for 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.