Features Apograph CMS on GitHub

How Apograph is put together

A host, two plugin contracts, one database connection, and the two ways plugins extend each other.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Apograph is not a monolith with features bolted on. It is a small, generic host that turns a list of plugins into a running application. The host owns no domain logic — no auth, no users, no content.

Two hosts, one per runtime

// apps/server/src/main.ts
createServer({ plugins });

// apps/admin/src/main.tsx
createAdmin({ plugins });

createServer runs each plugin’s init hook in order, imports its NestJS module, and applies the global /api prefix and validation pipe. createAdmin mounts the React root, the router and the providers, and registers the routes each plugin contributes.

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

The server plugin contract

interface ServerPlugin {
    name: string;
    module: Type | DynamicModule;
    onPluginInit?(): void | Promise<void>;
    migrations?: { dir: () => string; table: string };
}

A plugin is a NestJS module, an optional init hook, and an optional declaration of the migrations it owns. See server plugins.

The admin plugin contract

type AdminPlugin = {
    name: string;
    routes?: RouteItem[];
    layout?: ReactNode;
    slots?: SlotContribution[];
};

Routes, an optional shell, and contributions into named extension points. See admin plugins.

One database connection

@apograph/database owns a single Drizzle/pg pool, opened in its init hook — which is why it is first in the plugin list. It is exposed by dependency injection (@InjectDatabase(), a global module) and as plain getDatabase() / getPool().

It owns almost no schema of its own. The one exception is outbox_events, the transactional outbox, which ships with its own migrations.

Every other plugin owns its own tables and its own migrations. There are no foreign keys across plugin boundaries — a content row’s workspace_id is a plain uuid, because the workspaces table belongs to identity and content-server cannot reference it. Isolation is enforced in the application layer instead.

How a request flows

  1. The admin fires a TanStack Query mutation through a shared axios client with an /api base URL.
  2. A global AuthGuard validates the session — database-backed, revocable, carried in an httpOnly cookie.
  3. PermissionsGuard plus @RequirePermissions('users:read') enforces RBAC. State-changing POSTs additionally pass an OriginGuard, which is the CSRF defence.
  4. The use case runs inside a UnitOfWork transaction and appends its domain events to the transactional outbox using that same transaction — so an event commits if and only if the mutation does, and never without it.
  5. After commit, the outbox dispatcher delivers each event to its subscribers. The activity plugin’s subscriber turns the audited kinds into activity_events rows.
  6. The response returns and React Query updates client state.

The drain is triggered post-commit and awaited, so the audit row is normally there before the response returns, with a five-second poll as the backstop. Delivery is at-least-once and the audit insert is keyed on the event id with ON CONFLICT DO NOTHING, so a redelivery never double-records.

Two ways plugins extend each other

Both exist so that one plugin can extend another with no direct dependency.

Admin: named slots

A plugin defines a slot; other plugins contribute into it as data.

// shell/admin defines it
export const SIDEBAR_NAV_SLOT = createSlot<NavItem>('sidebar.nav');

// workspaces/admin contributes
slots: [{ slot: SIDEBAR_NAV_SLOT, items: [{ label: 'Workspaces', to: '/workspaces' }] }]

Slots are wired once at boot and read sorted by whoever renders them. See slots.

Server: DI ports

The depended-upon plugin declares a Symbol token and an interface, and injects it @Optional(). The implementing plugin binds it.

That inversion is what keeps the package graph acyclic: content-server declares CONTENT_ENTRY_EXTENSION and knows nothing about locales; the localization plugin binds it and adds row-per-locale scoping to content’s pipeline. See DI ports.

Packages resolve from source

Workspace packages are consumed with no build step. Their exports point at ./src/index.ts and tsconfig.base.json sets a custom "@apograph/source" condition, so the admin’s Vite transpiles every package’s source directly.

Run npx nx sync after changing cross-project dependencies, to update the TypeScript project references.

This is also why publishing to npm has a pack step that rewrites each manifest: a consumer outside the workspace cannot resolve from source.

What is deliberately absent

There is no external queue, no separate worker process, no cache server, no search cluster and no websocket layer. A deployment is the API, the static admin bundle and Postgres.

Outbox events leave the process through one door

The transactional outbox is an internal bus: its subscribers are plugins in the same server, and nothing outside the process can subscribe to it directly. The webhooks plugin is the subscriber that turns outbox events into HTTP deliveries — and it does so by queueing a row per endpoint inside the dispatcher’s transaction and POSTing from a worker with nothing open, never from the subscriber itself (ADR-0016). That separation is why one slow receiver cannot stall the activity log or the alarms evaluator.