Server routes
Write API endpoints in server/api, read params, query strings and bodies safely, answer with the right status, and keep secrets in runtime config.
What you'll learn
When you finish, you can do these.- 1Write an API endpoint as a file in server/api
- 2Read an id and a filter from a request's URL
- 3Check a request body and answer with the right status
- 4Keep a secret such as a webhook URL out of your code
The Corner Shop, the small online shop this track builds, keeps a todo list for its staff: buy oat milk, book the van. The todos are stored on the server, and the shop's todo page asks for them at an address, /api/todos. Code on the server has to answer that address, and it must not trust what arrives: a browser can send an empty form, or a title that is a number.
The Code tab holds that code: the stored todos and three handlers, functions that answer an address and run only on the server. By the end, they list and find todos, refuse a bad one with a clear error, and post new ones to a team chat through a secret address. You need one idea from the lesson Data fetching: a page loads data with useFetch('/api/todos') and sends new data with $fetch, and both calls reach handlers like these.
File names are routes
Files in server/api/ become endpoints under /api, URLs that return data instead of a page. The file name gives the path and, with a suffix, the HTTP method (GET to read, POST to send): todos.get.ts answers GET /api/todos, todos.post.ts answers POST /api/todos, and any other method gets a 405 error. Square brackets mark a part of the path that changes, as they do for pages in File-based routing: server/api/todos/[id].get.ts answers GET /api/todos/2.
Each file's default export is a handler: a function wrapped in defineEventHandler() that receives the request as event. Whatever it returns is sent back as JSON, data written as plain text. Inside server/, Nuxt auto-imports defineEventHandler, the helpers that read the request, and your own functions in server/utils/.
The todos live in server/utils/todos.ts: an array in server memory, reset on every restart, read by listTodos() and findTodo(id) and grown by addTodo(title). The handler for one todo, server/api/todos/[id].get.ts, looks it up by the id in its URL:
export default defineEventHandler((event) => {
const id = Number(getRouterParam(event, 'id'))
const todo = findTodo(id)
if (!todo) {
throw createError({ status: 404, statusText: 'Todo not found' })
}
return todo
})
getRouterParam(event, 'id') reads that id, the route param, as a string, so Number() converts it. /api/todos/2 answers with Book the van. /api/todos/99 finds nothing, so the thrown createError() becomes the response: a 404, the status number for not found, with the text Todo not found.
Before a handler runs, the request passes through server middleware: files in server/middleware/ that run for every request, for example to log it. Route middleware, the checks in app/middleware/, runs only when a page opens, never for an API request.
browser requestserver middlewareevery requestroute middlewarepage navigationshandler or pageresponse
Read the request, answer with the right status
Besides its path, a request can carry a body and a query string, the part of the URL after ?. server/api/todos.get.ts lists every todo, or only the finished ones when the URL ends in ?done=true. getQuery(event) returns the query string as an object of strings, so done is the string 'true', not the value true, and the filter compares strings:
export default defineEventHandler((event) => {
const { done } = getQuery(event)
const todos = listTodos()
if (done === undefined) {
return todos
}
return todos.filter(todo => String(todo.done) === done)
})
String(todo.done) turns each todo's true or false into text, so /api/todos?done=true returns only Buy oat milk.
A POST's body is the JSON data it sends. readBody(event) reads it and returns undefined when there is none. It checks nothing, so { "title": 42 } passes straight through.
readValidatedBody() checks the body first. With Zod, a library for checking data (npm install zod adds it), you describe the body as a schema: NewTodo wants a title of 1 to 200 characters. server/api/todos.post.ts, the handler that adds a todo, passes the schema's parse function:
import { z } from 'zod'
const NewTodo = z.object({
title: z.string().trim().min(1).max(200),
})
export default defineEventHandler(async (event) => {
const { title } = await readValidatedBody(event, NewTodo.parse)
const todo = addTodo(title)
setResponseStatus(event, 201)
return todo
})
When validation fails, the visitor gets a 400 marked Validation Error, and addTodo() never runs.
Three outcomes cover most handlers: return a value for a 200, call setResponseStatus(event, 201) when you created something, and throw createError({ status, statusText }) when the request cannot be served. Any other error that escapes the handler becomes a 500.
Secrets, caching and where the server runs
Code in server/ never reaches the browser, so it can use secrets. todos.post.ts needs one: it announces each new todo in your team chat through a webhook URL, an address the chat gives you for posting. Anyone who has it can post there.
Keep it in private runtime config, which A Nuxt project tour introduced: a setting under runtimeConfig in nuxt.config.ts, here chatWebhookUrl: '', that only the server can read. The environment variable NUXT_CHAT_WEBHOOK_URL, a value set outside your code, fills it when the server starts. After addTodo(title), the handler passes the event to useRuntimeConfig(event) to read it:
const { chatWebhookUrl } = useRuntimeConfig(event)
if (chatWebhookUrl) {
await $fetch(chatWebhookUrl, { method: 'POST', body: { text: `New todo: ${title}` } }).catch(() => {})
}
Without the variable, chatWebhookUrl is empty and the handler skips the message. .catch(() => {}) keeps a failed message from failing the request.
For an answer that rarely changes, defineCachedEventHandler(handler, { maxAge: 60 }) reuses it for 60 seconds.
Handlers need a running server, which nuxt build (run by npm run build) produces in .output/server. nuxt generate produces static files only. On a static host, one that only serves files, a POST /api/todos fails, and only the data that pages fetched while generating survives, saved with each page.
What goes wrong
An unchecked body turns a visitor's mistake into your error.
export default defineEventHandler(async (event) => {
const body = await readBody<{ title: string }>(event)
return addTodo(body.title)
})
A POST with no body makes readBody return undefined, body.title throws Cannot read properties of undefined, and the visitor gets a 500 Internal Server Error, as if the server had broken. The type argument <{ title: string }> only tells TypeScript what to expect; nothing checks it. Validate instead:
const { title } = await readValidatedBody(event, NewTodo.parse)
The same empty POST now gets a 400 marked Validation Error.
A secret written into the code works, and travels everywhere the code goes.
const chatWebhookUrl = 'https://hooks.example.com/services/T0K3N-abc123'
It never reaches the browser, but it sits in your git history and every build: grep, the terminal command that searches files, finds it in .output/server. Anyone with the repository or the build can post to your team chat. Declare it in runtimeConfig and set NUXT_CHAT_WEBHOOK_URL on the host:
const { chatWebhookUrl } = useRuntimeConfig(event)
The build no longer holds the address, and you change it on the host, not in the code.
What to remember
- A file in
server/api/is an endpoint: its name gives the URL and the method, and[id]becomes a param read withgetRouterParam(). - Query values and route params are always strings: convert them, or compare strings.
readBody()returnsundefinedfor an empty body and checks nothing.readValidatedBody()with a schema turns bad input into a 400.- Throw
createError({ status, statusText })for errors you expect; anything else that escapes is a 500. - Secrets live in private
runtimeConfig, set byNUXT_variables and read withuseRuntimeConfig(event), never in the code.
In your project
Do these in a real project. Each one says how to confirm it worked.- Send a POST with no body from a terminal:
curl -i -X POST http://localhost:3000/api/todos. The response is a 400 marked Validation Error. Swap in thereadBodyversion from What goes wrong and repeat: now it is a 500. Put the validated version back. - Run
curl 'http://localhost:3000/api/todos?done=true': only the finished todo comes back. Try?done=1: an empty list, because the string'1'is not'true'. - Write a made-up webhook URL into
todos.post.ts, runnpm run build, thengrep -rl hooks.example .output/server: it prints a file. Put theuseRuntimeConfig(event)line back, rebuild with no.envfile and grep again: no match. Supply it at start withNUXT_CHAT_WEBHOOK_URL=https://hooks.example.com/t node .output/server/index.mjs.