Plugin Development Guide
A feature plugin is the other kind of EduNest add-on (the first being channel integrations — see the Add-on Development Guide). Where a channel add-on connects an external provider, a feature plugin adds a whole capability — admin pages, learner pages, public pages, API endpoints, navigation, scheduled hooks, and its own database tables. Certificates, Gamification, Forums, Exams, Assignments, and dozens more ship this way.
Feature plugins live alongside channel add-ons under src/product/addons/<id>/, are declared with "feature": true in their manifest, and are activated by license entitlement + an admin toggle (no files to upload). This guide uses Certificates (src/product/addons/certificates/) as the worked example. The plugin contract is defined in src/core/plugins/types.ts.
Anatomy of a Feature Plugin
src/product/addons/certificates/
addon.json manifest (feature: true, premium, templates…)
index.ts exports `plugin` (the FeaturePlugin object)
schema.ts ensureSchema — idempotent CREATE TABLE
hooks.ts core-event handlers (issue on course.completed)
api.ts API handlers (templates CRUD, mine, verify)
admin.tsx admin page component
learner.tsx learner page component
public.tsx public verification page
installation-guide.md docs shown in admin
The manifest
{
"id": "certificates",
"name": "Certificates",
"type": "code",
"feature": true,
"products": ["edunest-lms"],
"version": "1.1.0",
"premium": true,
"vendor": "creative-cape.com",
"category": "lms",
"templates": [
{ "slug": "certificate_issued", "channel": "EMAIL",
"name": "Certificate Issued",
"subject": "Your certificate for {{course}} 🎓",
"body": "Hi {{name}}, congratulations on completing {{course}}! …" }
]
}
"feature": true marks it a plugin. Any templates are seeded into the notification system on activation (idempotently), so the plugin's emails/SMS/WhatsApp messages exist as soon as it's switched on.
The FeaturePlugin object
index.ts exports a plugin matching the FeaturePlugin interface:
import type { FeaturePlugin } from "@/core/plugins/types";
export const plugin: FeaturePlugin = {
id: "certificates",
name: "Certificates",
ensureSchema: ensureCertificatesSchema,
nav: [
{ area: "admin", label: "Certificates", icon: "Award", slug: "", permission: "certificates" },
{ area: "learner", label: "Certificates", icon: "Award", slug: "" },
],
adminPages: { "": CertificatesAdmin },
learnerPages: { "": LearnerCertificates },
publicPages: { "verify/:code": VerifyCertificate },
api: {
"GET templates": listTemplates,
"POST templates": createTemplate,
"GET templates/:id": getTemplate,
"PUT templates/:id": updateTemplate,
"DELETE templates/:id": deleteTemplate,
"GET mine": mine,
"GET verify/:code": verify,
},
hooks: { "course.completed": issueCertificate },
};
The full shape (src/core/plugins/types.ts):
| Field | Purpose |
|---|---|
id, name |
Identity (id matches the folder and license key) |
ensureSchema? |
Idempotent CREATE TABLE …, run on activation |
nav? |
Sidebar entries (area: admin / learner / instructor; slug; optional permission) |
adminPages? |
sub-slug → component ("" is the plugin root); rendered by the admin catch-all |
learnerPages? |
sub-slug → component; rendered by the learner catch-all |
publicPages? |
pattern → component (e.g. "verify/:code"); rendered by the site catch-all |
api? |
"METHOD pattern" → handler (e.g. "GET verify/:code") |
hooks? |
Handlers for core domain events |
slots? |
Components injected into named core slots (e.g. learner.dashboard.cards) |
settings? |
Per-plugin settings fields (text/secret/select/switch) edited on its admin page |
loginButtons? |
Buttons rendered on login/register (e.g. SSO) |
Schema
ensureSchema follows the project-wide ensure-schema pattern — a guarded, idempotent function that creates the plugin's tables. It runs automatically when the plugin is activated:
export async function ensureCertificatesSchema(): Promise<void> {
await sql`CREATE TABLE IF NOT EXISTS certificate_templates ( … )`;
await sql`CREATE TABLE IF NOT EXISTS certificates (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL,
course_id INT NOT NULL,
certificate_no VARCHAR(64) UNIQUE,
UNIQUE (customer_id, course_id)
)`;
}
Hooks on core events
Core emits domain events (src/core/plugins/types.ts → CoreEvent): course.completed, lesson.completed, enrollment.created, order.paid, order.refunded, user.registered, user.login, certificate.issued, admin.action. A plugin subscribes by adding a handler. Certificates issues a certificate on completion and notifies the learner across configured channels:
export async function issueCertificate(payload: unknown): Promise<void> {
const p = payload as { courseId?: number; customerId?: number };
if (!p?.courseId || !p?.customerId) return;
const no = `EN-${p.customerId}-${p.courseId}-${Date.now().toString(36).toUpperCase()}`;
const [ins] = await sql`INSERT INTO certificates (customer_id, course_id, certificate_no)
VALUES (${p.customerId}, ${p.courseId}, ${no})
ON CONFLICT (customer_id, course_id) DO NOTHING RETURNING id`;
if (!ins) return; // already issued
await notifyLearner(p.customerId, "certificate_issued", { course: courseTitle });
}
API handlers
Each API entry maps "METHOD pattern" to a PluginApiHandler(req, ctx) where ctx.params carries pattern matches and ctx.query is the search params. Handlers reuse the same auth helpers as core — requirePermission() for admin endpoints, getCustomerId() for learner endpoints:
export const listTemplates: PluginApiHandler = async () => {
const denied = await requirePermission("read", "certificates");
if (denied) return denied;
const rows = await sql`SELECT * FROM certificate_templates ORDER BY created_at DESC`;
return Response.json(rows);
};
How Plugins Are Rendered (catch-all routes)
Plugins don't define their own Next.js route files. Three catch-all routes plus one API dispatcher resolve active plugins at runtime:
| Surface | Route file | URL |
|---|---|---|
| Admin pages | src/app/admin/(panel)/x/[...slug]/page.tsx |
/admin/x/<id>/<slug> |
| Learner pages | src/app/learner/x/[...slug]/page.tsx |
/learner/x/<id>/<slug> |
| Public pages | src/app/(site)/x/[...slug]/page.tsx |
/x/<id>/<slug> |
| API | src/app/api/v1/ext/[addon]/[...path]/route.ts |
/api/v1/ext/<id>/<path…> |
The API dispatcher looks up the active plugin, walks its api map, matches the "METHOD pattern" key against the request path, and calls the handler:
const feature = (await getActiveFeatures()).find((f) => f.id === addon);
for (const [key, handler] of Object.entries(feature.api)) {
const [m, pattern = ""] = key.trim().split(/\s+/);
if (m.toUpperCase() !== method) continue;
const params = matchPattern(pattern, parts);
if (params) return handler(req, { params, query });
}
So a Certificates verification call hits /api/v1/ext/certificates/verify/ABC123 and matches "GET verify/:code".
Activation & Licensing
A plugin is usable only when it is installed (present in the generated FEATURES list, built-time) and active (a row in the plugin_state table). Activation is handled by setFeatureActive() (src/core/plugins/state.ts):
export async function setFeatureActive(id: string, active: boolean): Promise<void> {
const f = getFeature(id);
if (!f) throw new Error("Unknown feature plugin.");
if (active && f.ensureSchema) await f.ensureSchema(); // create tables
if (active && ADDON_TEMPLATES[id]) await seedAddonTemplates(…); // seed templates
await sql`INSERT INTO plugin_state (id, active, activated_at) VALUES (${id}, ${active}, NOW())
ON CONFLICT (id) DO UPDATE SET active = ${active}`;
}
On activation it runs ensureSchema, seeds declared templates, and flips the plugin_state row. getActiveFeatures() is the single resolver every catch-all and the event bus use, so deactivating a plugin instantly hides its nav, pages, and hooks (its data is kept). Settings are stored per-plugin in the same plugin_state.settings JSON column via saveFeatureSettings().
License gate
Premium plugins are gated by featureBlock(headers, featureId) (src/lib/premium.ts), mirroring the channel logic. A premium plugin activates only when the license entitlements carry the feature id (or "*"/"all"), or a per-add-on purchase code has been activated for the domain. The free/premium split is in src/core/channels/tiers.ts (FREE_FEATURES is empty by default — every feature is premium); development/localhost hosts unlock everything.
Admins manage activation from Settings → Integrations — toggling a feature on (which calls setFeatureActive) and, if needed, entering a purchase code to satisfy the entitlement.
© CreativeCape Solutions · creative-cape.com · support@creative-cape.com