Black Friday: 50% off all products for the first 50 customers. Only 10 left. Use code
40/50 claimed
See pricing →
November 9, 2026

Nuxt Server Routes: Build an API in Your App

Nuxt server routes step by step: a CRUD API in server/api with Zod validation, proper status codes and errors, consumed by a Quasar QTable on the page.
Nuxt
Nitro
API
Quasar

Nuxt server routes let you write your app's API in the same project as its pages: drop a file into server/api/, export a handler, and Nuxt serves it as an endpoint. No separate Express app, no CORS setup, and useFetch on the page knows the response type. This post builds a small but complete CRUD API for tasks, with validation, status codes and errors, and then shows it in a Quasar q-table. It targets Nuxt 4 and the h3 1.x helpers that Nitro ships with.

How Nuxt server routes map to URLs

Nuxt scans server/ and turns file paths into routes. The rules, from the Nuxt 4 docs:

FileRoute
server/api/tasks/index.get.tsGET /api/tasks
server/api/tasks/index.post.tsPOST /api/tasks
server/api/tasks/[id].patch.tsPATCH /api/tasks/:id
server/api/tasks/[id].delete.tsDELETE /api/tasks/:id
server/api/files/[...path].tsanything under /api/files/
server/routes/health.tsGET /health, no /api prefix
  • Files in server/api/ get the /api prefix. Files in server/routes/ don't, which suits things like /sitemap.xml or /robots.txt.
  • A method suffix (.get, .post, .patch, .delete) limits the file to that method. Other methods get a 405.
  • index stands for the folder itself, and [id] is a dynamic segment.

In a Nuxt 4 app, server/ sits at the project root, next to app/ and shared/.

The data layer in server/utils

Files in server/utils/ are auto-imported into every server file, so the handlers below don't need import lines for these helpers. The store is an in-memory Map to keep the example short. It resets when the server restarts, and each serverless instance gets its own copy, so swap it for a database before you ship.

// server/utils/tasks.ts
import { z } from 'zod'

export const taskInput = z.object({
  title: z.string().trim().min(1, 'Title is required').max(120),
  done: z.boolean().default(false),
})

export const taskPatch = z.object({
  title: z.string().trim().min(1).max(120).optional(),
  done: z.boolean().optional(),
})

export const taskIdParams = z.object({ id: z.uuid() })

export const taskListQuery = z.object({
  done: z.enum(['true', 'false']).transform(value => value === 'true').optional(),
})

export interface Task {
  id: string
  title: string
  done: boolean
  createdAt: string
}

const tasks = new Map<string, Task>()

export function listTasks(done?: boolean) {
  const all = [...tasks.values()]
  return done === undefined ? all : all.filter(task => task.done === done)
}

export function createTask(input: z.infer<typeof taskInput>) {
  const task: Task = { id: crypto.randomUUID(), ...input, createdAt: new Date().toISOString() }
  tasks.set(task.id, task)
  return task
}

export function updateTask(id: string, patch: z.infer<typeof taskPatch>) {
  const current = tasks.get(id)
  if (!current) return undefined
  const task = { ...current, ...patch }
  tasks.set(id, task)
  return task
}

export function deleteTask(id: string) {
  return tasks.delete(id)
}

Install Zod with pnpm add zod. The examples use Zod 4, where z.uuid() is a top-level schema.

taskPatch is a separate schema on purpose, not taskInput.partial(). It has no defaults, so a PATCH with only a title can't quietly reset done to false.

Validate input with readValidatedBody

h3 has three validated readers: readValidatedBody, getValidatedQuery and getValidatedRouterParams. Each takes the event and a function that throws on bad input, which is exactly what a Zod schema's parse is. When it throws, h3 answers with a 400 and the status message Validation Error, with the Zod error in data. You don't write that branch yourself.

GET /api/tasks

// server/api/tasks/index.get.ts
export default defineEventHandler(async (event) => {
  const { done } = await getValidatedQuery(event, taskListQuery.parse)
  return listTasks(done)
})

Query values always arrive as strings, so the schema accepts 'true' or 'false' and turns them into a boolean. ?done=yes gets a 400 instead of an empty list that hides the bug.

POST /api/tasks

// server/api/tasks/index.post.ts
export default defineEventHandler(async (event) => {
  const input = await readValidatedBody(event, taskInput.parse)
  setResponseStatus(event, 201)
  return createTask(input)
})

Whatever the handler returns is sent as JSON. setResponseStatus changes the 200 to a 201 Created. The schema also trims the title, so " Write post " is stored as "Write post".

PATCH /api/tasks/:id

// server/api/tasks/[id].patch.ts
export default defineEventHandler(async (event) => {
  const { id } = await getValidatedRouterParams(event, taskIdParams.parse)
  const patch = await readValidatedBody(event, taskPatch.parse)
  const task = updateTask(id, patch)
  if (!task) {
    throw createError({ statusCode: 404, statusMessage: 'Task not found' })
  }
  return task
})

For a plain param without validation, getRouterParam(event, 'id') returns the string. Validating it as a UUID means /api/tasks/abc is a 400, not a lookup that can never match.

DELETE /api/tasks/:id

// server/api/tasks/[id].delete.ts
export default defineEventHandler(async (event) => {
  const { id } = await getValidatedRouterParams(event, taskIdParams.parse)
  if (!deleteTask(id)) {
    throw createError({ statusCode: 404, statusMessage: 'Task not found' })
  }
  return null
})

