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.

Create a free account to save this page, take notes and track your progress. Sign in

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) and getValidatedRouterParams(event, validator) validate input; use zod's safeParse to 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.
  • runtimeConfig values are server-only (unless under public) and can be overridden at runtime with environment variables like NUXT_ADMIN_API_KEY.

The code

Validated endpoints, errors, middleware and runtime config
// 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-key

Output

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 readBody output without validation.
  • Returning errors as 200 responses with an error field; use createError with the right status.
  • Putting secrets in runtimeConfig.public (sent to the browser) or hard-coding production values; override with NUXT_* environment variables.
  • Calling your own API with $fetch("http://localhost/...") from server code; call the shared function in server/utils instead.

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.

Was this page helpful?