Inside a single Nuxt app the API is already typed end to end, which tempts people to skip design. The interview question "how would you design the API" is about the consumers you do not control (mobile apps, partners, your own future), about consistency, and about knowing when a typed RPC layer or GraphQL is worth its cost. It is also where the backend-for-frontend pattern, the main reason many teams run Nitro at all, gets explained.
/api/orders, /api/orders/{id}, /api/orders/{id}/items), methods carry the verb (GET read, POST create, PUT replace, PATCH partial update, DELETE), nesting only for ownership, actions that are not CRUD as sub-resources or verbs on a resource (POST /api/orders/{id}/cancel). Nuxt's file routing (orders/[id]/cancel.post.ts) maps onto this directly.Location)/204 for success, 400 validation, 401/403, 404 (also for "not yours" when existence is sensitive), 409 conflict, 412 precondition failed, 413 too large, 415 unsupported type, 422 semantic errors, 429 with Retry-After. One error shape for every route: createError gives { status, statusText, message, data }; put machine-readable detail (field issues, error codes) in data and never stack traces.?cursor=&limit= and a next cursor in the body (or Link headers), bounded limit; filters as query params validated by schema; explicit sort=field:asc allow-lists; sparse fields or include only when payload size demands it./api/v2) is the most discoverable, headers the most purist; publish deprecation with Deprecation/Sunset headers and a date.Idempotency-Key on POSTs that create resources (store the key and the response for a window; return the same response on retry); ETag/If-Match for optimistic concurrency on updates (412 on mismatch); If-None-Match for cheap reads.nitro.experimental.openAPI: true serves /_openapi.json built from routes and defineRouteMeta({ openAPI: { summary, parameters, responses } }), with Scalar and Swagger UIs (/_scalar, /_swagger); use it for documentation, client generation for external consumers, and contract tests. Zod-to-OpenAPI generators keep the document in sync with the shared schemas.$fetch plus generated route types already give end-to-end typing. tRPC (trpc-nuxt) adds procedures with full inference and middleware, at the cost of a non-REST wire format only TypeScript clients enjoy. oRPC gives the same typed-procedure DX while producing a real OpenAPI-described REST surface, which is why it grew in 2025–26. Choose RPC when the consumer is your own TypeScript client and the API surface changes fast; choose REST + OpenAPI when partners or non-TS clients exist.nuxt-graphql-client or Apollo on the client, GraphQL Yoga in Nitro on the server.routeRules proxy and h3's proxyRequest forward whole paths when no shaping is needed; event.$fetch composes internal routes.nuxt-security, Cloudflare, rate-limiter-flexible) with RateLimit-* headers; CORS opened only for known origins; request logging with ids for support.A REST resource with the conventions applied, documented for OpenAPI:
import { createOrderSchema } from '#shared/schemas/order'
defineRouteMeta({
openAPI: {
tags: ['orders'],
summary: 'Create an order',
requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateOrder' } } } },
responses: { 201: { description: 'Created' }, 400: { description: 'Validation failed' }, 409: { description: 'Duplicate idempotency key with a different body' } },
},
})
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const key = getHeader(event, 'idempotency-key')
if (!key) throw createError({ status: 400, statusText: 'Idempotency-Key header required' })
const input = await readValidatedBody(event, createOrderSchema.parse)
const replay = await getIdempotentResponse(useDb(), user.id, key, input) // same key + same body -> stored response; different body -> 409
if (replay) { setResponseStatus(event, 201); return replay }
const order = await placeOrder(useDb(), { userId: user.id, ...input })
const dto = toOrderDto(order)
await storeIdempotentResponse(useDb(), user.id, key, input, dto)
setResponseStatus(event, 201)
setResponseHeaders(event, { location: `/api/orders/${order.id}`, etag: `"${order.version}"` })
return dto
})
import { z } from 'zod'
const query = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['pending', 'paid', 'shipped']).optional(),
sort: z.enum(['created:desc', 'created:asc']).default('created:desc'),
})
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const q = await getValidatedQuery(event, query.parse)
const page = await listOrders(useDb(), { userId: user.id, ...q })
return { items: page.items.map(toOrderDto), next: page.nextCursor, limit: q.limit }
})
The BFF: aggregate, shape, cache and hide secrets, or simply proxy:
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { crmToken, weatherKey } = useRuntimeConfig(event)
const [account, tickets, weather] = await Promise.all([
$fetch(`https://crm.example.com/accounts/${user.crmId}`, { headers: { authorization: `Bearer ${crmToken}` }, signal: AbortSignal.timeout(2_000) }).catch(() => null),
$fetch(`https://support.example.com/tickets?user=${user.id}`, { signal: AbortSignal.timeout(2_000) }).catch(() => []),
getWeatherCached(user.city, weatherKey), // cachedFunction: shared across users of the same city
])
return { plan: account?.plan ?? 'unknown', openTickets: (tickets as unknown[]).length, weather } // one round trip, one shape, no upstream keys in the browser
})
export default defineNuxtConfig({
routeRules: {
'/api/legacy/**': { proxy: 'https://legacy.example.com/v1/**' }, // pure pass-through when no shaping is needed
},
nitro: { experimental: { openAPI: true } },
})
An inbound webhook: verify, dedupe, acknowledge, process later:
export default defineEventHandler(async (event) => {
const raw = (await readRawBody(event, 'utf8')) ?? ''
const signature = getHeader(event, 'x-signature') ?? ''
const timestamp = Number(getHeader(event, 'x-timestamp') ?? 0)
if (Math.abs(Date.now() / 1000 - timestamp) > 300) throw createError({ status: 401, statusText: 'Stale webhook' }) // replay window
if (!await verifyHmac(raw, `${timestamp}.${raw}`, signature, useRuntimeConfig(event).paymentsWebhookSecret)) throw createError({ status: 401, statusText: 'Bad signature' })
const payload = JSON.parse(raw) as { id: string, type: string }
const inserted = await useDb().insert(webhookEvents).values({ id: payload.id, type: payload.type, payload }).onConflictDoNothing().returning() // idempotent by event id
if (inserted.length) await useDb().insert(outbox).values({ id: crypto.randomUUID(), type: `payments:${payload.type}`, payload })
setResponseStatus(event, 202) // acknowledged; processing happens in the drain task
return null
})
One route returns { error: 'x' }, another { message }, a third a plain string; the client grows three parsers. createError everywhere, validation issues in data, an error code in data.code where clients need to branch, and a test that asserts the shape.
A partner integrating from Python gets a batching query-string protocol and no documentation. Typed RPC is for your own TypeScript clients; anyone else needs REST with OpenAPI, which oRPC can generate from the same procedures if you want both.
Nitro's OpenAPI option names, the defineRouteMeta shape and the UI routes (/_scalar, /_swagger); also oRPC's current Nuxt integration.
defineRouteMeta to three routes, enable OpenAPI, open Scalar, and generate a TypeScript client from the document with an OpenAPI generator; compare it with Nuxt's built-in $fetch typing.Idempotency-Key on a create route; send the same request twice and a third time with a different body.cachedFunction and measure the page's TTFB before and after."REST by default because Nuxt's file routing maps onto it and every consumer understands it: plural resources, methods as verbs, the standard status codes, one error shape from createError with field issues in data, cursor pagination with bounded limits, validated filters, idempotency keys on creates, ETag and If-Match for concurrent updates, and additive changes instead of versions until a break is unavoidable. I document it with Nitro's OpenAPI output and test the contract with the shared schemas. Inside a single Nuxt app the routes are already typed end to end, so tRPC adds little; if the client surface changes fast and only our TypeScript apps consume it, oRPC gives typed procedures and still emits OpenAPI for everyone else. GraphQL earns its complexity when many diverse clients query a rich graph; for one frontend it costs more in complexity control, N+1 handling and caching than it returns."
"A thin server owned by the frontend team that sits between the browser and the real APIs: it holds the credentials so the browser never sees them, aggregates several upstream calls into one response shaped for the page, applies caching, timeouts and retries, translates errors into one shape, and absorbs upstream changes. Nitro fits because it deploys with the app, shares its types and schemas, has cachedFunction and route-level caching built in, event.$fetch for composing internal routes, and routeRules proxy for pure pass-through. It is the reason many teams run a Nuxt server even when the domain logic lives elsewhere."
Nothing yet. Notes filed here show up on this page and the section overview.
Files, realtime and background work
Uploads through the server or with presigned URLs, validation and processing, serving files safely; WebSockets and server-sent events from the server's point of view and how they scale; Nitro tasks and scheduled tasks, queues, idempotency, retries and the outbox pattern; and post-response work with waitUntil.
Deployment and operations
Nitro presets and what each implies — Node in Docker, Cloudflare Workers with bindings, Vercel and Netlify, static — plus runtime configuration and secrets, health checks and graceful shutdown, structured logging with request ids, error and performance monitoring, migrations in the release, zero-downtime deploys and rollbacks, cost, and the NuxtHub shift to self-hosting.