Documentation

Low-level HTTP

Handle files, streams, redirects, proxies, raw Responses, and 204 routes.

Nuxt Endpoints is strongest for JSON APIs, but the canonical route definition keeps native HTTP escape hatches.

Native responses

Omit responses when the response should not be modelled as a typed body. Request validation still applies; callers should use .raw().

export default defineEndpoint({
  request: {
    params: z.object({ id: z.string() }),
  },
  handler: (event) => new Response(`raw response for ${event.validated.params.id}`),
})
const response = await $endpoint('/api/raw/:id', {
  method: 'get',
  params: { id: 'abc' },
}).raw()
const text = await response.text()

There is intentionally no useEndpointRaw: native Response, Headers, and streams do not serialize into Nuxt async-data payloads.

File downloads

Use a media response when the representation is known:

export default defineEndpoint({
  request: {
    params: z.object({ id: z.string() }),
  },
  responses: {
    200: { media: 'application/pdf', description: 'Invoice PDF' },
  },
  handler: async (event) => {
    const file = await loadFile(event.validated.params.id)
    return event.respond(200, file.bytes, {
      headers: { 'content-disposition': `attachment; filename="${file.name}"` },
    })
  },
})
const response = await $endpoint('/api/invoices/:id/download', {
  method: 'get',
  params: { id: 'invoice-1' },
}).raw()
const blob = await response.blob()

When the content type cannot be declared, return a native Response and read it through .raw().

Multipart and raw uploads

A media-type body map validates parsed representations and can expose raw bytes for selected members:

export default defineEndpoint({
  request: {
    body: {
      'multipart/form-data': z.object({ name: z.string() }),
      'application/pdf': true,
    },
  },
  responses: { 201: z.object({ ok: z.literal(true) }) },
  handler: (event) => {
    if (event.bodyMediaType === 'application/pdf') {
      return event.respond(201, { ok: savePdf(event.validated.body) })
    }
    return event.respond(201, { ok: saveForm(event.validated.body) })
  },
})

Use a plain Nitro handler when you need streaming part-by-part multipart processing rather than a parsed contract value.

Redirects

Redirect semantics belong to HTTP rather than a JSON response schema:

export default defineEndpoint({
  request: {
    query: z.object({ to: z.string().startsWith('/') }),
  },
  handler: (event) =>
    new Response(null, {
      status: 302,
      headers: { location: event.validated.query.to },
    }),
})

Call redirects with .raw() when status and Location matter.

Proxies

Return the upstream Response directly. Do not declare a schema unless the route actually reads and validates the upstream body:

export default defineEndpoint({
  request: {
    params: z.object({ path: z.string() }),
  },
  handler: (event) => {
    return fetch(new URL(event.validated.params.path, 'https://upstream.example'), {
      method: event.req.method,
      headers: event.req.headers,
      signal: event.req.signal,
    })
  },
})

Empty responses

An explicit 204 can be declared as a media response and returned with respond:

export default defineEndpoint({
  responses: {
    204: { media: 'application/octet-stream', description: 'Deleted' },
  },
  handler: (event) => event.respond(204, new Uint8Array()),
})

For strict HTTP semantics where no content type or body should be emitted, return new Response(null, { status: 204 }) without a response schema and use .raw() on the client.

When to keep a plain Nitro route

Use defineEventHandler directly when the contract would be incomplete or misleading—for example, transparent proxies, open-ended streaming protocols, or routes whose behavior is entirely controlled by another framework. Plain and contracted routes coexist in the same server directory.