QUESTPIE
Guides

Building a plugin

A plugin teaches the generator a directory of your own. Declare it once, and every app that installs your package picks up that convention, with no central registry to edit.

View markdown

You want a directory of your own. Drop a file into webhooks/, and have the generator collect it the way it collects a collection. This page builds the plugin that does that, from an empty file to a working result.

Check that you need one

Most extension is not a plugin. A plugin adds a new place for the generator to look. If you want a new value where it already looks, write the value.

You wantWrite this instead
A new f.* typeA field type
A method on collection()A builder method
Another queue, search or realtime layerAn adapter. See Infrastructure
A unit for the block editorA file in blocks/. See Blocks
A directory of your ownKeep reading

What you are building

Four files. The first two are yours. The last two are what someone using your plugin writes.

src/questpie/server/
  webhook.ts           # the factory your files call
  plugin.ts            # the plugin: build time here, also loaded at runtime once shipped in a module
  questpie.config.ts   # registers it
  webhooks/stripe.ts   # the new convention, in use

1. Write the factory

The generator does not care what your factory returns. It needs a name to scan for.

src/questpie/server/webhook.ts
export function webhook(
	name: string,
	config: { path: string; handler: (payload: unknown) => Promise<void> },
) {
	return { name, ...config };
}

2. Declare the convention

A plugin is one object. It has a name and target contributions. The server target writes index.ts, so root and outputFile must match the core plugin.

src/questpie/server/plugin.ts
import type { CodegenPlugin } from "questpie/codegen";

export function webhooksPlugin(): CodegenPlugin {
	return {
		name: "acme-webhooks",
		targets: {
			server: {
				root: ".",
				outputFile: "index.ts",
				categories: {
					webhooks: {
						dirs: ["webhooks"],
						prefix: "webhook",
						factoryFunctions: ["webhook"],
						registryKey: true,
					},
				},
			},
		},
	};
}

dirs is what to scan. prefix names the variables in the generated file. factoryFunctions is the switch that matters. It says a webhook() call marks an entity. One file can then hold several, and a helper file holds none.

3. Register it

src/questpie/server/questpie.config.ts
import { runtimeConfig } from "questpie/app";

import { webhooksPlugin } from "./plugin";

export default runtimeConfig({
	db: { url: process.env.DATABASE_URL! },
	plugins: [webhooksPlugin()],
});

`plugins` is not `modules.ts`

plugins takes codegen plugins. modules.ts takes package dependencies. A module can carry its own plugin, so most apps never write plugins at all. The core plugin is prepended on every run and you never register it.

4. Use the convention

src/questpie/server/webhooks/stripe.ts
import { webhook } from "../webhook";

export default webhook("stripe", {
	path: "/hooks/stripe",
	async handler(payload) {},
});

5. Generate and read it back

questpie generate
import { app, type AppWebhooks } from "#questpie";

const webhooks = app.state?.webhooks as AppWebhooks;
webhooks.stripe.path; // "/hooks/stripe"

That is the working result. The key is stripe because the first string argument wins over the file name, with kebab-case turned to camelCase.

A built-in category such as collections gets a home of its own on the app. Yours lands under app.state, typed Record<string, unknown>. So you cast, the way @questpie/mcp does. Codegen names the type after your category.

If your factory returns a builder with a .build() method, the app calls it for you and stores the built value.

Add a scaffold

Add a template and your users get a questpie add command for it.

src/questpie/server/plugin.ts
scaffolds: {
	webhook: {
		dir: "webhooks",
		description: "Webhook handler",
		template: ({ kebab, camel }) =>
			`import { webhook } from "../webhook";\n\nexport const ${camel} = webhook("${kebab}", {\n\tpath: "/hooks/${kebab}",\n\tasync handler(payload) {},\n});\n`,
	},
},

questpie add webhook stripe now writes the file. questpie add --list shows it beside the built-in types. An existing file is skipped with a warning.

Claim a config file

Your plugin probably needs settings. Claim a file instead of a runtimeConfig key. Add a discover pattern, then type the key so users get autocomplete.

src/questpie/server/plugin.ts
discover: {
	webhooksConfig: { pattern: "config/webhooks.ts", configKey: "webhooks" },
},
src/questpie/server/plugin.ts, at the top level
declare module "questpie" {
	interface AppStateConfig {
		webhooks?: { secret: string };
	}
}

A config/webhooks.ts now lands on app.state.config.webhooks. Pick a key no other plugin will claim, because nothing checks. Two modules that set the same key are last-wins on the whole object. Only app, auth and admin merge below the key.

Ship it

Move plugin.ts into a module directory in your package. Codegen puts it on that module's plugin key. The consuming app picks it up from modules.ts with no extra config. That is how @questpie/admin and @questpie/mcp install themselves. Publishing has the layout.

The plugin key holds a live value, not a specifier. On this path the plugin file and everything it imports load in the server process of every app that lists your module, so keep those imports light. Giving the plugin its own entry point, like @acme/webhooks/plugin, helps only on the other path, where an app registers it by hand in questpie.config.ts.

Where each topic lives

TopicPage
How a file becomes a keyDiscovery
Shipping a plugin on npmPublishing
Config files from the app author's sideConfiguration

Next

Plugins is the same contract as a reference. Every option on a category, a discover pattern and a registry, with its default.

On this page