Skip to main content
Technology & EngineeringVue290 lines

Nuxt Server

Nuxt 3 server routes, API endpoints, server middleware, and Nitro engine patterns for full-stack development

Quick Summary36 lines
You are an expert in Nuxt 3 server-side development using Nitro, covering API routes, server middleware, database access, and full-stack patterns.

## Key Points

- **`server/api/`** — files here become API endpoints at `/api/...`
- **`server/routes/`** — files here become routes without the `/api` prefix
- **`server/middleware/`** — server middleware that runs on every request before route handlers
- **`server/utils/`** — auto-imported server-only utilities
- **`server/plugins/`** — Nitro plugins that run at server startup
- **Event handler** — every route exports a function wrapped in `defineEventHandler`
1. **Use method suffixes** — name files `users.get.ts`, `users.post.ts` to split HTTP methods into separate files for clarity.
2. **Validate all input** — use `readValidatedBody` with Zod or a similar library. Never trust client input.
3. **Use `useRuntimeConfig`** — never import `process.env` directly in server routes. Runtime config is type-safe and works across deployment targets.
4. **Put shared logic in `server/utils/`** — auto-imported functions keep route handlers thin.
5. **Use `createError` for HTTP errors** — it sets status codes and messages properly for both SSR and API responses.
6. **Separate API concerns** — `server/api/` for JSON APIs, `server/routes/` for HTML or redirect endpoints, `server/middleware/` for cross-cutting concerns.

## Quick Example

```ts
// server/api/hello.get.ts
// GET /api/hello
export default defineEventHandler((event) => {
  return { message: 'Hello from the server' }
})
```

```ts
// server/api/users.get.ts     → GET /api/users
// server/api/users.post.ts    → POST /api/users
// server/api/users/[id].get.ts    → GET /api/users/:id
// server/api/users/[id].put.ts    → PUT /api/users/:id
// server/api/users/[id].delete.ts → DELETE /api/users/:id
```
skilldb get vue-skills/Nuxt ServerFull skill: 290 lines
Paste into your CLAUDE.md or agent config

Nuxt Server Routes and API — Vue.js

You are an expert in Nuxt 3 server-side development using Nitro, covering API routes, server middleware, database access, and full-stack patterns.

Core Philosophy

Nuxt server routes turn a frontend framework into a full-stack platform by embedding Nitro, a universal server engine, directly into the development and build pipeline. The core idea is co-location: your API lives alongside your pages, shares the same TypeScript types, and deploys as a single unit. This eliminates the friction of maintaining a separate backend project for applications whose server needs are primarily data fetching, validation, and authentication.

Every server route is a pure function wrapped in defineEventHandler. This functional design is intentional — event handlers receive an event object, process it, and return data. They have no implicit global state, no class hierarchies, and no middleware chains you cannot see. The file-based routing with method suffixes (.get.ts, .post.ts) makes the API surface self-documenting: the directory structure is the API documentation.

The separation between server/api/, server/routes/, server/middleware/, and server/utils/ reflects a clean architectural boundary. API endpoints return JSON, route handlers can return anything, middleware runs cross-cutting concerns on every request, and utilities provide shared server-only logic. Keeping these responsibilities distinct prevents the server directory from becoming an unstructured collection of handler files.

Anti-Patterns

  • Importing Server Code in Client Components — importing from server/ directly in a .vue file, which breaks the build because server code is not bundled for the client. Always communicate with the server through $fetch or useFetch.

  • Trusting Client Input Without Validation — reading readBody or getQuery and using the values directly without schema validation. Always use readValidatedBody with Zod or equivalent to enforce types at runtime.

  • Secrets in runtimeConfig.public — accidentally placing database URLs, API keys, or JWT secrets under the public key, which exposes them to every client browser. Server-only secrets belong at the top level of runtimeConfig.

  • Throwing in Server Middleware — having authentication middleware throw createError(401) for unauthenticated users, which blocks all public routes. Middleware should set event.context and let individual route handlers decide whether authentication is required.

  • Catch-All Route Handlers Without Method Suffixes — creating a users.ts file without a method suffix, which silently handles GET, POST, PUT, DELETE, and every other HTTP method, making it easy to expose unintended endpoints.

Overview

Nuxt 3 includes Nitro, a server engine that provides file-based API routes in the server/ directory. Server routes run on the server only, support all HTTP methods, and can be deployed to Node.js, edge workers, serverless functions, and more. They are the primary way to build backend logic in a Nuxt application.

Core Concepts

  • server/api/ — files here become API endpoints at /api/...
  • server/routes/ — files here become routes without the /api prefix
  • server/middleware/ — server middleware that runs on every request before route handlers
  • server/utils/ — auto-imported server-only utilities
  • server/plugins/ — Nitro plugins that run at server startup
  • Event handler — every route exports a function wrapped in defineEventHandler

Implementation Patterns

Basic API Route

// server/api/hello.get.ts
// GET /api/hello
export default defineEventHandler((event) => {
  return { message: 'Hello from the server' }
})

HTTP Method Suffixes

// server/api/users.get.ts     → GET /api/users
// server/api/users.post.ts    → POST /api/users
// server/api/users/[id].get.ts    → GET /api/users/:id
// server/api/users/[id].put.ts    → PUT /api/users/:id
// server/api/users/[id].delete.ts → DELETE /api/users/:id

Reading Request Data

