Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 036 / 100

Nuxt server routes: Vue & Nuxt best practices explained

Build APIs inside your Nuxt app.

Expona Admin7 min read

Introduction

By day 36 of working with Nuxt, you've built components, managed state, and navigated between pages. Now it's time to complete the full-stack picture: Nuxt server routes: Vue & Nuxt best practices explained. Instead of spinning up a separate Express or Node server, you can build entire APIs inside your Nuxt application using Nitro, Nuxt's powerful server engine.

This approach collapses deployment complexity and keeps your frontend and backend code in the same repository. Whether you're fetching product data, handling authentication, or processing form submissions, server routes give you a structured way to handle backend logic without leaving the Nuxt ecosystem.

Understanding server/api — Files become API endpoints

Nuxt uses a file-based routing system for server routes, much like the page-based routing you already know. Any file you create in the server/api directory automatically becomes an HTTP endpoint. The file path maps directly to the URL.

For example, a file at server/api/products/[id].ts becomes an endpoint at /api/products/[id]. When a client makes a GET request to /api/products/123, Nuxt matches that dynamic segment and passes it to your handler.

Here's a real-world scenario: you're building an e-commerce product page. On the client, you need to fetch product details. Instead of hitting an external API, you create a server route that queries your database:

typescript
// server/api/products/[id].ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  
  if (!id) {
    throw createError({ statusCode: 400, statusMessage: 'ID required' })
  }

  try {
    const product = await db.product.findUnique({ where: { id } })
    
    if (!product) {
      throw createError({ statusCode: 404, statusMessage: 'Product not found' })
    }
    
    return product
  } catch (error) {
    throw createError({ statusCode: 500, statusMessage: 'Database error' })
  }
})

On the client side, you fetch this data just like any other API:

typescript
// app.vue or a component
const { data: product } = await useFetch(`/api/products/${productId}`)

The key insight here is that defineEventHandler receives an event object containing the request. From it, you extract route parameters, read the body, set headers, and return data. Nuxt automatically serializes your response to JSON.

For POST requests, you handle form data or JSON payloads. For example, a route that creates a new product:

typescript
// server/api/products.ts
export default defineEventHandler(async (event) => {
  if (event.method === 'POST') {
    const body = await readBody(event)
    const product = await db.product.create({ data: body })
    return product
  }
  
  if (event.method === 'GET') {
    return await db.product.findMany()
  }
})

This single handler responds to both GET and POST requests, keeping related logic in one place. The readBody utility parses the incoming request body automatically.

Validation — readValidatedBody with Zod

Never trust client input. Even if your frontend has validation, a determined user or a rogue script can send malformed data. Server-side validation is non-negotiable, and Nuxt makes it straightforward with Zod integration.

Zod is a TypeScript-first schema validation library. Define your expected data shape, and Zod will validate incoming requests against it. If validation fails, you throw an error; if it passes, you get typed data.

Let's say you're building a user registration endpoint. You need to validate the email format, password strength, and that both match:

typescript
// server/api/auth/register.ts
import { z } from 'zod'

const registrationSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword']
})

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, (body) =>
    registrationSchema.parseAsync(body)
  )

  // At this point, body is guaranteed to match the schema
  const user = await db.user.create({
    data: {
      email: body.email,
      password: await hashPassword(body.password)
    }
  })

  return { userId: user.id, email: user.email }
})

If the client sends invalid data—say, a password with only 6 characters—readValidatedBody throws a validation error automatically. Nuxt catches it and returns a 400 response with error details. Your handler only runs if the data is valid.

This pattern prevents bugs, reduces boilerplate error checking, and gives you type safety. TypeScript knows the shape of body, so autocomplete works perfectly.

Middleware — server/middleware for auth and logging

As your API grows, you'll need cross-cutting concerns: authentication checks, request logging, rate limiting, CORS headers. Rather than repeating this logic in every route, use server middleware.

