Plugin Development Guide
Create, register, and extend SveltyCMS with isomorphic plugins featuring lifecycle hooks, RBAC slots, sandboxed execution, and headless API contracts.
On this page
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
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
publishpermission
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_TENANTis enabled, all DB queries automatically filter bytenantId. Data written without atenantIdbecomes 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
- Always prefix your collections with
plugin_<your-id>_ - Return data from
beforeSaveβ itβs required - Handle errors gracefully β thrown errors in hooks are caught by the sandbox
- Use abstract migrations β prefer
dbAdapter.schema.ensureCollection()over rawcreateModel - Keep hooks fast β stay under the 5s timeout; use migrations for heavy provisioning
- Test independently β mock the
PluginContextfor unit tests - Use conditional tiles β check
pluginRegistrybefore showing config tiles - Document β every plugin must have an
.mdxin its directory - Draft-by-Default Airgap β all external mutations must save as draft; human review required for publish
- 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
- Plugin Architecture Guide β Deep dive into the system design
- Marketplace System β Plugin distribution
- AI Integration β Widget scaffolder + behavioral learning
- Security Overview