Skip to content

Documentation

Plugin Development Guide

Create, register, and extend SveltyCMS with isomorphic plugins featuring lifecycle hooks, RBAC slots, sandboxed execution, and headless API contracts.

6/25/2026
11 min read Edit on GitHub

SveltyCMS features an isomorphic plugin system that runs on both server and client, with full TypeScript support, RBAC-gated UI slots, lifecycle hooks for CRUD operations, sandboxed execution boundaries, and headless API contracts for decoupled frontends.

Architecture Overview

graph TB subgraph "Plugin System" R["Plugin Registry"] S["Plugin Settings"] SB["Sandbox (dynamic scaling)"] end subgraph "Plugin Structure" M["metadata"] H["hooks (lifecycle)"] U["ui (slots/columns)"] MG["migrations"] SR["ssrHook"] HL["headless (virtual slots)"] end subgraph "Execution" BF["beforeSave"] AF["afterSave"] BD["beforeDelete"] AD["afterDelete"] end subgraph "Headless Delivery" API["Stateless REST/GraphQL"] VS["Virtual Slots"] PA["Preview Auth"] end subgraph "UI Injection Zones" D["dashboard"] SID["config_grid"] EE["collection_builder"] CFG["media_gallery"] ELA["user_profile"] end R --> M R --> H --> SB --> BF & AF & BD & AD R --> U --> D & SID & EE & CFG & ELA R --> MG R --> HL --> VS & PA & API R --> SR --> SB S --> R

Try It Live

Open the plugin starter template in StackBlitz to follow along:

The starter includes a working plugin scaffold with lifecycle hooks, RBAC slot registration, and sandboxed execution.

Quick Start: Minimal Plugin

Create a new directory in src/plugins/my-plugin/ and define your plugin in index.ts:

// src/plugins/my-plugin/index.ts
import type { Plugin } from "@src/plugins/types";

export const myPlugin: Plugin = {
  metadata: {
    id: "my-plugin",
    name: "My Custom Plugin",
    description: "Adds custom functionality",
    version: "1.0.0",
    enabled: true,
    icon: "mdi:star",
  },
};

Because SveltyCMS features System Discovery & Autoloading, there is no need to manually register or import your plugin inside any central files. Simply saving your plugin code inside the src/plugins/ directory is enough for the CMS to automatically scan, register, and boot your plugin.

Plugin Interface

interface Plugin {
  metadata: PluginMetadata; // Required: id, name, version, enabled
  config?: PluginConfig; // Public/private settings
  hooks?: PluginLifecycleHooks; // CRUD interception
  ssrHook?: PluginSSRHook; // SSR data enrichment
  ui?: PluginUIContribution; // UI columns, actions, tabs, slots
  migrations?: PluginMigration[]; // DB schema migrations
  enabledCollections?: string[]; // Restrict to specific collections
}

Lifecycle Hooks

Hooks intercept CRUD operations with a sandboxed context:

hooks: {
  beforeSave: async (context, collection, data) => {
    // Validate, transform, or enrich data before save
    if (collection === 'blog_posts') {
      data.wordCount = data.content?.split(/\s+/).length || 0;
    }
    return data; // Must return data
  },

  afterSave: async (context, collection, result) => {
    // Trigger side effects after save (e.g., cache invalidation)
    await context.dbAdapter.crud.insert('plugin_my-plugin_logs', {
      action: 'save',
      collection,
      entryId: result._id,
      timestamp: new Date()
    });
  },

  beforeDelete: async (context, collection, id) => {
    // Guard deletions or archive before remove
  },

  afterDelete: async (context, collection, id) => {
    // Cleanup related plugin data
  },

  afterAuthenticate: async (event) => {
    // Fires after successful credential verification but before session cookie issuance.
    // Use to enforce additional security policies or force 2FA gating.
    //
    // event.user    β€” the authenticated user object
    // event.method  β€” "password" | "oauth" | "passkey" | "api_key" | "token" | "magic_link"
    // event.ip      β€” client IP address
    // event.userAgent β€” User-Agent header
    // event.userHas2FA β€” whether user already has 2FA enabled
    // event.tenantId β€” tenant ID (or null)
    //
    // Return { deny: true, message: "..." } to block the login
    // Return { requires2FA: true } to force 2FA even if user hasn't enabled it
    // Return void to proceed normally
  }
}

