# Nuxt server routes with Hono RPC-style type safety, no rewrite

> Source: <https://dev.to/yoshinoriishii/nuxt-server-routes-with-hono-rpc-style-type-safety-no-rewrite-205k>
> Published: 2026-08-26 08:35:32+00:00

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)
