skip to content

In a Nuxt 4 server route, how do you read the path parameter, query and JSON body, and reject bad input?

level: middleimportance: must knowfreq 40%

answer

  1. three helpers, one event
  2. query values are always strings
  3. body only on writing methods
  4. validated variants answer 400
  5. throw createError with status

basics

~20 s

Use getRouterParam for the path parameter, getQuery for the query (values arrive as strings) and await readBody for the body, or their validated variants that answer 400. Reject input by throwing createError with status and statusText.

solid answer

~40 s

Nuxt 4 server routes get one argument, the h3 `event`, and auto-imported helpers read it: `getRouterParam(event, 'sku')` returns the `[sku]` segment as a string, `getQuery(event)` returns an object whose values are strings or string arrays, and `await readBody(event)` parses the body by `Content-Type`. `readBody` works only for POST, PUT, PATCH and DELETE; on a GET it throws a 405. For untrusted input use `readValidatedBody`, `getValidatedQuery` or `getValidatedRouterParams` with a validator: if it returns `false` or throws, h3 answers 400 'Validation Error'. To reject a request yourself, `throw createError({ status: 409, statusText: 'Not enough stock', data })`; since Nuxt 4.3 `status`/`statusText` are the documented names, and h3 1.15 maps them onto `statusCode`/`statusMessage`. Throw deliberate errors: in production a plain `Error` is treated as unhandled, logged, and sent as a 500 with a generic message.

code

ts · 20 lines
ts
// server/api/inventory/[sku]/reserve.post.ts
export default defineEventHandler(async (event) => {
  const sku = getRouterParam(event, 'sku')
  if (!sku) throw createError({ status: 400, statusText: 'Missing SKU' })

  const { dryRun } = getQuery(event) // '1' or undefined: always a string
  const body = await readValidatedBody(event, (b) =>
    typeof b === 'object' && b !== null
      && Number.isInteger((b as { quantity?: unknown }).quantity))
  const { quantity } = body as { quantity: number }

  const stock = await findStock(sku) // from server/utils/inventory.ts
  if (!stock) {
    throw createError({ status: 404, statusText: 'Unknown SKU', data: { sku } })
  }
  if (stock.available < quantity) {
    throw createError({ status: 409, statusText: 'Not enough stock' })
  }
  return dryRun === '1' ? { sku, ok: true } : reserveStock(sku, quantity)
})

go deeper

for a junior

Recall the three readers, getRouterParam, getQuery and readBody, and that you reject input by throwing createError with a status.

for a middle

Explain the details: query values are strings, readBody works only on writing methods and caches its result, and validated variants answer 400 based on the validator's return value.

for a senior

Show how you translate upstream failures into deliberate statuses with cause, keep user input out of statusText, and rely on unhandled errors being logged and masked in production.

for a principal

Treat the BFF's error contract as an interface: which statuses and data shapes pages may depend on, and how the move to status/statusText is rolled out before h3 v2 arrives.

