Beta You're reading the docs for Kubb v5, which is currently in beta. View the stable v4 docs
Skip to content

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.

my-plugin.ts
typescript
import { , ,  } from '@kubb/core'

export const  = (() => ({
  : 'plugin-hello',
  : {
    'kubb:plugin:setup'() {
      .(
        ({
          : 'hello-generator',
          (, ) {
            return [
              ..({
                : `${.}.ts`,
                : `${.}/${.}.ts`,
                : [
                  ..({
                    : [..(`// ${.} ${.}\n`)],
                  }),
                ],
              }),
            ]
          },
        }),
      )
    },
  },
}))

Wire it into kubb.config.ts:

kubb.config.ts
typescript
import {  } from 'kubb'
import {  } from './my-plugin.ts'
Cannot find module './my-plugin.ts' or its corresponding type declarations.
export default ({ : { : './petStore.yaml' }, : { : './src/gen' }, : [()], })

Run the CLI to see it work:

Terminal
shell
kubb generate

Project 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:

Resulting tree
text
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.md

TIP

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:

Terminal
shell
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 mocks

Naming 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:

pluginExampleName.ts
typescript
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.

Plugin anatomy
typescript
// @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.

exampleGenerator.ts
typescript
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.

resolvers.ts
typescript
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.
setup-context.ts
typescript
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.

options.ts
typescript
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.

plugin.test.ts
typescript
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.

lifecycle.ts
typescript
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.

package.json
json
{
  "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:

Terminal
shell
npm login
npm publish --access public

tsconfig.json

tsconfig.json
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:

schema-generator.ts
typescript
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:

plugin-with-dep.ts
typescript
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`)] })],
              }),
            ]
          },
        }),
      )
    },
  },
}))