OpeNext CMS · Developer Docs

Plugin Upload

Upload plugin packages via Dashboard → Plugins. Upload first, run npm install for shared dependencies, then enable. Supports blocks, dashboard apps, and full-stack backend plugins.

Quick start

OpeNext CMS installs plugins from a single .zip via Dashboard → Plugins → Upload Plugin. If your backend declares npm packages in manifest.json, upload them before running npm install — the CMS merges dependency names into the root package.json automatically.

StepWhat happens
1. Upload ZIPValidates package; extracts files; merges deps into root package.json
2. npm installRun at CMS project root — installs packages from uploaded themes/plugins
3. Restart CMSRequired after installing new root dependencies
4. Enable pluginLoads backend, runs migrations; blocked until deps are in node_modules
5. UseBlock appears in page editor; system plugins open from dashboard sidebar

A full-stack TypeScript plugin can use this package layout. The ZIP may contain the wrapper folder shown below; the CMS resolves the nested package automatically.

crm-plugin/
└── crm-plugin/
    ├── manifest.json
    ├── ui/
    │   ├── admin/
    │   │   ├── index.html
    │   │   └── app.ts
    │   └── block/
    │       └── index.ts
    └── api/
        ├── index.ts
        ├── routes/
        │   └── customers.ts
        ├── services/
        │   └── CustomerService.ts
        ├── models/
        │   └── Customer.ts
        └── hooks/
            └── install.ts

For a block-only plugin, use ui/index.ts or ui/block/index.ts. For a dashboard plugin, keep ui/admin/index.html and load the browser entry from that HTML file.

  • Always upload through Dashboard → Plugins — do not copy files manually onto the server
  • Plugins with no dependencies enable immediately on upload
  • Plugins with pending npm deps upload as disabled — enable after npm install + restart
  • Backend npm packages resolve from the CMS root install — not a per-plugin folder
  • Frontend browser deps: bundle with Vite/Webpack into ui/ — never zip node_modules/
  • adminNav adds multi-page dashboard sidebar links when the plugin is enabled
  • userTypes register custom roles on Dashboard → Users after upload
  • Requires Owner or Admin role for upload and enable

Required ZIP layout

Every plugin ZIP must use a double-nested folder named after the plugin id, with manifest.json, a ui/ folder for all frontend assets, and an optional api/ folder for backend code:

crm-plugin/
└── crm-plugin/
    ├── manifest.json
    ├── ui/
    │   ├── admin/index.html      System / dashboard plugin
    │   ├── block/index.js        Optional page-editor block
    │   └── index.js                Block-only plugin entry
    └── api/                        Optional full-stack backend
        ├── index.js
        ├── routes/
        ├── models/
        └── migrations/
After uploadPublic URL
Frontend (ui/, manifest.json)/plugins/{pluginId}/ui/…
Backend APIs (api/)/api/plugins/{pluginId}/…
Plugin settings (plugin-data API)/api/plugins/{pluginId}/data/{key}
  • manifest.serverEntry defaults to api/index.js (legacy server/index.js still supported)
  • manifest.adminEntry defaults to ui/admin/index.html
  • Block entryPoint defaults to ui/index.js or ui/block/index.js
  • Legacy admin/ + server/ layout at package root is still accepted for older packages

How plugins work

