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}/
- Full-stack — frontend assets plus optional server/ backend code 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 MongoDB. Frontend files land in public/plugins/{pluginId}/. Backend code (if present) is extracted to plugins/{pluginId}/ and is never served as static files or executed from public/.
Package types at a glance
| Type | Contains | Backend APIs | Best for |
| ---- | -------- | ------------ | -------- |
| Block plugin | manifest + index.js | No | Page-editor widgets |
| System plugin | manifest + admin/ | No (plugin-data API only) | Simple dashboard apps |
| Hybrid plugin | admin/ + block/ | No | Dashboard + canvas block |
| Full-stack system | admin/ + server/ | 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.
Choose your plugin type
| Type | kind in manifest | Required files | Where it runs |
| ---- | ---------------- | -------------- | ------------- |
| Block plugin | "block" | manifest.json + index.js | Page editor palette + public pages |
| System / dashboard plugin | "system" | manifest.json + admin/index.html | Dashboard iframe |
| Hybrid plugin | "system" + blockEntry | manifest.json + admin/ + block/ | Dashboard iframe + page editor |
| Full-stack plugin | "system" + serverEntry | manifest.json + admin/ + server/index.js | Dashboard + /api/plugins/{id}/* |Files you need (by type)
| File / folder | Block | System | Hybrid | Full-stack | Purpose |
| ------------- | ----- | ------ | ------ | ---------- | ------- |
| manifest.json | Yes | Yes | Yes | Yes | Plugin identity, kind, entry paths |
| index.js | Yes | — | — | — | Block mount script (default entryPoint) |
| admin/index.html | — | Yes | Yes | Yes | Dashboard iframe entry |
| admin/app.js | — | Rec. | Rec. | Rec. | Dashboard application logic |
| block/index.js | — | — | Yes* | Optional | Page-editor block (*hybrid only) |
| server/index.js | — | — | — | Yes** | Backend entry (**or manifest.serverEntry) |
| server/routes/ | — | — | — | Optional | Auto-loaded REST route files |
| server/models/ | — | — | — | Optional | Mongoose model definitions |
| server/migrations/ | — | — | — | Optional | Database migration scripts |
| server/hooks/ | — | — | — | Optional | install.js / uninstall.js |
| assets/, fonts/ | Optional | Optional | Optional | Optional | Images, 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": "admin/index.html",
"blockEntry": "block/index.js",
"styles": ["block/style.css"],
"serverEntry": "server/index.js",
"migrations": ["server/migrations"],
"models": ["server/models"],
"permissions": ["crm.read", "crm.write"],
"installHook": "server/hooks/install.js",
"uninstallHook": "server/hooks/uninstall.js",
"dependencies": []
}- 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 admin/ folder if omitted)
- type — category label (crm, chart, slider) — not the same as kind
- adminEntry — HTML for dashboard plugins (defaults: admin/index.html, dashboard/index.html)
- entryPoint — JS for block plugins (defaults to index.js)
- blockEntry — JS for hybrid system plugins (defaults to block/index.js)
- serverEntry — backend main module (defaults to server/index.js when present)
- migrations — migration directories (defaults to server/migrations/)
- permissions — permission strings registered on install
- installHook / uninstallHook — scripts run after migrations / during uninstall
- styles — CSS loaded before block scripts (paths must exist in ZIP)
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/
├── manifest.json
├── index.js Required — block entry script
├── style.css Optional
└── assets/
└── quote.svgwindow.__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/
├── manifest.json
└── admin/
├── index.html Required — iframe entry point
├── app.js Your dashboard application
└── style.css Dashboard stylesUse 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 server/ 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 server/ 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": "admin/index.html",
"blockEntry": "block/index.js",
"styles": ["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 a server/ folder when your plugin needs REST APIs, database tables, or install/uninstall hooks. One ZIP installs everything — no CMS source changes required.
crm-plugin/
├── manifest.json
├── admin/
│ └── index.html
└── server/
├── 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 uninstallEvery 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/:idserver/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 server/routes/*.js are auto-loaded. Each file may export a function that receives the plugin router, or an array of route definitions.
// server/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 server/migrations/ run once during install and are 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 a server/ 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 admin/ and block/.
// vite.config.ts
export default defineConfig({
base: './',
build: {
outDir: 'crm-plugin/admin',
emptyOutDir: true
}
});For backend plugins, compile any TypeScript server code to plain JavaScript before zipping. The server/ folder must contain runnable .js files.
Never include node_modules/, src/, .env, or private keys in the ZIP.
Package and upload
Zip the contents of your plugin folder so manifest.json is at the ZIP root, or zip one wrapper folder. Both layouts work.
# PowerShell
Compress-Archive -Path .\crm-plugin\* -DestinationPath .\crm-plugin.zip -Force- Open Dashboard → Plugins → Upload Plugin
- Select or drag your .zip (max 25 MB, max 200 files)
- Plugin is installed and enabled automatically
- System plugins — open from dashboard sidebar or Plugins → Open
- Block plugins — add from page-editor plugin palette
- Duplicate plugin ID returns 409 — remove the old plugin first
On failure, extracted files and partial database rows are rolled back.
Upload lifecycle (what actually happens)
Upload ZIP
→ Validate size (25 MB) and file count (200)
→ Read and validate manifest.json
→ Extract frontend → public/plugins/{pluginId}/
→ Extract backend → plugins/{pluginId}/ (if server/ present)
→ Run pending database migrations (if backend)
→ Run install hook (if backend)
→ Register manifest permissions (if backend)
→ Load server module + routes (if backend)
→ Save plugin metadata in MongoDB (isActive: true)
→ Enable plugin
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}
→ Load adminEntry in sandboxed iframe
→ Admin JS calls plugin APIs or plugin-data API with credentials: include
CMS startup (backend plugins):
→ Bootstrap loads all active plugins with hasBackend: true
→ Registers routes under /api/plugins/{pluginId}/*Enable, disable, and uninstall
| Action | Frontend | Backend |
| ------ | -------- | ------- |
| Disable | Hidden from editor/sidebar | Routes unloaded; database kept |
| Enable | Visible again | Server module and routes reloaded |
| Uninstall | Files removed from public/plugins/{id}/ | server/ removed; uninstall hook runs |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 extracted
- Frontend files → public/plugins/{id}/; backend → plugins/{id}/ only
- Backend code never executed from public/
- 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 admin/index.html — add admin/index.html or set adminEntry
- Block plugin requires an entryPoint — add index.js or declare entryPoint in manifest
- Backend plugin requires server/index.js — add server/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 upload — fix server/migrations/*.js; upload rolls back on failure
Related guides
Walk through a full example in the Plugin Development tutorial. Reference implementation with backend APIs: examples/crm-plugin/ in the CMS repository.