In h3 1.x, returning null sends a 204 No Content. Returning undefined is not the same thing, so be explicit.

Errors and status codes

  • createError sets the status and message. Thrown from a handler, it becomes a JSON error response. An error you don't catch becomes a 500.
  • Keep the messages generic. statusMessage is sent to the client. Write "Task not found", not the SQL that failed.
  • setResponseStatus(event, code) is for successful responses that aren't 200, like 201 or 202.

The status codes from the four handlers, which I checked by running them against h3 1.15:

RequestResponse
POST with a title201 and the task
POST with an empty title400 Validation Error
PATCH an unknown id404 Task not found
PATCH /api/tasks/abc400 Validation Error
DELETE an existing task204, empty body

Use the API from a page with QTable

On the page, useFetch loads the list during server-side rendering and infers its type from the handler. Mutations use $fetch, because they run on a click, not during render. The useFetch vs useAsyncData vs $fetch guide explains that split in detail.

<!-- app/pages/tasks.vue -->
<script setup lang="ts">
import type { QTableColumn } from 'quasar'

const show = ref<'all' | 'open' | 'done'>('all')
const query = computed(() => (show.value === 'all' ? {} : { done: String(show.value === 'done') }))

const { data: tasks, refresh, status } = await useFetch('/api/tasks', {
  query,
  default: () => [],
})

const columns: QTableColumn[] = [
  { name: 'done', label: 'Done', field: 'done', align: 'left' },
  { name: 'title', label: 'Task', field: 'title', align: 'left', sortable: true },
  { name: 'actions', label: '', field: 'id', align: 'right' },
]

const title = ref('')
const saving = ref(false)
const saveError = ref('')

async function addTask() {
  saving.value = true
  saveError.value = ''
  try {
    await $fetch('/api/tasks', { method: 'POST', body: { title: title.value } })
    title.value = ''
    await refresh()
  } catch {
    saveError.value = 'Could not save the task. Check the title and try again.'
  } finally {
    saving.value = false
  }
}

async function setDone(id: string, done: boolean) {
  await $fetch(`/api/tasks/${id}`, { method: 'PATCH', body: { done } })
  await refresh()
}

async function removeTask(id: string) {
  await $fetch(`/api/tasks/${id}`, { method: 'DELETE' })
  await refresh()
}
</script>

<template>
  <q-page padding>
    <q-form class="row q-col-gutter-sm items-start q-mb-md" @submit="addTask">
      <q-input
        v-model="title"
        class="col"
        outlined
        dense
        label="New task"
        :error="!!saveError"
        :error-message="saveError"
        :rules="[value => !!value.trim() || 'Title is required']"
      />
      <div>
        <q-btn type="submit" unelevated no-caps color="primary" label="Add" :loading="saving" />
      </div>
    </q-form>

    <q-btn-toggle
      v-model="show"
      no-caps
      unelevated
      toggle-color="primary"
      class="q-mb-md"
      :options="[
        { label: 'All', value: 'all' },
        { label: 'Open', value: 'open' },
        { label: 'Done', value: 'done' },
      ]"
    />

    <q-table
      flat
      bordered
      row-key="id"
      :rows="tasks"
      :columns="columns"
      :loading="status === 'pending'"
      no-data-label="No tasks yet"
    >
      <template #body-cell-done="props">
        <q-td :props="props">
          <q-checkbox
            :model-value="props.row.done"
            :aria-label="`Mark ${props.row.title} as done`"
            @update:model-value="value => setDone(props.row.id, value)"
          />
        </q-td>
      </template>

      <template #body-cell-actions="props">
        <q-td :props="props">
          <q-btn flat round dense icon="delete" aria-label="Delete task" @click="removeTask(props.row.id)" />
        </q-td>
      </template>
    </q-table>
  </q-page>
</template>

What to notice:

  • The filter is a computed query. The Nuxt docs say a ref or computed fetch option is watched, and a change triggers a refetch. Clicking Open requests /api/tasks?done=false with no extra code.
  • Client rules and server validation together. The q-input rule gives instant feedback. The Zod schema still guards the API, because anything can call it.
  • refresh() after each change. It reloads the list with the current query. For a big list, update tasks.value locally instead of refetching.

Sorting, searching and pagination on the table side are in the Quasar QTable tutorial. Once the list grows, move pagination to the server: read page and rowsPerPage with getValidatedQuery and use QTable's @request event.

Calling your own API from the server

During server-side rendering, useFetch('/api/tasks') calls the handler directly without an HTTP round trip. Inside a server route, use event.$fetch to call another route of your app. Per the Nuxt docs, it forwards the incoming request's context and headers, like the cookie, while a plain $fetch doesn't. Some headers, such as host, are never forwarded.

Secrets belong in runtime config. Read them in a handler with useRuntimeConfig(event). Passing event lets environment variables override the config at runtime.

Common questions about Nuxt server routes

server/api or server/routes? Use server/api for JSON endpoints. Use server/routes when the URL must not start with /api, such as /sitemap.xml.

How do I read the request body in a GET handler? You don't. readBody in a GET handler throws a 405. Use query parameters.

Can a component import from server/utils? No. The Nuxt docs say not to import server code into the app, or app code into the server. Put shared types and pure functions in shared/.

Next step

An API like this usually feeds an admin screen. The QuasarUI Templates are four complete Quasar admin dashboards with light and dark mode, including data tables, task management and kanban pages. Wire their pages to routes like the ones above instead of building the screens from scratch.