Plugins are installed from a single ZIP via Dashboard → Plugins → Upload Plugin. A package can be:

  • Frontend-only — static HTML, CSS, and JavaScript served from /plugins/{pluginId}/ui/...
  • Full-stack — ui/ frontend plus optional api/ backend with REST APIs under /api/plugins/{pluginId}/*

Frontend plugins load in one of three shapes:

  • Block plugins — mount into the page editor and live site via window.__NEXTCMS_PLUGINS__
  • System (dashboard) plugins — admin UI runs in a sandboxed iframe at /dashboard/plugins/runtime/{pluginId}
  • Hybrid plugins — both a dashboard app and an optional page-editor block

After upload, the CMS stores plugin metadata in the database. Frontend assets are served at /plugins/{pluginId}/ui/…. Backend APIs (if present) are available at /api/plugins/{pluginId}/*.

Package types at a glance

TypeContainsBackend APIsBest for
Block pluginmanifest + ui/index.jsNoPage-editor widgets
System pluginmanifest + ui/admin/No (plugin-data API only)Simple dashboard apps
Hybrid pluginui/admin/ + ui/block/NoDashboard + canvas block
Full-stack systemui/admin/ + api/Yes — /api/plugins/{id}/*CRM, inventory, custom APIs

Dashboard plugin and system plugin mean the same thing — a full admin application opened from the dashboard sidebar.

JavaScript and TypeScript support

Plugin authors can write simple frontend and backend plugin files in either JavaScript or TypeScript. During upload, the CMS transpiles .ts and .tsx files into JavaScript and rewrites manifest entry paths automatically.

{
  "id": "crm-plugin",
  "kind": "system",
  "adminEntry": "ui/admin/index.html",
  "serverEntry": "api/index.ts",
  "blockEntry": "ui/block/index.ts"
}
  • Use .js files when you want no compilation step; use .ts or .tsx files when you want TypeScript types
  • TypeScript files are retained, and JavaScript runtime files are generated during upload
  • This supports simple plugin modules, routes, services, models, hooks, and block scripts
  • This upload compiler does not bundle React/Vue applications or npm imports; build those applications first with Vite, Webpack, or another bundler
  • Do not upload node_modules/; declare backend packages in manifest.json dependencies

Choose your plugin type

Typekind in manifestRequired filesWhere it runs
Block plugin"block"manifest.json + ui/index.jsPage editor palette + public pages
System / dashboard plugin"system"manifest.json + ui/admin/index.htmlDashboard iframe
Hybrid plugin"system" + blockEntrymanifest.json + ui/admin/ + ui/block/Dashboard iframe + page editor
Full-stack plugin"system" + serverEntrymanifest.json + ui/admin/ + api/index.jsDashboard + /api/plugins/{id}/*

Files you need (by type)

File / folderBlockSystemHybridFull-stackPurpose
manifest.jsonYesYesYesYesPlugin identity, kind, entry paths
ui/index.jsYesBlock mount script (default entryPoint)
ui/admin/index.htmlYesYesYesDashboard iframe entry
ui/admin/app.jsRec.Rec.Rec.Dashboard application logic
ui/block/index.jsYes*OptionalPage-editor block (*hybrid only)
api/index.jsYes**Backend entry (**or manifest.serverEntry)
api/routes/OptionalAuto-loaded REST route files
api/models/OptionalMongoose model definitions
api/migrations/OptionalDatabase migration scripts
api/hooks/Optionalinstall.js / uninstall.js
ui/assets/, ui/fonts/OptionalOptionalOptionalOptionalImages, icons, web fonts

Always required in manifest.json: name, version. Strongly recommended: stable id (never change between releases).

Step 1 — Create manifest.json

Every plugin starts with manifest.json at the package root. The installer reads this first to determine plugin ID, kind, entry paths, and optional backend configuration.

{
  "id": "crm-plugin",
  "name": "CRM",
  "version": "1.0.0",
  "description": "Customer relationship management",
  "author": "Your Company",
  "kind": "system",
  "type": "crm",
  "icon": "👥",
  "adminEntry": "ui/admin/index.html",
  "blockEntry": "ui/block/index.js",
  "styles": ["ui/block/style.css"],
  "serverEntry": "api/index.js",
  "migrations": ["api/migrations"],
  "models": ["api/models"],
  "permissions": ["crm.read", "crm.write", "crm.configure"],
  "adminNav": [
    { "id": "dashboard", "label": "Dashboard", "entry": "ui/admin/index.html", "icon": "layout-dashboard" },
    { "id": "contacts", "label": "Contacts", "entry": "ui/admin/contacts.html", "icon": "users" },
    { "id": "settings", "label": "Settings", "entry": "ui/admin/settings.html", "icon": "settings" }
  ],
  "userTypes": [
    {
      "id": "crm-agent",
      "name": "CRM Agent",
      "baseRole": 3,
      "permissions": ["crm.read", "crm.write"]
    },
    {
      "id": "crm-manager",
      "name": "CRM Manager",
      "baseRole": 2,
      "permissions": ["crm.read", "crm.write", "crm.configure"]
    }
  ],
  "installHook": "api/hooks/install.js",
  "uninstallHook": "api/hooks/uninstall.js",
  "dependencies": ["pdfkit"]
}
  • id — 3–64 chars, lowercase letters/numbers/-/_; must match __NEXTCMS_PLUGINS__ registry key
  • name — required display name
  • version — required semver string
  • kind — block or system (auto-detected from ui/admin/ if omitted)
  • type — category label (crm, chart, slider) — not the same as kind
  • adminEntry — HTML for dashboard plugins (defaults: ui/admin/index.html; legacy admin/index.html)
  • entryPoint — JS for block plugins (defaults to ui/index.js)
  • blockEntry — JS for hybrid system plugins (defaults to ui/block/index.js)
  • serverEntry — backend main module (defaults to api/index.js; legacy server/index.js)
  • migrations — migration directories (defaults to api/migrations/)
  • permissions — permission strings registered on install (use *.configure for settings-only access)
  • userTypes — optional custom roles added to Dashboard → Users after upload
  • installHook / uninstallHook — scripts run after migrations / during uninstall
  • dependencies — npm package names merged into CMS root package.json on upload (enable blocked until npm install)
  • adminNav — optional array of dashboard pages for multi-page system plugins (sidebar links under plugin name)
  • styles — CSS loaded before block scripts (paths must exist in ZIP)

Multi-page system plugins declare sidebar entries with adminNav:

{
  "id": "crm-plugin",
  "adminEntry": "ui/admin/index.html",
  "adminNav": [
    { "id": "dashboard", "label": "Dashboard", "entry": "ui/admin/index.html", "icon": "layout-dashboard" },
    { "id": "contacts", "label": "Contacts", "entry": "ui/admin/contacts.html", "icon": "users" },
    { "id": "settings", "label": "Settings", "entry": "ui/admin/settings.html", "icon": "settings" }
  ]
}
FieldRequiredDescription
labelYesText shown in the dashboard sidebar under the plugin name
entryYesHTML file path relative to the package root (e.g. ui/admin/contacts.html — must exist in the ZIP)
idNoURL segment for /dashboard/plugins/runtime/{pluginId}/{id}
iconNoLucide-style icon name (list, users, settings, bar-chart). Defaults to puzzle icon

Each page opens in the same iframe runtime at /dashboard/plugins/runtime/{pluginId}/{navId}. With two or more adminNav entries, the dashboard sidebar shows a collapsible submenu under the plugin name. With zero or one entry, the plugin name is a single direct link. The plugin must be enabled (isActive: true) for sidebar links to appear.

Add custom user types (userTypes)

Plugins can register custom roles that appear on Dashboard → Users after upload. Declare them in manifest.jsonuserTypes. The CMS assigns stable numeric role values (≥ 100) automatically — do not hard-code value in new manifests.

{
  "id": "crm-plugin",
  "permissions": ["crm.read", "crm.write", "crm.configure"],
  "userTypes": [
    {
      "id": "crm-agent",
      "name": "CRM Agent",
      "baseRole": 3,
      "permissions": ["crm.read", "crm.write"]
    },
    {
      "id": "crm-manager",
      "name": "CRM Manager",
      "baseRole": 2,
      "permissions": ["crm.read", "crm.write", "crm.configure"]
    }
  ]
}
FieldRequiredDescription
nameYesLabel shown in the Users screen role dropdown
idNoStable slug for this user type (auto-generated from name if omitted)
baseRoleNoCore CMS capabilities inherited: 0 Owner, 1 Admin, 2 Editor, 3 Author (default 3)
permissionsNoSubset of manifest permissions granted to this user type (defaults to all manifest permissions)

On upload, the CMS:

  • Registers each user type in CMS settings (pluginUserTypes store)
  • Creates matching Role documents so GET /api/get-role returns them
  • Maps plugin API access via resolvePluginPermissionsForRole at request time

Adding users with a plugin role — after upload, an Owner or Admin opens Dashboard → Users → Add User, picks the plugin role from the dropdown (for example “CRM Agent”), and saves. The same role values work with POST /api/sub-users/add-users (requires users.manage capability).

POST /api/sub-users/add-users
Content-Type: application/json

{
  "username": "jane.agent",
  "email": "jane@example.com",
  "password": "secure-password",
  "role": 100,
  "active": true
}

Use GET /api/get-role to list core and plugin role values. On plugin uninstall, users assigned removed plugin roles are reassigned to Author (3).

Permission resolution: Owner/Admin receive all plugin permissions; Editor receives all except *.configure; Author receives none; plugin user types receive only their declared subset.

Add plugin settings

“Settings” for an uploaded plugin can mean three things — pick the approach that fits your plugin:

ApproachBest forHow
Settings admin pageUI toggles, forms, brandingAdd an adminNav entry (for example label "Settings", entry ui/admin/settings.html) and build the form in your admin HTML/JS
Plugin-data APIFrontend-only plugins, small JSON config (≤ 256 KB per key)Read/write keys at /api/plugins/{pluginId}/data/{key} with credentials: 'include'
Backend + permissionsFull-stack plugins with guarded config APIsDeclare permissions in manifest; use *.configure for settings-only access; persist in MongoDB via plugin routes or install hook

Declare permissions in manifest.json so the CMS registers them on install. Use a .configure suffix for settings-only operations — Editors are blocked from *.configure permissions automatically:

{
  "permissions": [
    "crm.read",
    "crm.write",
    "crm.configure"
  ]
}

Example — save settings with plugin-data API (no api/ folder required):

const pluginId = 'crm-plugin';

// Load settings
const res = await fetch('/api/plugins/' + pluginId + '/data/settings', {
  credentials: 'include'
});
const { data } = await res.json();
const settings = data?.data ?? { notifyEmail: '', maxContacts: 100 };

// Save settings
await fetch('/api/plugins/' + pluginId + '/data/settings', {
  method: 'PUT',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    notifyEmail: 'admin@example.com',
    maxContacts: 250
  })
});

Example — seed default settings on install (full-stack plugin):

// api/hooks/install.js
module.exports = async (cms) => {
  cms.logger.info('Seeding CRM default settings');
  // Use your own model, or call plugin-data storage from a route seeded at first login
};

For relational or large configuration, add a dedicated route such as GET/PUT /api/plugins/crm-plugin/settings and check req.permissions includes crm.configure before allowing updates.

CMS core dependencies (shared node_modules)

Plugins do not run per-package npm install. If your backend api/ code needs npm packages (for example pdfkit or zod), declare them in manifest.jsondependencies. The CMS merges them into the root package.json on upload.

Workflow (upload first, install second, enable last)

1. Upload plugin ZIP
   → CMS extracts files and merges dependencies into root package.json

2. npm install (at CMS project root)
   → Installs all packages declared by uploaded themes and plugins

3. Restart CMS server

4. Enable plugin (Dashboard → Plugins)
   → Blocked until every declared package exists in root node_modules
  • Plugins with pending npm deps upload as disabled — enable after npm install
  • Plugins with no dependencies enable immediately on upload
  • Backend require('pdfkit') resolves from CMS root node_modules/
  • Frontend-only plugins: bundle browser deps with Vite/Webpack — do not zip node_modules/
  • Do not manually edit package.json before upload — upload adds package names for you

Example manifest for a PDF backend plugin:

{
  "id": "invoice-plugin",
  "dependencies": ["pdfkit"],
  "serverEntry": "api/index.js"
}

// api/routes/pdf.js — after npm install + enable:
const PDFDocument = require('pdfkit');

Step 2 — Develop a block plugin

Block plugins add a component editors drag from the page-editor plugin palette onto the canvas. The block type in the editor equals your plugin id.

testimonial-block/
└── testimonial-block/
    ├── manifest.json
    └── ui/
        ├── index.js          Required — block entry script
        ├── style.css         Optional
        └── assets/
            └── quote.svg
window.__NEXTCMS_PLUGINS__ = window.__NEXTCMS_PLUGINS__ || {};

window.__NEXTCMS_PLUGINS__['testimonial-block'] = {
  mount(element, context) {
    const data = (context.block || {}).data || {};
    element.innerHTML = '';

    const quote = document.createElement('blockquote');
    quote.textContent = data.quote || 'Your testimonial';

    const author = document.createElement('p');
    author.textContent = data.author || 'Customer name';

    element.append(quote, author);
  },
  unmount(element) {
    element.innerHTML = '';
  }
};

mount(element, context) receives { block: {}, isEditing: true }. Use textContent for user content — avoid unsafe innerHTML.

Step 3 — Develop a system (dashboard) plugin

System plugins are full dashboard applications. The CMS opens your HTML in an iframe at /dashboard/plugins/runtime/{pluginId} and adds a sidebar link.

crm-plugin/
└── crm-plugin/
    ├── manifest.json
    └── ui/
        └── admin/
            ├── index.html        Required — iframe entry point
            ├── app.js            Your dashboard application
            └── style.css         Dashboard styles

Use relative URLs in admin HTML (./app.js, ./style.css). Installed assets are served at /plugins/{pluginId}/{path}.

Option A — plugin-data API (frontend-only, no api/ folder). Good for small JSON payloads up to 256 KB per key:

const pluginId = 'crm-plugin';

const response = await fetch('/api/plugins/' + pluginId + '/data/contacts', {
  credentials: 'include'
});
const payload = await response.json();
const contacts = payload.data?.data || [];

Option B — full-stack plugin APIs (with api/ folder). Good for CRUD, relational data, and migrations — see Step 5.

Step 4 — Develop a hybrid plugin

A hybrid plugin combines a dashboard app with a page-editor block — for example, a CRM admin panel plus a contact-summary widget on pages.

{
  "id": "crm-plugin",
  "name": "CRM",
  "version": "1.0.0",
  "kind": "system",
  "type": "crm",
  "icon": "👥",
  "adminEntry": "ui/admin/index.html",
  "blockEntry": "ui/block/index.js",
  "styles": ["ui/block/style.css"]
}

The dashboard loads adminEntry in the iframe; the page editor loads blockEntry through the same __NEXTCMS_PLUGINS__ mount contract.

Step 5 — Add a backend (full-stack plugin)

Add an api/ folder when your plugin needs REST APIs, database tables, or install/uninstall hooks. One ZIP installs everything — no CMS source changes required.

crm-plugin/
└── crm-plugin/
    ├── manifest.json
    ├── ui/
    │   └── admin/
    │       └── index.html
    └── api/
        ├── index.js              Main server module (required)
        ├── routes/
        │   └── customers.js      Auto-loaded route files
        ├── services/
        │   └── CustomerService.js
        ├── models/
        │   └── Customer.js       Mongoose schemas
        ├── migrations/
        │   └── 001_create_customers.js
        └── hooks/
            ├── install.js        Runs after migrations
            └── uninstall.js      Runs on uninstall

Every backend plugin receives a dedicated API namespace. Define routes relative to your plugin — the CMS mounts them automatically:

GET    /api/plugins/crm-plugin/customers
POST   /api/plugins/crm-plugin/customers
PUT    /api/plugins/crm-plugin/customers/:id
DELETE /api/plugins/crm-plugin/customers/:id

api/index.js exports a server module contract:

module.exports = {
  register({ app, cms }) {
    // Optional: register routes manually
    app.get('/health', (req, res) => {
      res.json({ ok: true, plugin: cms.pluginId });
    });
  },
  boot({ cms }) {
    cms.logger.info('CRM plugin booted');
  },
  shutdown() {}
};

Route files in api/routes/*.js are auto-loaded. Each file may export a function that receives the plugin router, or an array of route definitions.

// api/routes/customers.js
module.exports = (app) => {
  app.get('/customers', async (req, res) => {
    const cms = req.cms;
    const Customer = cms.models.get('Customer');
    const customers = await Customer.find({}).lean();
    res.json({ customers });
  });
};

Plugin APIs inherit CMS authentication automatically. Handlers receive req.user, req.permissions, and req.cms (the plugin SDK with database, models, logger, and permissions).

Call plugin APIs from your admin UI:

const response = await fetch('/api/plugins/crm-plugin/customers', {
  credentials: 'include'
});
const { customers } = await response.json();

Migrations in api/migrations/ run once when the backend is first enabled — on upload if dependencies are already satisfied, or on the first Enable after npm install when the plugin uploaded as disabled. Progress is tracked in the plugin_migrations collection. The path segment data is reserved for the CMS plugin-data API — do not register backend routes at /data/*.

Plugin data API (frontend-only storage)

Uploaded and enabled plugins can store authenticated JSON (max 256 KB per key) without an api/ folder:

  • GET /api/plugins/{pluginId}/data — list all keys
  • GET /api/plugins/{pluginId}/data/{key} — read one key
  • POST /api/plugins/{pluginId}/data — create or update: { key, data }
  • PUT /api/plugins/{pluginId}/data/{key} — replace one key
  • DELETE /api/plugins/{pluginId}/data/{key} — delete one key

For large or relational data, use a full-stack plugin with dedicated routes under /api/plugins/{pluginId}/* instead.

Building with React or Vite

React, Vue, Svelte, or TypeScript frontend source must be compiled before upload. The ZIP must contain browser-ready output in ui/admin/ and ui/block/.

// vite.config.ts — build dashboard UI into the plugin package
export default defineConfig({
  base: './',
  build: {
    outDir: 'crm-plugin/crm-plugin/ui/admin',
    emptyOutDir: true
  }
});

Simple backend TypeScript files can be uploaded directly; the CMS transpiles .ts and .tsx files during upload. React/Vue applications with imports still need to be bundled before zipping. The api/ folder may contain .js or .ts files.

Never include node_modules/, src/, .env, or private keys in the ZIP.

Package and upload

Zip using the double-nested layout so the inner folder contains manifest.json, ui/, and optional api/:

# PowerShell — zip the inner package folder
Compress-Archive -Path .\crm-plugin\crm-plugin\* -DestinationPath .\crm-plugin.zip -Force

# Or zip the outer folder (CMS detects {pluginId}/{pluginId}/manifest.json)
Compress-Archive -Path .\crm-plugin -DestinationPath .\crm-plugin.zip -Force
  • Dashboard → Plugins → Upload Plugin
  • Select or drag your .zip (max 25 MB, max 200 files)
  • If manifest lists dependencies, CMS adds them to root package.json
  • Run npm install at the CMS project root, then restart the server
  • Enable the plugin in Dashboard → Plugins (automatic if no deps needed)
  • Duplicate plugin ID returns 409 — remove the old plugin first

Plugins without npm dependencies are enabled immediately. Plugins with dependencies stay disabled until you run npm install and enable them manually.

On failure, extracted files and partial database rows are rolled back.

Upload API

Authenticated dashboard endpoint for programmatic upload (same flow as the UI):

POST /api/dashboard/plugins/upload-plugin
Content-Type: multipart/form-data

file: <plugin.zip>

Success response (201) — no pending dependencies (plugin enabled immediately):

{
  "plugin": { "pluginId": "invoice-plugin", "isActive": true, ... },
  "kind": "system",
  "hasBackend": true,
  "backend": { "migrationsRun": ["001_create_invoices.js"], "routesRegistered": 2 },
  "requiresNpmInstall": false,
  "pendingDependencies": [],
  "addedToPackageJson": [],
  "message": "Plugin extracted and installed as a system (dashboard) plugin with backend APIs"
}

Success response (201) — dependencies pending (plugin saved disabled):

{
  "plugin": { "pluginId": "invoice-plugin", "isActive": false, ... },
  "kind": "system",
  "hasBackend": true,
  "backend": null,
  "requiresNpmInstall": true,
  "pendingDependencies": ["pdfkit"],
  "addedToPackageJson": ["pdfkit"],
  "message": "Plugin uploaded. Run npm install at the CMS project root, restart the server, then enable the plugin. Pending packages: pdfkit."
}

Enable after upload (when deps were pending):

PATCH /api/dashboard/plugins/toggle-plugin
Content-Type: application/json

{ "pluginId": "invoice-plugin", "isActive": true }

Returns 400 with CMS_DEPENDENCY_MISSING if packages are still missing from root node_modules/. Other routes: GET /api/dashboard/plugins/get-plugins, DELETE /api/dashboard/plugins/remove-plugin. See the API Reference.

Upload lifecycle (what actually happens)

Upload ZIP
  → Validate size (25 MB) and file count (200)
  → Read and validate manifest.json
  → Install frontend and backend package files
  → Merge manifest dependencies → CMS root package.json
  → Register userTypes and permissions
  → If deps already installed:
      → Run migrations, install hook, load backend, enable plugin
  → If deps pending:
      → Save plugin as disabled (isActive: false)
      → Backend not loaded until you npm install + enable

After npm install + Enable:
  → Validate dependencies are installed
  → Run migrations + install hook (first enable)
  → Load server module + routes
  → Plugin becomes active

Block plugin at render time:
  → Load styles[] then entryPoint script
  → Call window.__NEXTCMS_PLUGINS__[id].mount(element, { block, isEditing })

System plugin when opened:
  → Navigate to /dashboard/plugins/runtime/{id} or /dashboard/plugins/runtime/{id}/{navId}
  → Load adminEntry or adminNav entry HTML in sandboxed iframe
  → adminNav links appear in dashboard sidebar when plugin is enabled

Enable, disable, and uninstall

ActionWhen deps pendingFrontendBackend
UploadStays disabledFiles extractedNot loaded
npm installRequired before enable
EnableAfter npm installVisible in editor/sidebarLoaded + routes registered
DisableHiddenRoutes unloaded; database kept
UninstallRemoved from dashboardUninstall hook runs; data cleaned up

Uninstall also deletes plugin-data records. Migration history is kept by default so reinstalling does not re-run migrations.

Security and limits

  • ZIP size: 25 MB; max 200 files
  • Blocked extensions: .exe, .dll, .bat, .cmd, .sh, .ps1, .msi, .com, .scr
  • Blocked paths: .env, node_modules/, .git/ — never included in the ZIP
  • Plugin browser JS runs same-origin — treat as trusted code
  • Never embed secrets or API keys in plugin files

Common errors

  • manifest.json was not found — place it at ZIP root or one wrapper folder deep
  • System plugin requires ui/admin/index.html — add ui/admin/index.html or set adminEntry
  • Block plugin requires an entryPoint — add ui/index.js or declare entryPoint in manifest
  • Backend plugin requires api/index.js — add api/index.js or set serverEntry
  • Block script loads but nothing renders — registry key must exactly match manifest id
  • Plugin is already installed — remove existing plugin before uploading an update
  • Plugin API returns 401 — call with credentials: include while logged into CMS
  • Plugin API returns 503 — plugin disabled or backend failed to load; check server logs
  • Migration failed during install/enable — fix api/migrations/*.js; enable rolls back on failure
  • Invalid userTypes in manifest — each entry needs name; permissions must exist in manifest permissions array
  • Role not in Users dropdown — upload plugin first; userTypes register on install
  • User create fails with Invalid role — use GET /api/get-role for the assigned plugin role value
  • Settings page 403 — user lacks *.configure permission; assign a user type or role that includes it
  • Missing CMS dependency on enable — run npm install at CMS root, restart, then enable the plugin
  • Cannot find module 'pdfkit' at runtime — package not in root node_modules or missing from manifest dependencies

Related guides

Walk through a full example in the Plugin Development tutorial. Reference implementation with backend APIs: examples/crm-plugin/ in the CMS repository.

For uploaded themes (Theme Engine ZIP packages), see Theme Upload. Themes ship browser libraries in assets/; plugins with backend npm needs use the shared root package.json workflow documented above.

Full package specification: docs/plugin-upload-format.md on GitHub → · docs/plugin-backend-architecture.md on GitHub →