Features Apograph CMS on GitHub

Plugins

The unit of capability in Apograph, and the two contracts a plugin implements.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Everything in Apograph is a plugin: authentication, content, media, localization, the audit log, the copilot, the MCP endpoint. The host owns no domain logic at all.

Adding a capability means writing a plugin and registering it. You never edit the host.

Two contracts

Most domains ship a pair — an admin plugin and a server plugin:

packages/<group>/admin    → AdminPlugin  (routes, slots, layout)
packages/<group>/server   → ServerPlugin (a NestJS module, schema, migrations)

Some are single-runtime: the design system and the query builder are admin-only; database and the Nx plugin are server-only.

Registration is two arrays:

// apps/server/src/plugins.ts
export function buildPlugins(config: ApographConfig): ServerPlugin[] {
    return [DatabasePlugin({}), IdentityPlugin(), YourPlugin()];
}

// apps/admin/src/main.tsx
createAdmin({ plugins: [shellPlugin, identityPlugin, yourPlugin] });

What a plugin may own

A plugin mayNotes
Own tablesAnd ship its own migrations, tracked under its own table
Contribute routesServer: a NestJS module. Admin: React Router routes
Contribute UI into slotsNamed extension points other plugins define
Declare a DI portFor another plugin to implement
Bind another plugin’s portTo extend it without it depending on you
Contribute agent toolsAvailable to the copilot, the MCP endpoint, or both
Emit domain eventsInto the transactional outbox

What a plugin may not own is a foreign key into another plugin’s tables. That boundary is why workspace_id on a content row is a plain uuid: the workspaces table belongs to identity, and content-server cannot reference it. Isolation is enforced in the application layer instead.

Plugins extend each other without depending on each other

Two mechanisms, one per runtime, and both are inversions.

Admin — slots. A plugin defines a named extension point; other plugins contribute into it as data. The shell defines the sidebar’s nav slot; workspaces, users and activity each register entries. See slots.

Server — DI ports. The plugin that wants to be extended declares a Symbol token and an interface, and injects it @Optional(). The plugin that provides the behaviour binds it. See DI ports.

The direction is the point. Content-server declares the entry-extension port and knows nothing about locales; the localization plugin binds it and adds row-per-locale behaviour to content’s pipeline. The package graph stays acyclic, and removing the localization plugin degrades content rather than breaking it.

Order in the plugin list

Order matters in three specific ways, and it is easy to over-claim what it buys.

Every plugin’s init hook runs before the Nest application is created, so by the time any provider is instantiated the database is open regardless of where DatabasePlugin sits in the array. Its presence is what matters to dependency injection, not its position.

Order is load-bearing for:

  • Migrations — applied in list order, and foreign keys between plugins’ schemas depend on it. Identity’s users before workspaces’ memberships.
  • An init hook that genuinely depends on an earlier hook’s side effect.
  • The API documentation passes, which run in registration order, last writer winning.

Getting started

  • Server plugins — the factory, the dynamic module, config, schema and migrations.
  • Admin plugins — routes, layout, slots, the data layer.
  • Testing — the harnesses that already exist.

Two things in the repository are worth reading before you write any of it: the package AGENTS.md next to whatever you are extending, and the skills under .agents/skills/server-plugin, admin-plugin, accessibility, admin-e2e, server-e2e. They are written for AI agents and are the most precise statement of house style that exists.

There is no plugin marketplace, and no runtime loading

A plugin is a workspace package that the application imports and lists. There is no registry, no install command, no versioned plugin API with a compatibility contract, and nothing is loaded at runtime — adding a plugin is a code change and a deploy.

The packages are published to npm under @apograph, so a plugin can be an ordinary npm dependency; but it is still an import and a line in an array.