Nuxt server routes with Hono RPC-style type safety, no rewrite A developer introduced Nuxt Endpoints, a module that unifies request validation, client types, and OpenAPI documentation into a single declaration for Nuxt server routes, eliminating the need for separate hand-written contracts. The module allows developers to swap defineEventHandler for defineEndpoint, keeping the same file, path, and method, while providing type-safe client calls via $endpoint and optional response schemas for typed error handling. Here is a Nuxt route written the way the docs recommend, and the code that calls it: js // 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. js // 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 . js 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: js 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 https://nuxt.com/docs/4.x/community/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 https://github.com/h3js/h3/issues/1437 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 https://github.com/nuxt-endpoints/nuxt-endpoints/blob/main/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 https://nitro.build/examples/hono 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/ https://nuxt-endpoints.github.io/nuxt-endpoints/ Repo: https://github.com/nuxt-endpoints/nuxt-endpoints https://github.com/nuxt-endpoints/nuxt-endpoints