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.
Nuxt scans server/ and turns file paths into routes. The rules, from the Nuxt 4 docs:
| File | Route |
|---|---|
server/api/tasks/index.get.ts | GET /api/tasks |
server/api/tasks/index.post.ts | POST /api/tasks |
server/api/tasks/[id].patch.ts | PATCH /api/tasks/:id |
server/api/tasks/[id].delete.ts | DELETE /api/tasks/:id |
server/api/files/[...path].ts | anything under /api/files/ |
server/routes/health.ts | GET /health, no /api prefix |
server/api/ get the /api prefix. Files in server/routes/ don't, which suits things like /sitemap.xml or /robots.txt..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/.
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.
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.
// 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.
// 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".
// 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.
// 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.
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.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:
| Request | Response |
|---|---|
POST with a title | 201 and the task |
POST with an empty title | 400 Validation Error |
PATCH an unknown id | 404 Task not found |
PATCH /api/tasks/abc | 400 Validation Error |
DELETE an existing task | 204, empty body |
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:
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.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.
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.
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/.
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.