## One event, three inputs A handler in Nuxt 4's `server/` directory receives a single argument, the h3 **event**, which wraps the incoming request and the outgoing response. You never parse the raw request yourself; h3's helpers, auto-imported inside `server/`, do it: | Input | Helper | What you get back | |---|---|---| | Path parameter from `[sku]` | `getRouterParam(event, 'sku')` | a string, or `undefined` if there is no such parameter | | All path parameters | `getRouterParams(event)` | an object of strings | | Query string | `getQuery(event)` | an object of strings, or string arrays for repeated keys | | Body | `await readBody(event)` | the parsed body | Three details matter in practice: - **Query values are never numbers or booleans.** `?includeReserved=false` gives the string `'false'`, which is truthy, and `?limit=10` gives `'10'`. Convert and check every value. - **`readBody` is asynchronous and method-bound.** It reads only for `POST`, `PUT`, `PATCH` and `DELETE`; on any other method it throws a 405 Method Not Allowed error. It parses `application/json` as JSON, `application/x-www-form-urlencoded` into an object and `text/*` as a string, and it remembers the result, so calling it twice does not read the stream twice. - **Parameters come from the file name.** `server/api/inventory/[sku]/reserve.post.ts` puts `sku` into `event.context.params`, which is what `getRouterParam` reads. ## Validating instead of trusting Each reader has a validated twin: `readValidatedBody`, `getValidatedQuery` and `getValidatedRouterParams`. They take a validator function and interpret its result: 1. It returns `false` or throws: h3 throws a **400 'Validation Error'**, with the validator's error in `data`. 2. It returns `true`: you get the raw value back. 3. It returns anything else: that value replaces the input, which is how a schema's parse function hands you typed, coerced data. That is why Nuxt's own docs pass a throwing parse function, `readValidatedBody(event, bodySchema.parse)`. A "safe" parse function that returns a result object even on failure falls under rule 3, so invalid input would pass through as that object. ## Rejecting a request with `createError` `createError` builds an **H3Error** that carries an HTTP status. You `throw` it, which stops the handler at that line: ```ts throw createError({ status: 409, statusText: 'Not enough stock', message: 'Only 3 units of A1024 are available', data: { sku: 'A1024', available: 3 }, }) ``` - **`status` and `statusText`** set the status code and reason phrase. Nuxt 4.3 made these the documented names and deprecated `statusCode`/`statusMessage` on the Nuxt side, preparing for h3 v2. h3 1.15, which Nuxt 4.5 runs on, accepts both spellings and stores them as `statusCode`/`statusMessage`. - **Keep `statusText` short.** It becomes the HTTP reason phrase; h3 warns when it has to sanitise one and recommends `message` for longer text. - **`message` and `data`** travel in the JSON error body for deliberate errors, so never put secrets or raw upstream responses in them. - **A plain `Error`** (a bug, or an upstream client's exception that escaped) is flagged **unhandled**: it is logged as a request error and answered with a 500 whose body says `Server Error` instead of your message. ## Wrapping an upstream call In a backend-for-frontend, the upstream inventory service fails in its own ways. Catch its errors and translate them, for example `throw createError({ status: 502, statusText: 'Inventory unavailable', cause: err })`, so the browser sees a status your pages can handle and the original error survives as `cause` for logs. ## Choosing a status The status is part of the contract your pages code against, so pick it deliberately: | Situation in the inventory BFF | Status | |---|---| | body or query fails validation | 400 (the validated readers do this for you) | | no session on a protected route | 401 | | SKU does not exist upstream | 404 | | not enough stock to reserve | 409 | | upstream service down or erroring | 502 or 503 | A page calling the route with `$fetch` or `useFetch` receives these as errors with the status attached, so it can show "out of stock" for a 409 and a retry banner for a 503 instead of one generic failure. ## Pitfalls - `if (query.includeReserved)` is true for `'false'`. - `Number(query.limit)` is `NaN` for `?limit=ten`; validate before use. - Calling `readBody` in a `.get.ts` handler throws 405 on every request. - Building `statusText` from user input: keep user data in `data`, not in the status line. - Returning `{ error: 'not found' }` instead of throwing: the client gets a 200 and has to guess. - Letting an upstream client's exception escape: the shopper sees a masked 500 and your logs show only the upstream's message, without the SKU that caused it.

  • Why pass a schema's throwing parse function to readValidatedBody rather than a safe-parse function?
    h3 reads the validator's return value: `false` or a throw becomes a 400, `true` keeps the raw body, and any other value replaces the body. A safe-parse function returns a result object even when validation fails, so invalid input would reach your code wrapped in that object. A throwing parse function, like the `bodySchema.parse` in Nuxt's docs, turns failure into the 400.
  • What reaches the client when a deliberate createError and an accidental TypeError both escape a Nuxt 4 server route?
    The deliberate H3Error keeps its status, and in a production build its `message` and `data` go into the JSON error body. The TypeError is not an H3Error, so h3 marks it unhandled: Nitro logs it and sends a 500 whose message is the generic 'Server Error', which keeps internals out of the response.
  • Are statusCode and statusMessage wrong in Nuxt 4 server routes?
    They still work: h3 1.15 accepts both spellings and stores them as `statusCode`/`statusMessage`. Nuxt 4.3 deprecated them on the Nuxt side and documents `status`/`statusText`, the names h3 v2 uses, so new code should use those to keep the eventual upgrade small.

saying these in an interview costs you the question

  • getQuery converts numeric and boolean query values to numbers and booleans.
  • readBody on a GET request simply resolves to undefined.
  • Returning an object with an error field is how a server route signals a 404.
  • Any thrown Error keeps its message in the production response body.
  • A validator that returns a safe-parse result object makes readValidatedBody reject bad input.
  • In Nuxt 4.3 and later, statusCode is the recommended createError field.