h3-route-tools

Type-first routes for h3 v2 — validated params/body/query/response, a typed fetch client, and OpenAPI 3.1 — for plain h3 and nitro v3

10
1
10
1
TypeScript
public

h3-route-tools

Type-first routes for h3 v2 — validated params/body/query/response, a typed fetch client, and OpenAPI 3.1 — for plain h3 and nitro v3.

[!NOTE]
This is highly experimental with the main goal of exploring a fully type-first route design with a typed fetch client. The idea is based on the work done by productdevbook on the upstream h3#1143 and h3#1237.

Routes are validated with any Standard Schema validator (zod, valibot, …). The examples below use valibot.

Plain h3

Define routes with per-method validation. The validated params/query/headers are written — typed — to event.context (and mirrored read-only on event.validated); the response is validated too, and failures return 400 automatically.

import { serve } from "srvx";
import { H3Typed } from "h3-route-tools";
import * as v from "valibot";

const app = new H3Typed().route({
  route: "/posts/:id",
  params: v.object({ id: v.pipe(v.string(), v.toNumber()) }),
  get: {
    validate: { response: v.object({ id: v.number(), title: v.string() }) },
    handler: (event) => ({ id: event.context.params.id, title: "Hello" }),
  },
  post: {
    validate: {
      body: v.object({ title: v.string() }),
      response: v.object({ id: v.number() }),
    },
    handler: (event) => ({ id: event.context.params.id }),
  },
});

serve(app);

Typed fetch client

createTypedFetch<typeof app> infers the route, method, params, body, and response. Address a route by its pattern:

import { createTypedFetch } from "h3-route-tools";

// Hit the app directly, or pass `{ baseURL: "https://api.example.com" }` to use global fetch.
const api = createTypedFetch<typeof app>({ fetch: app.request });

const res = await api("/posts/:id", { method: "post", params: { id: 1 }, body: { title: "Hi" } });
const created = await res.json(); // typed: { id: number }

Requests are serialized to match what the server decodes: params are substituted into the pattern, a Date param/query value goes out as an ISO string, and an array as repeated keys (?tags=a&tags=b). res.json() is typed as the wire shape: a v.date() / z.date() response field comes back as string (that is what JSON gives you), not Date.

Custom validation errors

Out of the box, a request that fails validation is a 400 and a bad handler response a 500, both carrying the schema issues. To shape them yourself there are only two things to decide:

  • What failed — every hook receives { source, issues, event }, where source is "params" | "query" | "headers" | "body" | "response". You write one function and branch on source.
  • Where to hook it — the same (failure) => ErrorDetails | void runs at three scopes that cascade method → route → app (the narrower one wins). Return ErrorDetails to override, or nothing to fall back to the default.

That’s the whole model: one function shape, applied at the scope you need. A complete app:

import { serve } from "srvx";
import { H3Typed } from "h3-route-tools";
import * as v from "valibot";

const app = new H3Typed({
  // App-wide default: one consistent error envelope for every route.
  // (Named `onValidationError` so it doesn't shadow h3's own catch-all `onError`.)
  onValidationError: ({ source, issues, event }) => ({
    status: source === "response" ? 500 : 422,
    message: `${source} validation failed`,
    data: {
      source,
      requestId: event.req.headers.get("x-request-id"),
      issues: issues.map((i) => ({ path: i.path, message: i.message })),
    },
  }),
}).route({
  route: "/posts/:id",
  params: v.object({ id: v.pipe(v.string(), v.toNumber()) }),
  post: {
    validate: {
      body: v.object({ title: v.string() }),
      response: v.object({ id: v.number(), title: v.string() }),
    },
    handler: async (event) => {
      const { title } = await event.req.json();
      return { id: event.context.params.id, title };
    },
  },
});

serve(app);
curl -X POST localhost:3000/posts/abc -d '{"title":"hi"}'
# 422 { "message": "params validation failed", "data": { "source": "params", … } }

curl -X POST localhost:3000/posts/1 -H 'content-type: application/json' -d '{"title":42}'
# 422 { "message": "body validation failed",   "data": { "source": "body", … } }

Choosing a scope

onValidationError lives in the same place at every level — the config/def object — so it never hides in a forgettable second argument:

Scope When Where
Defaults you just want 400/500 + issues do nothing
App one envelope for the whole app new H3Typed({ onValidationError })
Route special-case a single route .route({ onValidationError, … })
Method special-case one method of a route get: { onValidationError, handler }
// All three at once — method beats route beats app:
new H3Typed({ onValidationError: appDefault }).route({
  route: "/x",
  onValidationError: forThisRoute,
  get: { onValidationError: justForGet, handler },
});

In Nitro (or anywhere without an H3Typed app), defineRouteHandler takes the same onValidationError in its definition object — there’s no app level, so set it per route file (or per method):

export default defineRouteHandler({
  onValidationError: ({ source, issues }) => ({ status: 422, data: { source, issues } }),
  get: { validate: { response: User }, handler },
});

One thing to keep in mind: a response failure always stays 500 (it’s a server-side contract breach — your message/data are used, the status is not).

onValidationError shapes the runtime response only. The OpenAPI error-response schema is separate — set it (or turn it off) globally via defineOpenAPI({ errors }) if you change the envelope and want the document to match.

Nitro v3

Add the module and write file routes whose default export is a defineRouteHandler:

// nitro.config.ts
import { defineConfig } from "nitro";

export default defineConfig({
  modules: ["h3-route-tools/nitro"],
  serverDir: "./",
  compatibilityDate: "2026-06-10",
});
// routes/posts/[id].ts
import { defineRouteHandler } from "h3-route-tools";
import * as v from "valibot";

