PluginsREST API

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

plugins/site-notes/src/api/modules/notes/list.route.ts
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

plugins/site-notes/src/api/modules/notes/notes.module.ts
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], 
})
plugins/site-notes/src/config.api.ts
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:

plugins/site-notes/src/config.api.ts
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):

apps/web/src/vitnode.api.config.ts
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

plugins/site-notes/src/features/notes/notes-query.ts
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.