Creating your first plugin
A plugin teaches Kubb to generate something new. It owns its output folder and file naming, runs generators that walk the AST, and hooks into the build lifecycle.
This guide builds a kubb-plugin-example package from scratch and publishes it to npm.
TIP
Before writing a plugin, check the Plugins registry. An existing plugin may already cover your case.
Prerequisites
You need:
- Node.js 22 or higher and pnpm (or npm/yarn)
- Working TypeScript knowledge
- A Kubb project with a valid configuration
- A read of the plugin concepts page
Quick start
A plugin is a factory function built with definePlugin from @kubb/core. It returns an object with a name string and a hooks map.
The kubb:plugin:setup hook is where you wire generators and resolvers into the build.
import { , , } from '@kubb/core'
export const = (() => ({
: 'plugin-hello',
: {
'kubb:plugin:setup'() {
.(
({
: 'hello-generator',
(, ) {
return [
..({
: `${.}.ts`,
: `${.}/${.}.ts`,
: [
..({
: [..(`// ${.} ${.}\n`)],
}),
],
}),
]
},
}),
)
},
},
}))Wire it into kubb.config.ts:
import { } from 'kubb'
import { } from './my-plugin.ts'
export default ({
: { : './petStore.yaml' },
: { : './src/gen' },
: [()],
})Run the CLI to see it work:
kubb generateProject layout
Every official Kubb plugin uses the same layout, one folder per concern: generators/, resolvers/, components/, and templates/. The reference implementation is @kubb/plugin-axios. Mirror it so other contributors find their way around:
kubb-plugin-example/
├── src/
│ ├── index.ts # Public exports (factory, generators, resolvers, types)
│ ├── plugin.ts # definePlugin factory + plugin<Name>Name constant
│ ├── types.ts # PluginExample = PluginFactoryOptions<...>
│ ├── generators/ # One file per generator (e.g. operationsGenerator.ts)
│ │ └── exampleGenerator.ts
│ ├── resolvers/ # One file per resolver
│ │ └── resolverExample.ts
│ ├── components/ # Optional: JSX components when using @kubb/renderer-jsx
│ └── templates/ # Optional: source templates exposed at runtime
├── mocks/ # OpenAPI fixtures consumed by tests
│ └── petStore.yaml
├── package.json
├── tsconfig.json
└── README.mdTIP
In @kubb/plugin-axios, src/index.ts re-exports each generator, resolver, and the plugin factory by name. src/plugin.ts declares a pluginAxiosName satisfies PluginAxios['name'] constant that other plugins consume.
Scaffold the directories:
mkdir kubb-plugin-example && cd kubb-plugin-example
npm init -y
npm install --save-peer @kubb/core@beta @kubb/ast@beta
npm install --save-dev typescript @types/node vitest
mkdir -p src/generators src/resolvers mocksNaming conventions
Match the package name and internal identifiers to Kubb conventions so the registry and other tooling find them.
| Surface | Pattern | Example |
|---|---|---|
| npm package (official) | @kubb/plugin-<name> | @kubb/plugin-ts |
| npm package (community) | kubb-plugin-<name> | kubb-plugin-example |
| Runtime plugin name | plugin-<name> (kebab-case, lowercase) | 'plugin-example' |
| Factory export | plugin<Name> (camelCase) | pluginExample |
| Name constant | plugin<Name>Name | pluginExampleName |
Use satisfies to export a typed name constant. Other plugins then reference it without typos:
import type { } from '@kubb/core'
export const = 'plugin-example' satisfies ['name']IMPORTANT
Use kubb-plugin-<name> for community packages. The @kubb/plugin-* namespace is reserved for official Kubb Labs packages.
Plugin anatomy
Four files form the skeleton. Read them in this order: types first, then the implementation, then the public entry point.
// @filename: src/types.ts
import type { } from '@kubb/core'
/** User-facing options for kubb-plugin-example. */
export interface PluginExampleOptions {
/** Output filename for the generated operations index. Defaults to `'operations.ts'`. */
?: string
/** Whether to emit the operations index file. Defaults to `true`. */
?: boolean
}
/**
* `PluginFactoryOptions` binds the plugin name, the user-facing option type,
* and the resolved option type together so generators, resolvers, and the
* build loop share a consistent interface.
*/
export type = <'plugin-example', PluginExampleOptions, <PluginExampleOptions>>
// @filename: src/generators/exampleGenerator.ts
import { , } from '@kubb/core'
import type { } from '../types'
/**
* Creates a generator that emits one file per operation and, optionally,
* an index file listing every operation ID.
*
* `defineGenerator` returns a `TElement | Array<FileNode> | void` union so
* handlers may return a single element, an array, or nothing.
*/
export function (: `${string}.${string}`, : boolean) {
const : string[] = []
return <>({
: 'example-generator',
(, ) {
// OperationNode.operationId is a required string, so no nullability guard is needed.
.(.)
return [
..({
: `${.}.ts`,
: `${.}/${.}.ts`,
: [
..({
: [..(`// ${.} ${.}\n`), ..(`export const operationId = '${.}'\n`)],
}),
],
}),
]
},
async (, ) {
if (!) return
return [
..({
: ,
: `${.}/${}`,
: [
..({
: [..(`export const operations = ${.()}\n`)],
}),
],
}),
]
},
})
}
// @filename: src/resolvers/resolverExample.ts
import { } from '@kubb/core'
import type { } from '../types'
/**
* `defineResolver` automatically injects defaults for `default`, `resolveOptions`,
* `resolvePath`, `resolveFile`, `resolveBanner`, and `resolveFooter`.
* Only `name` and `pluginName` are required in the builder object.
* Override any of the injected methods when you need custom naming or path logic.
*/
export const = <>(() => ({
: 'default',
: 'plugin-example',
}))
// @filename: src/plugin.ts
import { } from '@kubb/core'
import type { } from '@kubb/core'
import type { } from './types'
import { } from './generators/exampleGenerator'
import { } from './resolvers/resolverExample'
export const = 'plugin-example' satisfies ['name']
export const = <>(() => {
const = (?. ?? 'operations.ts') as `${string}.${string}`
const = ?. ?? true
return {
: ,
: {
'kubb:plugin:setup'() {
.()
.((, ))
.({ , })
},
},
}
})
// @filename: src/index.ts
export { } from './generators/exampleGenerator'
export { } from './resolvers/resolverExample'
export { , } from './plugin'
export type { , } from './types'Generators
A generator walks the AST produced by the adapter and emits FileNodes. Register generators in kubb:plugin:setup with ctx.addGenerator. Each generator implements any combination of three handlers:
| Handler | Called for | Return type |
|---|---|---|
schema | Each SchemaNode in the AST | Array<FileNode>, an element, or null/undefined |
operation | Each OperationNode in the AST | Array<FileNode>, an element, or null/undefined |
operations | Once with all OperationNodes after the operation walk | Array<FileNode>, an element, or null/undefined |
Each handler can return a Promise of any of these.
Emit roles
Most generators return Array<FileNode> built with the create* factories from @kubb/ast. That is the default. Three named roles cover the cases beyond it.
A printer renders one SchemaNode to a string, such as a TypeScript type or a z.object({ ... }). A handler calls it and stages the result on a FileNode.
A renderer turns JSX into FileNodes. Return an element instead of Array<FileNode>, set renderer: jsxRenderer on the generator, and @kubb/renderer-jsx walks the JSX into the same nodes the builder produces. It is sugar over the builder, not a second pipeline.
A parser handles serialization and runs last. It belongs to the build driver. Once every plugin finishes, the matching parser writes each FileNode out as the final file string. Plugins never call it.
src/generators/exampleGenerator.ts
Inside a handler, ctx is a GeneratorContext. It carries helpers such as addFile, upsertFile, getResolver, requirePlugin, warn, error, and info, plus the resolved config, root, adapter, and document meta. The meta is an InputMeta with title, version, baseURL, circularNames, and enumNames.
import { , } from '@kubb/core'
const = ({
: 'operation-files',
(, ) {
// node.operationId is a required string on OperationNode.
return [
..({
: `${.}.ts`,
: `${.}/${.}.ts`,
: [
..({
: [..(`// Generated from ${.} ${.}\n`), ..(`export const operationId = '${.}'\n`)],
}),
],
}),
]
},
(, ) {
// Runs for each SchemaNode. Return void to skip emitting a file.
.(`Visiting schema: ${.}`)
return []
},
})Resolvers
A resolver decides the file names and output paths for a plugin's files. Other plugins call ctx.getResolver('plugin-example') to reuse those names without hard-coding paths.
src/resolvers/resolverExample.ts
defineResolver fills in defaults for every resolver method. Provide name and pluginName in the builder, then override the methods you want to change. Returning null from resolveOptions drops the node from generation, so return null only when you mean to filter a node out.
import { } from '@kubb/core'
import from 'node:path'
import type { , } from '@kubb/core'
type = <'plugin-example', object, object, >
export const = <>(() => ({
: 'default',
: 'plugin-example',
// Override resolvePath to place files in a custom sub-folder.
({ }, { , }) {
return .(, ., 'example', )
},
}))The setup context
kubb:plugin:setup receives a KubbPluginSetupContext that wires the plugin into the build. The full interface from @kubb/core:
| Method / Property | Purpose |
|---|---|
addGenerator | Register one or more Generators for the AST walk. Pass them as separate arguments, or spread an existing list. |
setResolver | Set or override the resolver (file naming and paths). |
addMacro | Add a macro that rewrites AST nodes before generators. |
setMacros | Replace this plugin's macros with a new list. |
setOptions | Provide resolved options to the build loop. |
injectFile | Inject a raw UserFileNode into the build, bypassing generators. |
updateConfig | Merge a partial config update into the running build. |
config | The resolved Config at setup time. |
options | The user-supplied plugin options. |
import { } from 'node:url'
import { , , } from '@kubb/core'
export const = (() => ({
: 'plugin-example',
: {
'kubb:plugin:setup'() {
// ctx.config gives access to the full Kubb configuration.
const = ...
// Register a generator that emits one file per operation.
.(
({
: 'example-generator',
(, ) {
return [
..({
: `${.}.ts`,
: `${.}/${.}.ts`,
: [..({ : [..(`// output: ${}\n`)] })],
}),
]
},
}),
)
// Inject a static file directly, bypassing generators entirely.
.({
: 'README.md',
: `${}/README.md`,
: [{ : 'Source', : [{ : 'Text', : '# Generated\n' }] }],
})
// Copy a real file shipped in your package into the output, verbatim.
.({
: 'runtime.ts',
: `${}/runtime.ts`,
: (new ('../templates/runtime.ts', import.meta.)),
})
},
},
}))Set copy to an absolute on-disk path and Kubb writes that file's content into the output unchanged. It applies only banner/footer and skips the language parser. This keeps a hand-authored template as a real .ts file, linted, type-checked, and tested, and drops it into the generated folder without inlining its source as a string. The JSX renderer takes the same field: <File baseName="runtime.ts" path={…} copy={templatePath} />.
Options
PluginFactoryOptions binds the plugin name, the user-facing options, and the resolved options together. The type flows through definePlugin, defineGenerator, and the resolver, keeping all three in sync.
import { } from '@kubb/core'
import type { } from '@kubb/core'
interface PluginExampleOptions {
/** Output filename for the index. Defaults to `'operations.ts'`. */
?: string
/** Whether to emit the index file. Defaults to `true`. */
?: boolean
}
type = <'plugin-example', PluginExampleOptions, <PluginExampleOptions>>
export const = <>(() => {
// Apply defaults in the factory closure so each build invocation
// gets its own resolved copy.
const = ?. ?? 'operations.ts'
const = ?. ?? true
return {
: 'plugin-example',
: {
'kubb:plugin:setup'() {
// Store the resolved options so generators can read them from ctx.plugin.options.
.({ , })
},
},
}
})Testing
Use createKubb from @kubb/core to run an in-process build and check that your generator emits the files you expect. Pair it with a small OpenAPI fixture so tests stay fast and predictable.
@kubb/core does not apply the default adapter or parsers, so pass adapter: adapterOas() and the parsers your generator emits. (The kubb package's defineConfig is what wires those up automatically.) Without an adapter, Kubb runs in plugin-only mode and the operation and schema handlers never fire.
import { , , } from 'vitest'
import { , , , } from '@kubb/core'
import { } from '@kubb/adapter-oas'
import { } from '@kubb/parser-ts'
const = (() => ({
: 'plugin-example',
: {
'kubb:plugin:setup'() {
.(
({
: 'example-generator',
(, ) {
return [
..({
: `${.}.ts`,
: `${.}/${.}.ts`,
: [..({ : [..(`// ${.}\n`)] })],
}),
]
},
}),
)
},
},
}))
('pluginExample', () => {
('emits one file per operation', async () => {
const = ({
: { : './test/fixtures/petStore.yaml' },
: { : './dist/test' },
: (),
: [],
: [()],
})
const { } = await .()
(.).(0)
})
})Observing lifecycle events
Subscribe to kubb.hooks before you call build() to trace plugin activity or collect metrics. Each hook receives one typed context object.
import { , } from '@kubb/core'
import { } from '@kubb/adapter-oas'
const = ({
: { : './petStore.yaml' },
: { : './gen' },
: (),
: [(() => ({ : 'plugin-example', : {} }))()],
})
// kubb:plugin:end receives a single KubbPluginEndContext, not two separate arguments.
..('kubb:plugin:end', ({ , , }) => {
.(`[${.}] finished in ${}ms (ok=${})`)
})
// kubb:files:processing:update fires once per flush batch with an array of per-file updates.
..('kubb:files:processing:update', ({ }) => {
for (const { , , , } of ) {
.(`[${}/${}] (${.(0)}%) ${.}`)
}
})
await .()Publishing your plugin
Configure package.json
Peer-depend on @kubb/core and @kubb/ast at v5 to keep the runtime out of your bundle. List them under devDependencies too, for local builds.
{
"name": "kubb-plugin-example",
"version": "1.0.0",
"description": "A Kubb plugin that generates example files from OpenAPI specs.",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"test": "vitest",
"prepublishOnly": "npm run build && npm test"
},
"peerDependencies": {
"@kubb/core": "^5.0.0",
"@kubb/ast": "^5.0.0"
},
"devDependencies": {
"@kubb/ast": "^5.0.0",
"@kubb/core": "^5.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.0.0",
"vitest": "^3.0.0"
},
"keywords": ["kubb", "plugin", "openapi", "codegen"],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/yourname/kubb-plugin-example"
}
}Publish to npm
Before you publish, run through the checklist:
- Exported TypeScript types compile without errors
- Public APIs carry JSDoc comments
- The README covers installation and usage
- All tests pass
- The version follows Semantic Versioning
See the npm publishing docs for the full workflow:
npm login
npm publish --access publictsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "test"]
}Examples
The kubb-labs/plugins repository holds the official plugins that follow these conventions. Read the source to see how generators, resolvers, and options fit together in published packages.
Schema generator
Generate a file for each schema definition in the spec:
import { , } from '@kubb/core'
export const = ({
: 'schema-generator',
(, ) {
return [
..({
: `${.}.ts`,
: `${.}/${.}.ts`,
: [
..({
: [..(`// Schema: ${.}\nexport type ${.} = unknown\n`)],
}),
],
}),
]
},
})Extending an existing plugin
Declare dependencies when your plugin must run after another. Kubb verifies the dependency at startup and throws when it is missing:
import { , , } from '@kubb/core'
export const = (() => ({
: 'plugin-custom',
// plugin-ts must be registered before plugin-custom starts.
: ['plugin-ts'],
: {
'kubb:plugin:setup'() {
.(
({
: 'custom-generator',
(, ) {
// Use the plugin-ts resolver for consistent naming.
const = .('plugin-ts')
const = .(., 'function')
return [
..({
: `${}.custom.ts`,
: `${.}/${}.custom.ts`,
: [..({ : [..(`// extends ${}\n`)] })],
}),
]
},
}),
)
},
},
}))