Fullstack with Nitro

API design and integration

REST conventions that make an API predictable — resources, methods, status codes, a consistent error shape, pagination, filtering, versioning, idempotency keys, conditional requests — plus OpenAPI from Nitro, when tRPC, oRPC or GraphQL earn their complexity, the backend-for-frontend pattern, proxies, webhooks in and out, API keys and rate limits.

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.

Know

  • Resources and methods: plural nouns (/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.
  • Status codes and errors: 200/201 (with 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.
  • Pagination, filtering, sorting: cursor pagination with ?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.
  • Versioning: prefer additive, backward-compatible changes (new optional fields) with no version at all; when a break is unavoidable, a URL prefix (/api/v2) is the most discoverable, headers the most purist; publish deprecation with Deprecation/Sunset headers and a date.
  • Idempotency and concurrency: 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.
  • OpenAPI from Nitro: 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.
  • Typed RPC layers: inside one Nuxt app, $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.
  • GraphQL: one endpoint, client-specified shapes, strong schema and tooling; costs are query complexity control, N+1 (dataloaders), caching (no HTTP caching per URL), and a second type system. Worth it for many diverse clients over a rich graph; overkill for a single Nuxt frontend. In Nuxt: nuxt-graphql-client or Apollo on the client, GraphQL Yoga in Nitro on the server.
  • Backend-for-frontend: Nitro sits between the browser and third-party or internal APIs: it holds the secrets, aggregates several calls into one response shaped for the page, caches, retries and translates errors, and hides upstream changes. routeRules proxy and h3's proxyRequest forward whole paths when no shaping is needed; event.$fetch composes internal routes.
  • Webhooks: inbound handlers verify an HMAC signature over the raw body, check a timestamp window against replay, respond 2xx quickly and process asynchronously via the outbox, and are idempotent by event id; outbound webhooks you send are signed the same way, retried with backoff, and documented.
  • Machine clients and abuse: API keys hashed at rest with a prefix for lookup, scoped permissions, rotation; rate limiting per key and per IP (nuxt-security, Cloudflare, rate-limiter-flexible) with RateLimit-* headers; CORS opened only for known origins; request logging with ids for support.
  • Documentation and DX: a versioned changelog, examples for every endpoint, a sandbox environment, and SDKs generated from OpenAPI when partners matter.

How it works

A REST resource with the conventions applied, documented for OpenAPI:

server/api/orders/index.post.ts
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
})
server/api/orders/index.get.ts
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:

server/api/dashboard.get.ts
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
})
nuxt.config.ts
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:

server/routes/webhooks/payments.post.ts
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
})
Gotcha· A different error shape per route

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.

Gotcha· tRPC for a partner API

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.

Verify before the interview:

Nitro's OpenAPI option names, the defineRouteMeta shape and the UI routes (/_scalar, /_swagger); also oRPC's current Nuxt integration.

docs ↗

Exercise

Exercise
  • Add 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.
  • Implement Idempotency-Key on a create route; send the same request twice and a third time with a different body.
  • Build the dashboard BFF with two slow upstreams; add timeouts and cachedFunction and measure the page's TTFB before and after.

Be able to say

Be able to say· How do you design an API in a Nuxt application, and when would you use tRPC or GraphQL instead of REST?

"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."

Be able to say· What is the backend-for-frontend pattern and why does Nitro fit it?

"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."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.