Aphex

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:

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:

src/lib/plugins.ts
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-picker
npm install @aphexcms/plugin-color-picker
yarn add @aphexcms/plugin-color-picker

Register it

src/lib/plugins.ts
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

src/lib/plugins/on-publish.ts
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

src/lib/plugins.ts
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.

KindExamplesRuns
Server partsschemas, API routes, settings, event consumers, job handlers, capabilitieson the server (DB, requests)
UI partsfield inputs, editor buttons, admin screensin 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
}
GenerateSeoAction.svelte
<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:

ValueWho can call itUse for
['reports.export']Authenticated and holding that capabilityThe usual case.
[]Any signed-in memberWhen org membership is the authorization.
'public'Anyone on the internetWebhook 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:

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

src/lib/schemaTypes/index.ts
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.

Edit on GitHub

Last updated on