Security: Draft-by-Default Airgap

All external mutations (API, webhooks, AI agents) that create or update content MUST default to draft status. This prevents unauthorized publishing from non-human actors:

// βœ… Correct: Draft-by-Default airgap
hooks: {
  beforeSave: async (context, collection, data) => {
    // AI agents, webhooks, and headless API mutations always draft
    if (context.source === 'api' || context.source === 'webhook') {
      data.status = 'draft';
    }
    return data;
  },
}

// ❌ Wrong: Allowing external sources to publish directly
hooks: {
  afterSave: async (context, collection, result) => {
    // No status enforcement β€” external agents could publish malicious content
  },
}

Draft-by-Default applies to:

  • WebMCP AI agent tools (all mutations force status: "draft")
  • Stripe webhook payment events
  • Smart Importer content migrations
  • Any public-facing API POST/PATCH that doesn’t explicitly require publish permission

Human review is required to transition draft entries to published status. This airgap is enforced at the hook level and cannot be bypassed by external callers.

SSR Data Enrichment

Add custom data to entry lists during server-side rendering:

ssrHook: async (context, entries) => {
  return entries.map((entry) => ({
    entryId: String(entry._id),
    updatedAt: new Date().toISOString(),
    data: { score: Math.random() * 100 },
  }));
};

UI Injection Zones

Plugins can inject UI components into 16 predefined zones. All zones are rendered via the <Slot> component (src/components/system/slot.svelte).

Zone Location Used By
dashboard Main dashboard Stats, widgets
sidebar Admin sidebar Navigation links
entry_edit Entry editor Custom tabs/panels
entry_edit_sidebar Entry editor sidebar SEO metadata
entry_edit_header Entry editor header Workflow actions
config Settings page Plugin configuration

| config_grid | Config page icon grid | Conditional tiles | | collection_builder | Collection builder page | Schema extensions | | media_gallery | Media gallery page | Media plugins | | media_gallery_toolbar | Media gallery toolbar | Image optimizers |

| user_profile | User profile page | Profile extensions | | user_profile_sidebar | User profile sidebar | Account settings | | entry_list_actions | Entry list | Custom actions |

| global-toolbar | Top toolbar (layout) | System status | | global-footer | Footer (layout) | Debug info | | sticky-action-bar | Bottom sticky bar | Bulk operations |

ui: {
  slots: [
    {
      id: "my-widget",
      zone: "dashboard",
      position: 10,
      component: () => import("./components/Widget.svelte"),
      permissions: ["admin"],
    },
    {
      id: "my-config-tile",
      zone: "config_grid",
      component: () => import("./components/ConfigTile.svelte"),
    },
  ];
}

Database Migrations

Plugins create their own collections via abstract migrations using dbAdapter.schema.ensureCollection():

migrations: [
  {
    id: "001_create_results",
    pluginId: "my-plugin",
    version: 1,
    description: "Create results collection via abstract schema adapter",
    up: async (dbAdapter) => {
      // βœ… Recommended: abstract ensureCollection
      await dbAdapter.schema.ensureCollection("plugin_my-plugin_results", {
        fields: [
          { label: "Score", name: "score", type: "number" },
          { label: "Timestamp", name: "timestamp", type: "text" },
        ],
        status: "publish",
      });
    },
  },
];

Why ensureCollection? It abstracts away database-specific details, works across all four adapters (MongoDB, PostgreSQL, MariaDB, SQLite), and handles idempotent creation (won’t error if the collection already exists).