// server/api/users.post.ts
export default defineEventHandler(async (event) => {
  // Read JSON body
  const body = await readBody<{ name: string; email: string }>(event)

  // Read query parameters: /api/users?page=1&limit=10
  const query = getQuery(event)

  // Read route params
  const id = getRouterParam(event, 'id')

  // Read headers
  const authHeader = getHeader(event, 'authorization')

  // Read cookies
  const session = getCookie(event, 'session')

  return { received: body }
})

Input Validation with Zod

// server/api/posts.post.ts
import { z } from 'zod'

const PostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  tags: z.array(z.string()).optional(),
})

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, (raw) =>
    PostSchema.parse(raw)
  )

  // body is fully typed as { title: string; content: string; tags?: string[] }
  const post = await createPost(body)
  return post
})

Error Handling

// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')

  const user = await findUserById(id!)

  if (!user) {
    throw createError({
      statusCode: 404,
      statusMessage: 'User not found',
    })
  }

  return user
})

Server Middleware

// server/middleware/01.auth.ts
// Runs on every server request
export default defineEventHandler((event) => {
  const token = getHeader(event, 'authorization')?.replace('Bearer ', '')

  if (token) {
    try {
      const user = verifyToken(token)
      event.context.auth = { user }
    } catch {
      // Token invalid — don't throw, let route handlers decide
      event.context.auth = null
    }
  }
})
// server/api/protected.get.ts
export default defineEventHandler((event) => {
  if (!event.context.auth) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
  }

  return { secret: 'data', user: event.context.auth.user }
})

Server Utilities (Auto-Imported)

// server/utils/db.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'

let _db: ReturnType<typeof drizzle> | null = null

export function useDb() {
  if (!_db) {
    const pool = new Pool({
      connectionString: useRuntimeConfig().databaseUrl,
    })
    _db = drizzle(pool)
  }
  return _db
}
// server/api/users.get.ts
// useDb is auto-imported from server/utils/
export default defineEventHandler(async () => {
  const db = useDb()
  return db.select().from(users)
})

Runtime Config for Secrets

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server-only keys (not exposed to client)
    databaseUrl: process.env.DATABASE_URL,
    jwtSecret: process.env.JWT_SECRET,

    // Public keys (available on client too)
    public: {
      apiBase: '/api',
    },
  },
})
// server/api/config-example.get.ts
export default defineEventHandler(() => {
  const config = useRuntimeConfig()

  // config.databaseUrl — server only
  // config.public.apiBase — also available on client
  return { status: 'ok' }
})

Server Plugins

// server/plugins/database.ts
// Runs once at server startup
export default defineNitroPlugin(async (nitro) => {
  console.log('Initializing database connection...')

  // Run migrations, seed data, etc.
  await runMigrations()

  nitro.hooks.hook('close', async () => {
    console.log('Closing database connection...')
    await closeDbConnection()
  })
})

Catch-All and Proxy Routes

// server/api/[...path].ts
// Catch-all: proxies to an external API
export default defineEventHandler(async (event) => {
  const path = getRouterParam(event, 'path')
  const target = `https://external-api.com/${path}`

  return proxyRequest(event, target)
})

Calling Server Routes from Client

<script setup lang="ts">
// useFetch auto-handles SSR and client fetching
const { data: users, pending, error, refresh } = await useFetch('/api/users', {
  query: { page: 1, limit: 20 },
})

// $fetch for imperative calls (inside event handlers)
async function createUser(name: string, email: string) {
  const user = await $fetch('/api/users', {
    method: 'POST',
    body: { name, email },
  })
  await refresh()
}
</script>

Best Practices

  1. Use method suffixes — name files users.get.ts, users.post.ts to split HTTP methods into separate files for clarity.
  2. Validate all input — use readValidatedBody with Zod or a similar library. Never trust client input.
  3. Use useRuntimeConfig — never import process.env directly in server routes. Runtime config is type-safe and works across deployment targets.
  4. Put shared logic in server/utils/ — auto-imported functions keep route handlers thin.
  5. Use createError for HTTP errors — it sets status codes and messages properly for both SSR and API responses.
  6. Separate API concernsserver/api/ for JSON APIs, server/routes/ for HTML or redirect endpoints, server/middleware/ for cross-cutting concerns.
  7. Use $fetch in event handlers, useFetch in componentsuseFetch deduplicates and handles SSR hydration; $fetch is for imperative one-off calls.

Common Pitfalls

  • Importing server utils on the client — files in server/ are server-only. Importing them in a component causes build errors. Use $fetch or useFetch to communicate with the server.
  • Missing await on readBodyreadBody is async. Forgetting await gives you a Promise, not the data.
  • Middleware throwing errors for public routes — server middleware runs on every request. If your auth middleware throws for unauthenticated users, it blocks public routes. Set context instead and let route handlers decide.
  • Not using method suffixes — a file named users.ts without a method suffix handles all HTTP methods. This makes it easy to accidentally accept methods you did not intend.
  • CORS issues in development — Nuxt's dev server handles same-origin requests fine, but if your frontend is on a different port, configure CORS in server middleware or use Nitro's routeRules.
  • Leaking secrets via runtimeConfig.public — keys under public are sent to the client. Database URLs, API keys, and secrets must be at the top level of runtimeConfig, not inside public.

Install this skill directly: skilldb add vue-skills

Get CLI access →