export default defineRouteHandler({
  params: v.object({ id: v.pipe(v.string(), v.toNumber()) }),
  get: {
    validate: { response: v.object({ id: v.number(), title: v.string() }) },
    handler: (event) => ({ id: event.context.params.id, title: "Hello" }),
  },
  post: {
    validate: { body: v.object({ title: v.string() }), response: v.object({ id: v.number() }) },
    handler: (event) => ({ id: event.context.params.id }),
  },
});

The same handler serves every declared method (it self-dispatches), so a multi-method handler goes in a catch-all file (routes/posts/[id].ts). The module:

  • types nitro’s InternalApi (what $fetch and a $Fetch-typed client read) per method, from each route’s contract;
  • fails the build if a multi-method handler sits in a method-locked file (posts.get.ts), where the other methods would be silently unreachable;
  • enriches nitro’s OpenAPI document (below).

For a single method, defineValidatedHandler is a leaner primitive — method-agnostic, no self-dispatch — so it drops straight into a method-locked file:

// routes/posts/[id].get.ts
import { defineValidatedHandler } from "h3-route-tools";
import * as v from "valibot";

export default defineValidatedHandler({
  params: v.object({ id: v.pipe(v.string(), v.toNumber()) }),
  validate: { response: v.object({ id: v.number(), title: v.string() }) },
  handler: (event) => ({ id: event.context.params.id, title: "Hello" }),
});

Typed client in nitro

Use nitro’s own $fetch (response typing only), or createTypedFetch to also type params/body/query — fed a type-only route map (no runtime import, no client-bundle cost):

import { createTypedFetch } from "h3-route-tools";

type Routes = {
  "/posts/:id": typeof import("../routes/posts/[id]").default;
};

export const api = createTypedFetch<Routes>();
// api("/posts/:id", { method: "get", params: { id: 7 } })

OpenAPI

Enable nitro’s OpenAPI. The module merges your routes’ contracts into nitro’s document — keeping nitro’s entries for plain/legacy routes — and nitro’s Scalar (/_scalar) and Swagger (/_swagger) UIs render the result:

// nitro.config.ts
export default defineConfig({
  modules: ["h3-route-tools/nitro"],
  serverDir: "./",
  experimental: { openAPI: true },
  openAPI: { production: "runtime" }, // also serve it from the built server
});

Per-operation prose (defineOperation) and per-schema overrides (defineSchema) travel with the handlers, so they appear here too — see OpenAPI below.

OpenAPI

OpenAPI lives in its own opt-in entry, h3-route-tools/openapi — the core stays document-agnostic. With H3Typed, pass an openapi block and the document is served (default /openapi.json):

import { H3Typed } from "h3-route-tools";

const app = new H3Typed({
  openapi: { info: { title: "API", version: "1.0.0" } },
}).route(/* … */);
// GET /openapi.json → the generated 3.1 document

For a plain H3 app, register the plugin over your defineRoute handlers:

import { defineOpenAPI } from "h3-route-tools/openapi";

app.register(defineOpenAPI({ info: { title: "API", version: "1.0.0" } }));

The document is built from each route’s schemas — requests from a schema’s input, responses from its output (so a coercion like v.pipe(v.string(), v.toNumber()) on a param shows as string; only the accepted input reaches the wire) — with 400/415/500 responses added where relevant.

Customizing

Customization is central, in three tiers by scope.

Globaldocument (a full replacement value, or a (doc, ctx) => doc transform for what the generator can’t infer, like servers or security schemes) and errors (opt out of / override the auto error responses):

new H3Typed({
  openapi: {
    info: { title: "API", version: "1.0.0" },
    errors: false,
    document: (doc) => ({ ...doc, servers: [{ url: "https://api.example.com" }] }),
  },
});

Per operation — prose (summary, tags, deprecated, …) via defineOperation on a route/method meta.openapi; it merges over the generated operation:

import { defineOperation } from "h3-route-tools/openapi";

defineRoute({
  route: "/posts",
  get: {
    meta: { openapi: defineOperation({ summary: "List posts", tags: ["posts"] }) },
    handler,
  },
});

Per schema — override a schema’s emitted JSON Schema with defineSchema (which also assigns $id, lifting the schema into components). This is how you describe a shape the library can’t infer:

import { defineSchema } from "h3-route-tools/openapi";

// a Date has no JSON Schema type — give it the wire form:
const When = defineSchema(v.date(), { jsonSchema: { type: "string", format: "date-time" } });

defineSchema wraps a whole slot (a body/response/params schema), not a nested field. For a nested field use your validator’s metadata — zod’s .meta({ … }) merges into the output at any depth — or patch the assembled slot with the function form, defineSchema(schema, { jsonSchema: (auto) => ({ ...auto, … }) }).

JSON Schema support

Validation and TypeScript types work with any Standard Schema validator. OpenAPI generation needs one thing more: the schema must expose a JSON Schema through ~standard.jsonSchema. That’s the only thing checked — no per-library special-casing.

  • zod (v4) — native; works out of the box, and .meta() merges arbitrary keywords into the output.
  • valibot — not on its own; wrap schemas with @valibot/to-json-schema’s toStandardJsonSchema(). Its adapter drops arbitrary metadata, so describe un-inferrable shapes with defineSchema (which works even on a bare valibot schema).

A Date, a transform’s output, and other un-representable types emit {} — an empty, permissive schema — leaving validation and TS types untouched, only the document. Give them a shape with defineSchema / .meta() / the document hook above.

License

MIT

v0.3.3[beta]