Middleware runs before your route handlers. Files in server/middleware execute in alphabetical order (use a numeric prefix like 01-auth.ts to control order). Middleware can inspect the request, modify the event object, throw errors, or pass through.

A common use case is protecting API routes. Check for a valid session or JWT token before allowing access:

typescript
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
  // Skip auth for public routes
  if (event.node.req.url?.startsWith('/api/auth/')) {
    return
  }

  const token = getCookie(event, 'auth-token')
  
  if (!token) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
  }

  try {
    const decoded = verifyJWT(token)
    event.context.user = decoded
  } catch (error) {
    throw createError({ statusCode: 401, statusMessage: 'Invalid token' })
  }
})

Now any route handler can access event.context.user safely, knowing the user is authenticated:

typescript
// server/api/me.ts
export default defineEventHandler(async (event) => {
  const user = event.context.user
  return { id: user.id, email: user.email }
})

Another powerful middleware pattern is logging. Track request timing, method, and path for debugging and monitoring:

typescript
// server/middleware/logging.ts
export default defineEventHandler((event) => {
  const start = Date.now()
  
  event.node.res.on('finish', () => {
    const duration = Date.now() - start
    console.log(`${event.method} ${event.node.req.url} ${event.node.res.statusCode} ${duration}ms`)
  })
})

Middleware keeps your route handlers clean and focused on business logic, while infrastructure concerns live separately.

Nitro — The server engine deploys anywhere

Under the hood, Nuxt's server routes are powered by Nitro, a lightweight server framework designed for edge deployment. This is important because it means your Nuxt app isn't locked into a traditional Node.js server—it can run on serverless platforms, edge networks, or Docker containers.

When you deploy a Nuxt app with server routes, Nitro handles the compilation and bundling. It strips unused code, optimizes imports, and generates a production-ready bundle. The same code works on Vercel, Netlify, AWS Lambda, or a self-hosted Linux box.

You configure Nitro behavior in nuxt.config.ts:

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    prerender: {
      crawlLinks: true,
      routes: ['/sitemap.xml', '/rss.xml'],
      ignore: ['/admin']
    },
    storage: {
      db: {
        driver: 'fs',
        base: './data'
      }
    }
  }
})

In production, you can use Nitro's database abstraction layer to store session data, cache results, or manage temporary files. This abstraction works across different deployment targets—the same code runs on Vercel's KV store or a self-hosted Redis instance.

The deployment story is simple: build once, deploy everywhere. No need to maintain separate server code. Your entire full-stack app—components, pages, API routes—ships as a single unit.

Common mistakes

Forgetting to validate input: It's tempting to skip Zod and just use the raw body. Resist this. Always validate. A single mistake costs hours debugging a data corruption bug later.

Blocking operations in handlers: If you perform a slow synchronous operation (like reading a large file from disk), your entire server thread blocks. Use async/await for all I/O and consider offloading heavy work to background jobs.

Exposing sensitive logic to the client: Remember: anything in server/ stays on the server. Don't accidentally move API keys, database queries, or business logic to client-side composables. Keep that separation strict.

Ignoring CORS: If your frontend and API have different origins (common in development), you'll hit CORS errors. Nuxt handles same-origin requests automatically, but cross-origin calls need explicit headers. Use the h3 library's CORS utilities or add middleware.

Not testing server routes: Server routes are regular functions. Test them like any other backend code. Write unit tests that call defineEventHandler with a mock event, and integration tests that hit the endpoints via HTTP.

Key takeaways

  • Files in server/api automatically become HTTP endpoints; the file path maps to the URL.
  • Use defineEventHandler to wrap your handler logic and access the event object for request details.
  • Always validate input with readValidatedBody and Zod before processing data.
  • Server middleware runs before handlers and is perfect for auth, logging, and cross-cutting concerns.
  • Nitro compiles and optimizes your server code for deployment on any platform—serverless, edge, or traditional servers.
  • Keep server routes focused; let middleware handle infrastructure concerns.
  • Test your server routes as you would any backend code to catch bugs early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles