npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

h3-route-tools

v0.1.1

Published

Experimental library to generate typed routes for H3

Readme

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