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.
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 want | Write this instead |
|---|---|
A new f.* type | A field type |
A method on collection() | A builder method |
| Another queue, search or realtime layer | An adapter. See Infrastructure |
| A unit for the block editor | A file in blocks/. See Blocks |
| A directory of your own | Keep 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 use1. Write the factory
The generator does not care what your factory returns. It needs a name to scan for.
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.
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
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
import { webhook } from "../webhook";
export default webhook("stripe", {
path: "/hooks/stripe",
async handler(payload) {},
});5. Generate and read it back
questpie generateimport { 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.
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.
discover: {
webhooksConfig: { pattern: "config/webhooks.ts", configKey: "webhooks" },
},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
| Topic | Page |
|---|---|
| How a file becomes a key | Discovery |
| Shipping a plugin on npm | Publishing |
| Config files from the app author's side | Configuration |
Next
Plugins is the same contract as a reference. Every option on a category, a discover pattern and a registry, with its default.