Documentation
Define Endpoints
Declare validated request and response contracts with the canonical route-handler API.
Nuxt Endpoints uses a single-definition route shape designed to align with the
route-contract work happening in H3 and Nitro. Directly default-export
defineEndpoint({...}); the handler receives parsed schema output.
// server/api/users/[id].get.ts
import { z } from 'zod'
export default defineEndpoint({
name: 'getUser',
request: {
params: z.object({ id: z.coerce.number() }),
query: z.object({ include: z.string().optional() }),
},
responses: {
200: z.object({ id: z.number(), name: z.string() }),
404: z.object({ message: z.string() }),
},
handler: (event) => {
const { params, query } = event.validated
const user = findUser(params.id, query.include)
return user ?? event.respond(404, { message: 'Not found' })
},
})
On Nuxt 4/Nitro 2/H3 1, the module implements this API through a private compatibility adapter. As compatible upstream primitives become available, that adapter can shrink without changing the route or client UX.
The optional name creates a typed shorthand for the same HTTP request:
await $endpoint.getUser({
params: { id: '123' },
query: { include: 'profile' },
})
$endpoint('/api/users/:id', { method: 'get', ... }) remains available. Path
and method are the canonical identity; name only removes those two fields at
the call site. It does not flatten the explicit params, query, headers, or
body slots. Names must be unique valid JavaScript identifiers.
Progressively enhanced forms
Add a form declaration to a route with a compatible form-encoded body:
form: {
action: '/users/new',
redirect: '/users/{id}',
},
action is the native form submission URL, as in HTML. NE registers a bridge
at that page URL to call the endpoint; redirect names the destination after
success. useEndpointForm derives the HTML attributes and typed fields from
the contract. The same form works before hydration and with JavaScript
disabled. See the complete server and client examples.
Definition fields
A single-method definition accepts:
name: optional typed$endpointproperty alias for this method.summary,description,tags: OpenAPI metadata.idempotency: serializable idempotency contract metadata.pagination: an optional cursor-pagination contract for GET routes.form: an optional native-form projection for GET or POST routes.request: request schemas.responses: status-to-response schemas.handler: the single-method handler.
request accepts:
params: parsed route params.query: parsed query object.headers: request header object.body: one schema or a media-type map.
Schema input types become generated client request types. Schema output types become handler context types, so coercions and transforms have already run.
Multiple methods in one route
A method-suffix-free route can declare several methods in the same canonical
call. Shared params live at the root; each method owns its metadata,
validation, and handler.
// server/api/users/[id].ts
import { z } from 'zod'
const User = z.object({ id: z.number(), name: z.string() })
const Params = z.object({ id: z.coerce.number() })
export default defineEndpoint({
request: {
params: Params,
},
get: {
name: 'getUser',
responses: { 200: User, 404: z.object({ message: z.string() }) },
handler: (event) => {
return findUser(event.validated.params.id) ?? event.respond(404, { message: 'Not found' })
},
},
put: {
name: 'updateUser',
request: {
body: z.object({ name: z.string() }),
},
responses: { 200: User },
handler: (event) => updateUser(event.validated.params.id, event.validated.body),
},
})
Use the single form in files such as users.get.ts; use the multi-method
form in a bare file such as users.ts. Mixing a multi-method definition with
a method-suffixed filename fails generation because the remaining methods
would be unreachable.
The declarable methods are get, post, put, patch and delete. HEAD
is answered by the get entry with the body dropped, and OPTIONS answers
204 with an Allow header listing the declared methods, so neither is
declared directly. CONNECT and TRACE are not routed. The Nuxt 5 line
accepts all nine as explicit entries, so a route that declares one of them is
the single case that does not port back to this line unchanged.
Handler event
The handler receives the native H3 event, extended with the endpoint contract:
event.validated.params,.query,.headers, and.body: parsed schema outputs.event.bodyMediaType: selected request media type for a body map.event.responseMediaType: negotiated media response type.event.respond(status, body, options?): typed status response.- the usual H3 event context and request properties remain directly available.
export default defineEndpoint({
request: {
body: z.object({ name: z.string().trim() }),
},
responses: {
201: z.object({ id: z.number(), name: z.string() }),
409: z.object({ message: z.string() }),
},
handler: async (event) => {
const { body } = event.validated
if (await nameExists(body.name)) {
return event.respond(409, { message: 'Already exists' })
}
return event.respond(201, await createUser(body))
},
})
Returning a plain body is the status-200 shorthand. Use respond whenever
the status matters. TypeScript rejects a status or body not declared by the
response contract.
Response validation
Response schemas drive types, OpenAPI, and optional runtime response validation. During development, a declared response is validated before it is serialized:
export default defineEndpoint({
responses: { 200: z.object({ createdAt: z.date() }) },
handler: () => ({ createdAt: new Date() }),
})
The server value is validated as Date; generated HTTP clients see the JSON
wire value as string.
Production skips this extra traversal by default. Configure the application-wide
policy in server/endpoints/runtime.ts when response values cross an untyped or
independently deployed boundary:
export default defineEndpointRuntime({
validation: {
response: 'always', // 'development' (default) | 'always' | 'never'
},
})
The setting controls body and declared-header schema checks only. Request
validation, status declaration checks, content negotiation, and idempotency stay
active. In particular, never still rejects a status absent from the route contract.
A status can also declare the headers it promises. When response validation is active, declared
headers are checked against what is actually sent, so a missing or rejected header fails with a
500 response contract error. Header names are matched case-insensitively; use an optional schema
when a header is not always present.
export default defineEndpoint({
responses: {
200: {
body: z.object({ id: z.number() }),
headers: { 'X-Request-Id': z.string().uuid() },
},
},
handler: (event) =>
event.respond(200, { id: 1 }, { headers: { 'x-request-id': crypto.randomUUID() } }),
})
Declared response headers also appear in the generated OpenAPI document.
Request media types
Use a media-type map when one route accepts more than one representation.
Schema members are parsed; true members expose raw bytes.
export default defineEndpoint({
request: {
body: {
'application/json': z.object({ name: z.string() }),
'multipart/form-data': z.object({ name: z.string() }),
'application/pdf': true,
},
},
responses: { 201: z.object({ ok: z.literal(true) }) },
handler: (event) => {
return event.respond(201, { ok: true })
},
})
The client request becomes a discriminated union keyed by mediaType.
Unsupported content types are rejected with 415 before the handler runs.
Media responses
A response entry with media declares an unparsed representation such as a
file or stream:
export default defineEndpoint({
responses: {
200: { media: ['text/csv', 'application/json'] },
404: z.object({ message: z.string() }),
},
handler: (event) => {
const body =
event.responseMediaType === 'application/json' ? JSON.stringify(rows) : createCsvStream(rows)
return event.respond(200, body)
},
})
Use .raw() for routes whose live stream or headers matter. See
Low-level HTTP.
Idempotency metadata and runtime policy
The route definition contains build-time metadata only. Request-time functions belong in the application-wide endpoint runtime file:
export default defineEndpoint({
request: {
body: PaymentInput,
},
responses: { 201: Payment },
idempotency: true,
handler: (event) => event.respond(201, createPayment(event.validated.body)),
})
The central policy supplies storage, scope, and authorization. Route
overrides for fingerprint, replay statuses, and TTLs use
routes[path][method].idempotency in the same runtime file. This is required
for bodyless idempotent operations and multipart bodies containing File.
Putting these callbacks in the route definition is rejected so contract
discovery never evaluates application runtime dependencies during the build.
For application-wide policy and production storage requirements, see Idempotency.
Hooks
Application hooks and per-route overrides also live in
server/endpoints/runtime.ts:
export default defineEndpointRuntime({
validation: {
// The default is 'development'. Use 'always' at an untyped output boundary.
response: 'development',
},
onValidationError: ({ kind, source }) => ({
status: 422,
body: { error: 'invalid_request', field: source, reason: kind },
}),
wrapHandler: async (context, next) => {
const started = Date.now()
try {
return await next()
} finally {
recordDuration(context.event, Date.now() - started)
}
},
routes: {
'/api/users/:id': {
post: {
onValidationError: (failure) => {
if (failure.source === 'body') {
return { status: 422, body: { error: 'invalid_user' } }
}
},
},
},
},
})
A route validation hook may return nothing to fall through to the application
hook; the application hook may fall through to the built-in response.
wrapHandler is application-wide and wraps idempotency plus the handler; route
entries do not accept it. Runtime route keys must exactly match generated
templates and use lowercase methods; startup reports unmatched entries.
Reusing schemas and metadata
Contract values are ordinary imports. Put reusable values outside route
directories, for example in server/contracts:
// server/contracts/user.ts
export const userContract = {
params: UserParams,
responses: { 200: User, 404: NotFound },
}
// server/api/users/[id].get.ts
import { userContract } from '../../../contracts/user'
export default defineEndpoint({
request: {
params: userContract.params,
},
responses: userContract.responses,
handler: (event) => {
return findUser(event.validated.params.id) ?? event.respond(404, { message: 'Not found' })
},
})
There is no *.endpoint-contract.ts filename convention. On the current Nuxt 4
line the complete canonical route module is evaluated at build time, so keep
its top-level dependency graph deterministic. The project is working toward an
upstream contract carrier that can remove this evaluation requirement.
Canonical macro-compatible form
Keep route declarations compatible with Nitro's compiler boundary:
- call the canonical
defineEndpointidentifier directly in the default export; - pass an object literal as the first argument;
- do not alias or shadow the helper;
- do not use root or method-level spreads or computed properties;
- import reusable schema values instead of assembling the definition through runtime-only state.
Invalid or non-discoverable canonical routes fail generation rather than being silently omitted.