// ❌ Legacy: raw createModel β€” adapter-specific, no abstraction
up: async (dbAdapter) => {
  await (dbAdapter as any).createModel({
    _id: "plugin_my-plugin_results",
    name: "plugin_my-plugin_results",
    slug: "plugin_my-plugin_results",
    fields: [...],
    status: "publish",
  } as any);
};

Headless API Contracts

Plugins designed for headless delivery expose virtual slots β€” API-only layout definitions that external frontends (Next.js, Astro, React Native) render using their own component libraries:

// Extend your plugin with virtual slot definitions
export const virtualSlots = [
  {
    id: "headless-product-card",
    zone: "collection_item",
    schema: {
      type: "object",
      properties: {
        title: { type: "string" },
        price: { type: "number" },
        imageUrl: { type: "string" },
      },
    },
    endpoint: "/api/plugins/shop/product-card",
  },
  {
    id: "headless-cart-widget",
    zone: "page_sidebar",
    schema: { type: "object", properties: { itemCount: "number", total: "number" } },
    endpoint: "/api/plugins/shop/cart-data",
  },
];

External frontends fetch structured data from these endpoints and render using their native components β€” no Svelte dependency required. The CMS serves as a pure data source with well-typed API contracts.

Security Boundaries (Sandbox)

All plugin hooks execute within a sandboxed context (src/plugins/sandbox.ts) with dynamic scaling:

Boundary Limit Why
Collection access Only plugin_<id>_* for writes Prevent data corruption
Protected collections users, sessions, tokens, roles, audit_logs blocked Never access auth data
Query count 100 per hook (default); up to 500 for migrations Dynamic scaling prevents resource abuse
Timeout 5s per hook; 30s for migrations Extended timeout for schema provisioning
Error boundary Catches all errors Plugin crash β‰  CMS crash

Dynamic Scaling: Migrations receive relaxed limits (500 queries, 30s timeout) to handle complex schema provisioning. Hooks retain strict limits to keep runtime operations fast.

Per-Tenant Plugin State

Plugins can be enabled/disabled per tenant:

// Toggle via registry
await pluginRegistry.togglePlugin("my-plugin", true, tenantId, userId);

// Check state
const state = await pluginRegistry.getPluginState("my-plugin", tenantId);

Multi-Tenant Safety (Required)

Every plugin that reads or writes data must pass tenantId through all database operations. The system enforces this at the adapter level, but plugins should still explicitly scope their queries:

// βœ… Correct: Tenant-scoped queries
hooks: {
  beforeSave: async (context, collection, data) => {
    data.tenantId = context.tenantId; // Always inject tenant context
    return data;
  },
}

// ❌ Wrong: Missing tenantId β€” data will be invisible in multi-tenant mode
hooks: {
  beforeSave: async (context, collection, data) => {
    return data; // No tenantId β€” orphaned across tenants
  },
}

Database operations should always include tenantId:

// βœ… Correct
await context.dbAdapter.crud.insert("plugin_my-plugin_logs", {
  action: "save",
  collection,
  entryId: result._id,
  tenantId: context.tenantId, // ← Required
  timestamp: new Date(),
});

// ❌ Wrong β€” cross-tenant leak
await context.dbAdapter.crud.insert("plugin_my-plugin_logs", {
  action: "save",
  collection,
  entryId: result._id,
  // No tenantId!
});

Plugin migrations must also pass tenant context when creating data:

migrations: [
  {
    id: "001_create_results",
    pluginId: "my-plugin",
    version: 1,
    description: "Create results collection with tenant isolation",
    up: async (dbAdapter, context) => {
      await dbAdapter.schema.ensureCollection(
        "plugin_my-plugin_results",
        {
          fields: [
            { label: "Score", name: "score", type: "number" },
            { label: "Tenant", name: "tenantId", type: "string" }, // ← Include tenant field
          ],
          status: "publish",
        },
        { tenantId: context?.tenantId },
      );
    },
  },
];

