Here is a Nuxt route written the way the docs recommend, and the code that calls it:
// server/api/users/[id].get.ts
import { z } from 'zod'
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, z.object({
id: z.coerce.number(),
}).parse)
const user = await findUser(id)
if (!user) throw createError({ statusCode: 404, statusMessage: 'Not found' })
return user
})
js
// somewhere in the app
const user = await $fetch(`/api/users/${id}`)
user.name // typed β inferred from the handler's return
There is nothing wrong with this. h3 ships getValidatedRouterParams
and readValidatedBody
, they take any Standard Schema validator, and Nuxt's typed $fetch
infers the success type from the handler. Request validation and response types are both solved problems.
What isn't solved is that they're solved separately. The type at the call site comes from inference, not from the schema β the two have no relationship. The 404 you just threw doesn't appear anywhere in the client's type; every non-2xx response is effectively unknown
. And if you publish API docs, that's a third description of the same endpoint, hand-written, accurate the week it was written.
Three descriptions of one contract β handler, caller, docs β held together by discipline. You don't need convincing that this drifts.
Nuxt Endpoints collapses them into one. You swap defineEventHandler
for defineEndpoint
and declare the contract alongside the handler in the same call β the validator, the client types, and the OpenAPI document are then all read off that one declaration. The route keeps its path, its method, its place in server/api
, and Nitro's routing. If you know Hono RPC, the client will feel familiar; the difference is that no file moved to get it.
// server/api/users/[id].get.ts β same file, same path, same method
import { z } from 'zod'
export default defineEndpoint({
params: z.object({ id: z.coerce.number() }),
handler: ({ params }) => {
return findUser(params.id) // params.id is number, already validated
},
})
python
// app code β nothing to import
const user = await $endpoint('/api/users/:id', {
method: 'get',
params: { id: '1' },
})
user.name // inferred from the handler's return, as before
No router to mount, no directory to migrate, no second system running beside Nitro. The route is still a plain HTTP endpoint at /api/users/1
, still callable from curl. What changed is that the contract now exists as a value the rest of the module can read.
And the file next door is untouched. Only routes that define an endpoint join the contract; everything else stays an ordinary Nitro route, forever if you like.
The example above declares no response schemas β and client types still work, inferred from the handler's return exactly like typed $fetch
. That's deliberate: adopting the module costs nothing on day one, so the first route is a cheap experiment rather than a decision.
export default defineEndpoint({
operation: 'getUser', // optional; names the route for the generated helpers
params: z.object({ id: z.coerce.number() }),
// added once the route earned it. Without this key the client types come
// from the handler's return, same as typed $fetch:
responses: {
200: User,
404: z.object({ message: z.string() }),
},
handler: ({ params, respond }) => {
const user = findUser(params.id)
return user ?? respond(404, { message: 'Not found' }) // type-checked against `responses`
},
})
Delete the responses
key and you're back to inference-only typing. Swap defineEndpoint
for a plain defineEventHandler
and the route is an ordinary Nitro route again. There's no migration event β just a gradient, and you can stop anywhere on it.
Declaring responses buys something typed $fetch
can't give you: the failure cases stop being unknown
.
const result = await $endpoint('/api/users/:id', {
method: 'get',
params: { id: '123' },
}).result()
if (result.status === 200) {
result.body.name // User
}
if (result.status === 404) {
result.body.message // typed from the 404 schema
}
.result()
returns a discriminated union keyed on the status code. Branch on it and the body narrows β no instanceof FetchError
, no casting error.data
, no reading the server route to remember what a 404 contains.
$endpoint
is a one-off call. When a component needs the usual data
/ pending
/ error
/ refresh
shape β the same thing useFetch
gives you β call the same typed endpoint through useEndpoint
instead:
const { data: user, pending, error, refresh } = await useEndpoint('/api/users/:id', {
method: 'get',
params: { id: '123' },
key: 'user:123',
})
user.value?.name // typed, same as $endpoint
It forwards the Nuxt async-data options you already know β key
, lazy
, server
, watch
, default
β while params
, query
, headers
, and body
stay typed from the contract. If useFetch
is where you'd normally reach for a route, useEndpoint
is that same reach, pointed at a contract instead of a URL string.
useEndpoint
covers a single component's own request. When several components need to share the same cached data, invalidate it from elsewhere, or run background refetches, that's a Vue Query problem β not something to bolt onto useEndpoint
.
The usual cost of adopting a typed client is that it competes with your server-state library β you end up with the client's caching and Vue Query's caching, or you wrap one in the other.
Install @tanstack/vue-query
, and every endpoint carrying an operation
name β getUser
above β generates option factories instead:
import { useQuery } from '@tanstack/vue-query'
import { endpointQueryOptions } from '#endpoints/query'
const route = useRoute()
const user = useQuery(
endpointQueryOptions.getUser(() => ({
params: { id: String(route.params.id) },
})),
)
user.data.value?.name // User
These are ordinary Vue Query options. Invalidation, prefetching, optimistic updates, and Devtools all behave exactly as documented, because Vue Query still owns server-state behavior entirely β the module only supplies the request and the types. GET and HEAD become query and infinite-query options; mutations get endpointMutationOptions
with typed variables. If you want SSR, a request-scoped QueryClient with hydration is one config flag (endpoints.client.query.setup
).
GET /_endpoints/schema
serves an OpenAPI 3.1 document generated from the same contracts that run your validation. It cannot disagree with the code, because it is the code.
Schemas can't express everything β auth schemes, server URLs, tags. Those go in one file, merged into the generated document:
// server/endpoints/runtime.ts
export default defineEndpointRuntime({
openApi: {
document: {
servers: [{ url: 'https://api.example.com' }],
components: {
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } },
},
},
extend(document) {
document.security = [{ bearerAuth: [] }]
},
},
})
document
is deep-merged; extend
runs last on the merged result, for anything a patch can't reach.
If you follow h3, you already know some of this is heading into core. Nuxt 5 swaps the engine underneath β Nitro 3, srvx, h3 v2 β and h3 v2 ships defineValidatedHandler
, which validates body, headers, and query against Standard Schema. Nitro 3 serves an OpenAPI document. Nuxt's roadmap estimates Q4 2026.
The plan is to sit on that rather than beside it. Every h3 call the runtime makes goes through one file, and that file has been checked against the h3 v2 release candidates: everything still resolves, and the single call that goes away has a one-line replacement. When core owns the validation, this module hands it over and keeps the layer above.
That layer is not small. Response contracts, a client that narrows on status, OpenAPI derived from the schemas rather than hand-authored route meta, the Vue Query factories, the composables β none of it exists in h3 or Nitro today, and the RFC proposing the contract convention explicitly leaves the typed client, the codegen, and OpenAPI generation downstream. Nitro 3's OpenAPI route serves a document; it still reads hand-written meta.openAPI
, not your schemas.
The measurements behind that are in docs/nitro-v3-h3-v2-readiness.md β every adapter call mapped to its v2 form, dated and pinned to the versions it was checked against.
tRPC is excellent, and not an option if you want to keep HTTP. Procedures are reached through tRPC's own protocol, so curl and non-tRPC clients need a translation layer.
Hono RPC / @hono/zod-openapi is the closest relative, and mounting Hono inside Nitro is officially documented. But Nitro's own example has Hono take over as the server entry and handle all routing β so you write Hono routes, not Nuxt ones.
nuxt-open-fetch points the other way: spec first, client derived. The right tool when someone else owns the spec.
nuxt-actions also keeps file-based routes and validates with Standard Schema. It ships its own query layer β caching, invalidation, optimistic updates; this module hands that to Vue Query.
Response
s, 204s β is handled through documented escape hatches rather than abstractions. The Anything you find is useful β a route the contract model can't express, a schema library that
breaks, or just that the API reads wrong to you.
Docs and a type playground: https://nuxt-endpoints.github.io/nuxt-endpoints/
Repo: https://github.com/nuxt-endpoints/nuxt-endpoints