Plugins
Extend your CMS with a small, typed SDK — install a ready-made plugin, or write your own right inside your project. A recipe-driven guide.
A plugin is how you add features to your CMS without touching core: content types, custom field inputs, editor buttons, admin screens, API endpoints, background jobs, and reactions to things like publishing. They're plain TypeScript objects — no config DSL, no runtime magic.
There are two ways to get one:
Install a ready-made plugin
Add an npm package, register it in one file, restart.
Write your own
A plugin can live right inside your project — no package, no publishing.
This guide assumes you scaffolded your project with pnpm create aphex (or npm create aphex). You
have a normal standalone project — no monorepo, no pnpm workspace needed. Everything below is
pnpm add and editing files in your own src/.
The one file you edit
Your project has a single plugin registry at src/lib/plugins.ts. It starts empty:
import type { CMSPlugin } from '@aphexcms/cms-core';
export const plugins: CMSPlugin[] = [];Every plugin — installed or homegrown — goes in this array. Your project already wires it into both places that need it (the server engine via aphex.config.ts, and the admin UI), so you never touch anything else. Add to the array, restart, done.
Use a ready-made plugin
Install it
pnpm add @aphexcms/plugin-color-pickernpm install @aphexcms/plugin-color-pickeryarn add @aphexcms/plugin-color-pickerRegister it
import type { CMSPlugin } from '@aphexcms/cms-core';
import { colorPickerPlugin } from '@aphexcms/plugin-color-picker';
export const plugins: CMSPlugin[] = [colorPickerPlugin()];Restart the dev server
Plugins are read at boot. Restart, and the feature is live — in this case a color field type you can use in any schema.
That's the whole flow for any published plugin. Some plugins take options (somePlugin({ collections: ['post'] })); check the plugin's README.
Write your own plugin
A plugin doesn't have to be a package. The fastest way to learn the SDK is to write one inside your project — a normal file you import. Here's a complete, working plugin that runs code every time a document is published:
Create the file
import { definePlugin } from '@aphexcms/cms-core';
export const onPublishPlugin = definePlugin({
name: 'on-publish',
parts: [
{
implements: 'aphex/event/consumer',
id: 'my.on-publish',
events: ['document.published'],
async handler({ event, logger }) {
logger.info('🚀 A document was published!', event.payload);
}
}
]
});Add it to the registry
import type { CMSPlugin } from '@aphexcms/cms-core';
import { onPublishPlugin } from './plugins/on-publish';
export const plugins: CMSPlugin[] = [onPublishPlugin];Restart, publish something
Publish any document in the admin. Your handler runs. (Reactions run through the job worker — see Events & Jobs for how to keep it running; in local dev it fires within seconds.)
That's a real plugin. It didn't need a package, a build step, or npm publish — it lives in your app like any other module. When you understand this shape, the recipes below are all variations on it.
Where do local plugins go?
Anywhere under src/lib. A src/lib/plugins/ folder next to the plugins.ts file is a tidy
convention. For a one-liner you can even definePlugin(...) inline inside plugins.ts.
How it works
A plugin is an object with a name and a list of parts. Each part implements a named extension point — a slot in the CMS it plugs into. definePlugin gives you autocomplete: set implements and your editor narrows to that part's exact shape.
definePlugin({
name: 'my-plugin',
parts: [
{ implements: 'aphex/schema', schemas: [/* … */] }, // add content types
{ implements: 'aphex/event/consumer', /* … */ }, // react to events
{ implements: 'aphex/field/component', /* … */ } // custom field input
// …one plugin can mix as many parts as it needs
]
});Server parts vs UI parts
The only concept worth internalizing: parts come in two kinds.
| Kind | Examples | Runs |
|---|---|---|
| Server parts | schemas, API routes, settings, event consumers, job handlers, capabilities | on the server (DB, requests) |
| UI parts | field inputs, editor buttons, admin screens | in the browser (admin UI) |
Both live in the same plugins array — your project registers that array on both sides for you. There's just one rule that keeps it safe:
A plugin never imports server-only modules. No $lib/server/*, no $env/dynamic/private, no
database driver, no node: built-ins — anywhere in a plugin file. Server parts get everything they
need handed to them at request time (via c.var.aphexCMS), so they never need to import it.
Why: the plugins array is imported by the browser too. If a plugin reached for a database driver or a secret, that code would try to ship to the browser — so SvelteKit fails the build loudly, not silently. Follow the rule and your plugin is browser-safe no matter how server-heavy its logic is, because the heavy stuff is handed in, never imported:
// ✅ handed in — safe
async handler({ event, databaseAdapter, logger, settings }) { /* … */ }
// ❌ imported — build fails (and that's the point)
import { db } from '$lib/server/db';Recipes
Each recipe is a self-contained plugin part. Mix as many as you like into one definePlugin.
Run code when a document is published
Use an event consumer. It reacts durably — with automatic retries — and is decoupled from the publish, so a slow or failing reaction never blocks the editor. This is how you build webhooks, cache invalidation, notifications, and search-index sync.
{
implements: 'aphex/event/consumer',
id: 'notify.on-publish', // unique id for this consumer
events: ['document.published'],
maxAttempts: 5, // optional retry budget
async handler({ event, databaseAdapter, logger, settings }) {
// Events carry ids only, so fetch the doc if you need its content:
const doc = await databaseAdapter.findByDocIdAdvanced(
event.organizationId,
String(event.payload.documentId)
);
await fetch('https://example.com/webhook', {
method: 'POST',
body: JSON.stringify({ title: doc?.publishedData?.title })
});
// Throw to retry with backoff; return to mark done.
}
}Handlers can run more than once (at-least-once delivery), so make them idempotent. Full model, including how to define and emit your own events, is in Events & Jobs.
Give your plugin a settings screen
Declare the shape of your config and the CMS renders a form under Settings → Plugins, stores values per organization, and encrypts anything marked secret. No table, no route, no form to build.
{
implements: 'aphex/settings',
pluginId: 'my-notify', // storage key — keep it stable
title: 'Notifications',
description: 'Where to send publish alerts.',
fields: [
{ name: 'channel', type: 'string', title: 'Channel name' },
{ name: 'webhookUrl', type: 'secret', title: 'Webhook URL' } // encrypted at rest
]
}Read them inside a server part — settings arrive decrypted, scoped to the current org:
// In an event consumer or job handler:
async handler({ event, settings }) {
const config = await settings.get('my-notify'); // { channel, webhookUrl }
if (typeof config.webhookUrl === 'string') { /* … */ }
}
// In an API route:
handler: async (c) => {
const config = await c.var.aphexCMS.pluginSettingsService.get(orgId, 'my-notify');
}Secrets need an encryption key. Generate one and add it to your .env, or saving a secret fails
loudly (it never stores plaintext): APHEX_SECRET_ENCRYPTION_KEY=$(openssl rand -base64 32).
Settings fields are a small set on purpose — string, text, number, boolean, and secret.
Anything richer belongs in a content type, not settings.
Add your own field input
Swap the editing UI for a field, selected by an input key on the schema. The stored value and validation still come from type; input only changes the widget.
// The plugin part:
{ implements: 'aphex/field/component', input: 'color', component: ColorPickerField }// Use it in any schema — a string, edited with your picker:
{ name: 'brand', type: 'string', input: 'color' }Your component receives a stable FieldComponentProps contract (field, value, onUpdate, readonly) — the same one built-in inputs use, so it's a drop-in.
Add a button to the document editor
An editor toolbar action. Your component gets a stable DocumentActionProps (the current data, updateData, save, publish) — nothing from editor internals.
{
implements: 'aphex/document/action',
id: 'seo.generate',
title: 'Generate SEO',
component: GenerateSeoAction,
appliesTo: ['blog_post'] // omit for all types
}<script lang="ts">
import type { DocumentActionProps } from '@aphexcms/cms-core';
let { action }: { action: DocumentActionProps } = $props();
</script>
<button onclick={async () => { action.updateData({ seo: { /* … */ } }); await action.save(); }}>
✨ Generate
</button>Add your own screen to the admin
A top-level admin section. Renders as a tab next to Content/Media by default; set placement: 'sidebar' to put it in the left nav instead.
{
implements: 'aphex/admin/tool',
id: 'reports',
title: 'Reports',
icon: BarChart, // any Lucide icon
component: ReportsTool,
placement: 'sidebar'
}Your component receives AdminToolProps — the org, the user's capabilities, can(...) checks, and navigation helpers. For document data, use the session-authenticated REST client.
Add an API endpoint
Mounts under /api. The handler gets the same context as built-in routes (c.var.aphexCMS, c.var.auth).
{
implements: 'aphex/server/route',
id: 'reports.export',
method: 'GET',
path: '/reports/export', // → GET /api/reports/export
requiredCapabilities: ['reports.export'],
handler: async (c) => c.json(await buildReport(c.var.aphexCMS))
}requiredCapabilities is required — a plugin route is a public-internet endpoint, and there's no safe default. Pick one:
| Value | Who can call it | Use for |
|---|---|---|
['reports.export'] | Authenticated and holding that capability | The usual case. |
[] | Any signed-in member | When org membership is the authorization. |
'public' | Anyone on the internet | Webhook receivers (verify the caller yourself). |
The route is auto-gated: a request missing what it needs is rejected before your handler runs (401/403), so the check can't be forgotten.
'public' means public — it's the only value that skips the gate, and it's a word you have to
type on purpose. If a capability check is just inconvenient, use [] (a signed-in member) instead
of opening it to the internet.
Add new content types
Ship schemas from a plugin — they merge into your project's content types. Keep the schema in a server-safe module (no component imports).
import { bookingSchema, customerSchema } from './schemas';
{ implements: 'aphex/schema', schemas: [bookingSchema, customerSchema] }For a plugin's schema types to get generated TypeScript types, they must be visible to
aphex generate:types. See Type generation below — it's one line.
Do background work
Register a job handler to run work your plugin (or app) enqueues via databaseAdapter.scheduleJob(...) — a sync, a report, a cleanup. Where an event consumer is triggered by an event, a job handler runs whatever was explicitly queued.
{
implements: 'aphex/job/handler',
handlers: {
'reports.nightly': async ({ job, databaseAdapter, logger }) => {
/* … do the work; throw to retry … */
}
}
}Namespace job types (reports.nightly) to avoid collisions. See Events & Jobs.
Add a permission
Declare a capability so it appears in the org Roles editor and can gate your routes, actions, and tools.
import { defineCapability } from '@aphexcms/cms-core';
{
implements: 'aphex/capabilities',
capabilities: [
defineCapability('reports.export', {
title: 'Export reports',
description: 'Download reports as CSV.',
group: 'Reports'
})
]
}When do you need one? Only for a genuinely new, sensitive action — exporting PII, refunding a booking, triggering a deploy. A field widget or a plugin that just reads content through the standard API needs none; it inherits the built-in permissions. Owners get new capabilities automatically on the next restart; grant them to other roles in the Roles UI.
Share your plugin as a package
Once a homegrown plugin proves itself, you can lift it into its own npm package to reuse across projects or publish. Nothing about the plugin changes — only how it's distributed. It's a normal Svelte-compatible package with split exports so a consumer's server never bundles your components:
{
"name": "@acme/aphex-plugin-reports",
"type": "module",
"exports": {
".": { "svelte": "./dist/index.js" },
"./schema": { "types": "./dist/schema.d.ts", "default": "./dist/schema.js" }
},
"peerDependencies": { "@aphexcms/cms-core": "^9", "svelte": "^5" }
}Consumers then pnpm add @acme/aphex-plugin-reports and register it exactly like the ready-made flow above. Until then, a local file works just as well — don't reach for a package before you need to share.
Type generation
aphex generate:types reads your schemaTypes export. If a plugin contributes content types (aphex/schema), merge them in so they get generated types too:
import { createPartResolver } from '@aphexcms/cms-core';
import { plugins } from '../plugins';
export const schemaTypes = [...appSchemas, ...createPartResolver(plugins).schemaTypes()];Type generation stubs out component imports, so a plugin's UI parts never break it.
Last updated on