Why this matters: When MULTI_TENANT is enabled, all DB queries automatically filter by tenantId. Data written without a tenantId becomes invisible β€” it’s stored but never returned by any query. This is functionally equivalent to data loss.

Conditional Config Tiles

For plugins that add tiles to the Config page grid, make them conditional by checking the plugin state in +page.server.ts:

// In config/+page.server.ts
import { pluginRegistry } from "@src/plugins/registry";

const smartImporterPlugin = pluginRegistry.get("smart-importer");
const state = await pluginRegistry.getPluginState("smart-importer", tenantId);
const isEnabled = state?.enabled ?? smartImporterPlugin?.metadata.enabled;

Then in +page.svelte:

{#if data?.pluginState?.smartImporter}
  <a href="/config/migration" class="...">Migration</a>
{/if}

Existing Plugins (Reference Implementations)

Plugin Description Docs
Smart AI-Driven Migration Pro 36+ platform content migration smart-importer.mdx
PageSpeed Lighthouse-based performance scoring pagespeed.mdx
WebMCP Headless MCP server for AI agents webmcp.mdx
Cookie Consent GDPR cookie banner with consent log cookie-consent.mdx
Redirect Manager Headless redirect router + MV cache redirect-manager.mdx
Stripe Payment processing + webhooks stripe.mdx
Unified Data Hub Multi-source virtual collections unified-data-hub.mdx

Best Practices

  1. Always prefix your collections with plugin_<your-id>_
  2. Return data from beforeSave β€” it’s required
  3. Handle errors gracefully β€” thrown errors in hooks are caught by the sandbox
  4. Use abstract migrations β€” prefer dbAdapter.schema.ensureCollection() over raw createModel
  5. Keep hooks fast β€” stay under the 5s timeout; use migrations for heavy provisioning
  6. Test independently β€” mock the PluginContext for unit tests
  7. Use conditional tiles β€” check pluginRegistry before showing config tiles
  8. Document β€” every plugin must have an .mdx in its directory
  9. Draft-by-Default Airgap β€” all external mutations must save as draft; human review required for publish
  10. Headless contracts β€” export virtual slot definitions for external frontend consumption

πŸ”‘ Plugin Licensing & Trials

SveltyCMS plugins support monetization gating matching the widget system:

  • 14-day trials: Automatically calculated from the first admin registration timestamp. During the trial period, the plugin has full capability without requiring any license keys.
  • Superadmin Demo Keys: Superadmins can provide trial keys starting with SLM-DEMO- that verify as valid 14-day trial activations.
  • License Walls: Completely prevent specific plugin hooks, background tasks, or workspaces from executing once the trial has expired and no valid license key is present.

Verifying Plugin License Status

Invoke checkExtensionLicense("plugin", "plugin-id") within your plugin lifecycle.

1. Gating Backend Lifecycle Hooks (index.server.ts)

Intercept or block server-side mutations or services:

import { checkExtensionLicense } from "@src/utils/license-manager";

export const hooks = {
  beforeSave: async (context: any) => {
    const status = await checkExtensionLicense("plugin", "my-plugin-id");
    if (!status.active) {
      throw new Error("403 Forbidden: Active license required for MyPlugin.");
    }
    return context;
  },
};

2. Gating UI Slots

Block access to plugin workspace pages or settings tiles:

<script lang="ts">
	import UpgradePrompt from '@components/ui/upgrade-prompt.svelte';

	let { status } = $props(); // status is resolved from checkExtensionLicense
</script>

{#if status.active}
	<div class="plugin-workspace">
		<!-- Render Premium Plugin Workspace -->
	</div>
{:else}
	<UpgradePrompt extensionId="plugin:my-plugin-id" price="€19.99" />
{/if}

Related

pluginsdevelopmentextensionsguideheadless
Was this page helpful?