{"slug": "nuxt-server-routes-with-hono-rpc-style-type-safety-no-rewrite", "title": "Nuxt server routes with Hono RPC-style type safety, no rewrite", "summary": "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.", "body_md": "Here is a Nuxt route written the way the docs recommend, and the code that calls it:\n\n``` js\n// server/api/users/[id].get.ts\nimport { z } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const { id } = await getValidatedRouterParams(event, z.object({\n    id: z.coerce.number(),\n  }).parse)\n\n  const user = await findUser(id)\n  if (!user) throw createError({ statusCode: 404, statusMessage: 'Not found' })\n  return user\n})\njs\n// somewhere in the app\nconst user = await $fetch(`/api/users/${id}`)\nuser.name // typed — inferred from the handler's return\n```\n\nThere is nothing wrong with this. h3 ships `getValidatedRouterParams`\n\nand `readValidatedBody`\n\n, they take any Standard Schema validator, and Nuxt's typed `$fetch`\n\ninfers the success type from the handler. Request validation and response types are both solved problems.\n\nWhat 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`\n\n. And if you publish API docs, that's a third description of the same endpoint, hand-written, accurate the week it was written.\n\nThree descriptions of one contract — handler, caller, docs — held together by discipline. You don't need convincing that this drifts.\n\n**Nuxt Endpoints** collapses them into one. You swap `defineEventHandler`\n\nfor `defineEndpoint`\n\nand 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`\n\n, and Nitro's routing. If you know Hono RPC, the client will feel familiar; the difference is that no file moved to get it.\n\n``` js\n// server/api/users/[id].get.ts — same file, same path, same method\nimport { z } from 'zod'\n\nexport default defineEndpoint({\n  params: z.object({ id: z.coerce.number() }),\n  handler: ({ params }) => {\n    return findUser(params.id) // params.id is number, already validated\n  },\n})\npython\n// app code — nothing to import\nconst user = await $endpoint('/api/users/:id', {\n  method: 'get',\n  params: { id: '1' },\n})\n\nuser.name // inferred from the handler's return, as before\n```\n\nNo 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`\n\n, still callable from curl. What changed is that the contract now exists as a value the rest of the module can read.\n\nAnd 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.\n\nThe example above declares no response schemas — and client types still work, inferred from the handler's return exactly like typed `$fetch`\n\n. That's deliberate: adopting the module costs nothing on day one, so the first route is a cheap experiment rather than a decision.\n\n```\nexport default defineEndpoint({\n  operation: 'getUser', // optional; names the route for the generated helpers\n  params: z.object({ id: z.coerce.number() }),\n  // added once the route earned it. Without this key the client types come\n  // from the handler's return, same as typed $fetch:\n  responses: {\n    200: User,\n    404: z.object({ message: z.string() }),\n  },\n  handler: ({ params, respond }) => {\n    const user = findUser(params.id)\n    return user ?? respond(404, { message: 'Not found' }) // type-checked against `responses`\n  },\n})\n```\n\nDelete the `responses`\n\nkey and you're back to inference-only typing. Swap `defineEndpoint`\n\nfor a plain `defineEventHandler`\n\nand the route is an ordinary Nitro route again. There's no migration event — just a gradient, and you can stop anywhere on it.\n\nDeclaring responses buys something typed `$fetch`\n\ncan't give you: the failure cases stop being `unknown`\n\n.\n\n``` js\nconst result = await $endpoint('/api/users/:id', {\n  method: 'get',\n  params: { id: '123' },\n}).result()\n\nif (result.status === 200) {\n  result.body.name // User\n}\nif (result.status === 404) {\n  result.body.message // typed from the 404 schema\n}\n```\n\n`.result()`\n\nreturns a discriminated union keyed on the status code. Branch on it and the body narrows — no `instanceof FetchError`\n\n, no casting `error.data`\n\n, no reading the server route to remember what a 404 contains.\n\n`$endpoint`\n\nis a one-off call. When a component needs the usual `data`\n\n/ `pending`\n\n/ `error`\n\n/ `refresh`\n\nshape — the same thing `useFetch`\n\ngives you — call the same typed endpoint through `useEndpoint`\n\ninstead:\n\n```\nconst { data: user, pending, error, refresh } = await useEndpoint('/api/users/:id', {\n  method: 'get',\n  params: { id: '123' },\n  key: 'user:123',\n})\n\nuser.value?.name // typed, same as $endpoint\n```\n\nIt forwards the Nuxt async-data options you already know — `key`\n\n, `lazy`\n\n, `server`\n\n, `watch`\n\n, `default`\n\n— while `params`\n\n, `query`\n\n, `headers`\n\n, and `body`\n\nstay typed from the contract. If `useFetch`\n\nis where you'd normally reach for a route, `useEndpoint`\n\nis that same reach, pointed at a contract instead of a URL string.\n\n`useEndpoint`\n\ncovers 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`\n\n.\n\nThe 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.\n\nInstall `@tanstack/vue-query`\n\n, and every endpoint carrying an `operation`\n\nname — `getUser`\n\nabove — generates option factories instead:\n\n``` js\nimport { useQuery } from '@tanstack/vue-query'\nimport { endpointQueryOptions } from '#endpoints/query'\n\nconst route = useRoute()\nconst user = useQuery(\n  endpointQueryOptions.getUser(() => ({\n    params: { id: String(route.params.id) },\n  })),\n)\n\nuser.data.value?.name // User\n```\n\nThese 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`\n\nwith typed variables. If you want SSR, a request-scoped QueryClient with hydration is one config flag (`endpoints.client.query.setup`\n\n).\n\n`GET /_endpoints/schema`\n\nserves 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.\n\nSchemas can't express everything — auth schemes, server URLs, tags. Those go in one file, merged into the generated document:\n\n```\n// server/endpoints/runtime.ts\nexport default defineEndpointRuntime({\n  openApi: {\n    document: {\n      servers: [{ url: 'https://api.example.com' }],\n      components: {\n        securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } },\n      },\n    },\n    extend(document) {\n      document.security = [{ bearerAuth: [] }]\n    },\n  },\n})\n```\n\n`document`\n\nis deep-merged; `extend`\n\nruns last on the merged result, for anything a patch can't reach.\n\nIf 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`\n\n, 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.\n\nThe 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.\n\nThat 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`\n\n, not your schemas.\n\nThe 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.\n\n**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.\n\n**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.\n\n**nuxt-open-fetch** points the other way: spec first, client derived. The right tool when someone else owns the spec.\n\n**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.\n\n`Response`\n\ns, 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\n\nbreaks, or just that the API reads wrong to you.\n\nDocs and a type playground: [https://nuxt-endpoints.github.io/nuxt-endpoints/](https://nuxt-endpoints.github.io/nuxt-endpoints/)\n\nRepo: [https://github.com/nuxt-endpoints/nuxt-endpoints](https://github.com/nuxt-endpoints/nuxt-endpoints)", "url": "https://wpnews.pro/news/nuxt-server-routes-with-hono-rpc-style-type-safety-no-rewrite", "canonical_source": "https://dev.to/yoshinoriishii/nuxt-server-routes-with-hono-rpc-style-type-safety-no-rewrite-205k", "published_at": "2026-08-26 08:35:32+00:00", "updated_at": "2026-08-26 08:44:41.015042+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Nuxt", "Hono RPC", "Nitro", "zod", "h3"], "alternates": {"html": "https://wpnews.pro/news/nuxt-server-routes-with-hono-rpc-style-type-safety-no-rewrite", "markdown": "https://wpnews.pro/news/nuxt-server-routes-with-hono-rpc-style-type-safety-no-rewrite.md", "text": "https://wpnews.pro/news/nuxt-server-routes-with-hono-rpc-style-type-safety-no-rewrite.txt", "jsonld": "https://wpnews.pro/news/nuxt-server-routes-with-hono-rpc-style-type-safety-no-rewrite.jsonld"}}