API Modules
Add a typed Hono API module to a VitNode plugin, register it once, and keep endpoint ownership with the feature.
Start with a plugin, not a host endpoint. A module groups the plugin's Hono routes under one URL prefix and gives OpenAPI a tidy place to describe them.
A generated plugin already has one
create-vitnode-app --plugin writes a hello module, its config.api.ts and
a page that calls it. Read on for what each piece does—then rename them, or add
a second module beside them.
Define one plugin endpoint
import { z } from '@hono/zod-openapi'
import { buildRoute } from '@vitnode/core/api/lib/route'
export const listNotesRoute = buildRoute({
pluginId: '@acme/site-notes',
route: {
method: 'get',
path: '/',
responses: {
200: {
content: {
'application/json': {
schema: z.object({ notes: z.array(z.string()) }),
},
},
description: 'Published site notes.',
},
},
},
handler: (c) => c.json({ notes: ['Hello plugin'] }),
})Group it and export the plugin API
import { buildModule } from '@vitnode/core/api/lib/module'
import { listNotesRoute } from './list.route'
export const notesModule = buildModule({
name: 'notes',
pluginId: '@acme/site-notes',
routes: [listNotesRoute],
})import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'
import { notesModule } from './api/modules/notes/notes.module'
export const siteNotesApiPlugin = () =>
buildApiPlugin({
modules: [notesModule],
pluginId: '@acme/site-notes',
})This file, and not config.tsx: the API config reaches your handlers, database
and secrets, while config.tsx is read by the browser build. A module
registered in the wrong one is shipped to every visitor.
buildApiPlugin keeps everything it was given as a literal type - the plugin
id, the tuple of modules, and every module nested inside them. That type is
what the fetcher infers routes from, so a route you add
here is callable, and checked, the moment you save.
Export the type an application registers
Your pages call the endpoint through the universal fetcher, and the fetcher
looks the plugin up in the API plugin registry. Export one reduced type beside
your factory - create-vitnode-app --plugin writes this for you:
import type { ApiPluginContract } from '@vitnode/core/api/lib/plugin'
import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'
export const siteNotesApiPlugin = () =>
buildApiPlugin({ modules: [notesModule], pluginId: '@acme/site-notes' })
export type VitNodeApiPlugin = ApiPluginContract<
ReturnType<typeof siteNotesApiPlugin>
>ApiPluginContract keeps the four things a call needs - the literal plugin id,
every module path you serve, your module tree and your route definitions - and
drops everything else. Your Hono application, content models, event listeners,
queue tasks, WebSockets, search indexers and messages are runtime concerns, so
an app that installs your plugin never resolves them to type-check a call.
import type is the whole point: the factory is never executed here, so none of
the Hono, database or secret code behind it is ever reached by a browser build.
A plugin never registers itself
Your package augments nothing. The app that installs you writes the entry into
its own generated src/api-registry.gen.ts, from the plugins it configured. A
package that registered itself would put its routes into the registry of every
project that merely installed it.
To type-check your own pages before any app has installed the plugin, the
scaffold writes test-fixtures/api-registry.d.ts - the same entry an app
generates, kept outside src and out of the published package.
Compose it in the app API config
The app decides which installed plugins are active. Add the factory to the Hono
config that serves your app (apps/web for a single app, or apps/api when it
is separate):
import { siteNotesApiPlugin } from '@acme/site-notes/config.api'
export const vitNodeApiConfig = buildApiConfig({
plugins: [siteNotesApiPlugin()],
})The endpoint is now GET /api/@acme/site-notes/notes. OpenAPI picks it up too;
one less hand-written map to maintain. This is the one place siteNotesApiPlugin()
is ever called - everywhere else it is a type.
Call it
import { fetcher } from '@vitnode/core/tanstack/fetcher'
const response = await fetcher({
plugin: '@acme/site-notes',
method: 'get',
module: 'notes',
path: '/',
})
const { notes } = await response.json()Nothing but the plugin id crosses into the browser bundle. module, path,
method and the response are inferred from the module tree registered above,
and a typo in any of them is a compile error.