Vue 3 and Nuxt · Building Apps with Nuxt
Server API Routes with Validation
Build API endpoints in Nuxt's server directory: validated bodies and params, typed errors, server middleware, runtime config and status codes.
What you'll build
An orders API in server/api: create orders with a validated body, fetch them with validated route params, a protected admin delete that checks an API key from runtime config, and a server middleware that tags every request with a request ID.
The idea
- Files in
server/api/become endpoints under/api; the method goes in the file name (index.post.ts,[id].get.ts). Nuxt's server engine is Nitro, with h3 utilities. readValidatedBody(event, validator)andgetValidatedRouterParams(event, validator)validate input; use zod'ssafeParseto return field errors.throw createError({ statusCode, statusMessage, data })returns a JSON error with that status.server/middleware/runs for every request;server/utils/is auto-imported in server code.runtimeConfigvalues are server-only (unless underpublic) and can be overridden at runtime with environment variables likeNUXT_ADMIN_API_KEY.
The code
// file: nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: "2026-10-01",
devtools: { enabled: false },
runtimeConfig: { adminApiKey: "dev-only-key" }, // set NUXT_ADMIN_API_KEY in production
});
// file: app/app.vue
<template><main><NuxtPage /></main></template>
// file: app/pages/index.vue
<template><h1>Orders API</h1></template>
// file: server/utils/orders.ts
export type Order = { id: number; item: string; quantity: number };
export const orders = new Map<number, Order>([[1, { id: 1, item: "Mug", quantity: 2 }]]);
// file: server/middleware/request-id.ts
export default defineEventHandler((event) => {
const id = getRequestHeader(event, "x-request-id") ?? crypto.randomUUID().slice(0, 8);
event.context.requestId = id;
setResponseHeader(event, "x-request-id", id);
});
// file: server/api/orders/index.post.ts
import { z } from "zod";
const NewOrder = z.object({ item: z.string().trim().min(1), quantity: z.number().int().min(1).max(10) });
export default defineEventHandler(async (event) => {
const result = await readValidatedBody(event, (body) => NewOrder.safeParse(body));
if (!result.success) {
throw createError({ statusCode: 400, statusMessage: "Invalid order", data: z.flattenError(result.error).fieldErrors });
}
const order = { id: orders.size + 1, ...result.data };
orders.set(order.id, order);
console.log(`[app] ${event.context.requestId} created order ${order.id}`);
setResponseStatus(event, 201);
return order;
});
// file: server/api/orders/[id].get.ts
import { z } from "zod";
const Params = z.object({ id: z.coerce.number().int().positive() });
export default defineEventHandler(async (event) => {
const params = await getValidatedRouterParams(event, (p) => Params.safeParse(p));
if (!params.success) throw createError({ statusCode: 400, statusMessage: "Order id must be a positive integer" });
const id = params.data.id;
const order = orders.get(id);
if (!order) throw createError({ statusCode: 404, statusMessage: `Order ${id} not found` });
return order;
});
// file: server/api/admin/orders/[id].delete.ts
export default defineEventHandler((event) => {
if (getRequestHeader(event, "x-api-key") !== useRuntimeConfig(event).adminApiKey) {
throw createError({ statusCode: 401, statusMessage: "Missing or invalid API key" });
}
orders.delete(Number(getRouterParam(event, "id")));
setResponseStatus(event, 204);
return null;
});
// headers: x-request-id
// request: POST /api/orders header:x-request-id=req-1 {"item": "Teapot", "quantity": 1}
// request: POST /api/orders header:x-request-id=req-2 {"item": "", "quantity": 99}
// request: GET /api/orders/2 header:x-request-id=req-3
// request: GET /api/orders/abc header:x-request-id=req-4
// request: GET /api/orders/9 header:x-request-id=req-5
// request: DELETE /api/admin/orders/1 header:x-request-id=req-6
// request: DELETE /api/admin/orders/1 header:x-request-id=req-7 header:x-api-key=dev-only-keyOutput
POST /api/orders -> 201 (x-request-id: req-1)
[app] req-1 created order 2
{"id": 2, "item": "Teapot", "quantity": 1}
POST /api/orders -> 400 (x-request-id: req-2)
{"error": true, "url": "http://localhost:3000/api/orders", "statusCode": 400, "statusMessage": "Invalid order", "message": "Invalid order", "data": {"item": ["Too small: expected string to have >=1 characters"], "quantity": ["Too big: expected number to be <=10"]}}
GET /api/orders/2 -> 200 (x-request-id: req-3)
{"id": 2, "item": "Teapot", "quantity": 1}
GET /api/orders/abc -> 400 (x-request-id: req-4)
{"error": true, "url": "http://localhost:3000/api/orders/abc", "statusCode": 400, "statusMessage": "Order id must be a positive integer", "message": "Order id must be a positive integer"}
GET /api/orders/9 -> 404 (x-request-id: req-5)
{"error": true, "url": "http://localhost:3000/api/orders/9", "statusCode": 404, "statusMessage": "Order 9 not found", "message": "Order 9 not found"}
DELETE /api/admin/orders/1 -> 401 (x-request-id: req-6)
{"error": true, "url": "http://localhost:3000/api/admin/orders/1", "statusCode": 401, "statusMessage": "Missing or invalid API key", "message": "Missing or invalid API key"}
DELETE /api/admin/orders/1 -> 204 (x-request-id: req-7)Run this on your computer — see “Get Started” for setup.
Common mistakes
- Trusting
readBodyoutput without validation. - Returning errors as 200 responses with an
errorfield; usecreateErrorwith the right status. - Putting secrets in
runtimeConfig.public(sent to the browser) or hard-coding production values; override withNUXT_*environment variables. - Calling your own API with
$fetch("http://localhost/...")from server code; call the shared function inserver/utilsinstead.
Key points
- server/api file names define routes and methods.
- Validate bodies and params; throw createError with proper statuses.
- Server middleware for cross-cutting concerns; runtimeConfig for secrets.
Further reading: Nuxt server directory · h3 utilities · Runtime config
Practice questions
How do Nuxt server route file names map to endpoints?
Answer
Files under server/api become /api routes; brackets are params and a method suffix like .post.ts restricts the HTTP method.
How are runtimeConfig values overridden in production?
Answer
With environment variables prefixed NUXT_ matching the key, such as NUXT_ADMIN_API_KEY for adminApiKey; values outside public stay on the server.
What does readValidatedBody add over readBody?
Answer
It runs a validator on the parsed body and fails with a 400 response (or returns your validation result), so handlers only work with checked data.
Open in the app to mark lessons complete, take notes and track your progress.