Dashboard Widget Architecture
Technical deep-dive into dashboard widget packages β folder structure, widget.json manifest, dual registration, and licensing gates.
On this page
1. Package Model
Every dashboard widget is a Portable Module: a self-contained folder that can be shipped, uploaded to marketplace.sveltycms.com, and installed into any SveltyCMS installation without touching dashboard core code.
widgets/<kebab-folder>/
βββ <component>.svelte # Required β the widget component
βββ widget.json # Required β marketplace/telemetry manifest
βββ <component>.mdx # Required β marketplace description (features, licensing, data source)
βββ tests/ # Optional β unit tests
Why a folder per widget?
- Marketplace uploads β the marketplace API accepts package folders directly;
widget.jsonis the package manifest and the co-located<component>.mdxis the package description used for the marketplace listing. - Clean installs β
installDashboardWidget()writes the whole folder via the packageinstallPath, no file-by-file merging. - Stable registry keys β the folder id (
widget.json.id) is the key persisted insystem-preferences. The entry filename is alwaysindex.svelte; renaming the implementation file never resets user layouts.
2. The widget.json Manifest
{
"id": "system-health",
"name": "System Health",
"description": "Monitor system services and overall health",
"icon": "mdi:heart-pulse",
"version": "1.0.0",
"type": "dashboard-widget",
"author": "SveltyCMS",
"license": "free",
"price": 0,
"component": "index",
"defaultSize": { "w": 2, "h": 2 },
"category": "monitoring",
"sveltycms": ">=0.0.8"
}
| Field | Type | Description |
|---|---|---|
id |
string |
Kebab-case folder name (package id). |
name |
string |
Display name shown in the picker / marketplace. |
description |
string |
Short description for cards and listings. |
icon |
string |
Iconify icon id. |
version |
string |
Semver package version. |
type |
"dashboard-widget" |
Fixed type discriminator. |
author |
string |
Publisher name. |
license |
"free" \| "freemium" \| "paid" |
Monetization model. |
price |
number |
EUR price (0 for free). |
component |
string |
Entry filename without .svelte (always "index"). |
defaultSize |
{ w: 1-4, h: 1-4 } |
Recommended grid size. |
category |
"monitoring" \| "logs" \| "content" \| "static" |
Default fetch/cache/refresh behavior group. |
requiresPlugin |
string (optional) |
Picker omits the package unless this plugin is on. |
The manifest must agree with the componentβs widgetMeta export (name, icon, description, defaultSize). manifest-registry.ts reads manifests at build time via import.meta.glob("./*/widget.json", { eager: true }).
3. Dual Registration
Widgets are registered in two places, both compile-time β Svelte modules are not evaluated to build the picker:
-
Server metadata β
+page.server.tsreadsgetInstalledDashboardWidgets()(widget.jsonglob) and maps picker entries viamanifestsToPickerList(). Packages whosesveltycmsrange the host does not satisfy are omitted. The same load hydrates the saved layout fromsystem.preferencesso the client skips a/api/system-preferencesround-trip. -
Client runtime β
+page.sveltebuilds the picker fromdata.availableWidgets(JSON). Widgets are optional: none ship on an empty dashboard.import.meta.glob("./widgets/*/*.svelte")is a lazy chunk map only β a package is imported when the user adds it or its tile intersects the viewport. Packages withrequiresPluginstay out of the picker until that plugin is enabled (Commerce Orders / Inventory βcommerce, off by default). The registry is keyed by the package folder id (widget.json.id).
The folder id is the stable key β widget.json.id matches the folder, layouts persist that id, and the entry filename (index.svelte) is an implementation detail that can never break a saved layout. In dev, the Vite plugin (sveltyCmsPlugin) watches src/routes/(app)/dashboard/widgets/** and triggers a full reload on package add/remove β new widgets appear in the picker without a manual restart (same treatment as src/widgets).
Polling widgets pause while document.hidden is true and refetch when the tab is visible again if the poll interval has elapsed. Widget fetches use cache: "no-store" instead of a _=Date.now() query string.
4. Base Widget Contract
All widgets compose base-widget.svelte, which provides fetch/poll/retry/cache/refresh chrome, the header, resize handles, and keyboard-accessible controls:
<BaseWidget
{label} {theme} {icon} {widgetId} {size}
endpoint="/api/dashboard/health"
pollInterval={5000}
onSizeChange={onSizeChange}
onCloseRequest={onRemove}
>
{#snippet children({ data, error, isLoading, refresh })}
<!-- render data -->
{/snippet}
</BaseWidget>
The children snippet receives { data, updateWidgetState, getWidgetState, refresh, isLoading, error }.
5. Component Metadata (widgetMeta)
Each component still exports module-level metadata (keep it in sync with widget.json). The Add Widget picker reads widget.json only so Svelte modules stay lazy:
<script lang="ts" module>
export const widgetMeta = {
name: "System Health",
icon: "mdi:heart-pulse",
description: "Monitor system services and overall health",
defaultSize: { w: 2, h: 2 },
};
</script>
widget.json drives the picker, packaging, catalog, and telemetry. widgetMeta remains on the component for local documentation β keep the two in sync.
6. π Licensing Gates
Dashboard widgets use the same license model as content widgets and plugins:
| Model | Behavior |
|---|---|
| Free | No checks; bundled or community packages. |
| Freemium | 14-day key-less trial; premium features gate on checkExtensionLicense. |
| Paid | License required from install; widget renders an upgrade prompt otherwise. |
- Client-side gating β
checkExtensionLicense("dashboard", widgetId); on failure render<UpgradePrompt extensionId="dashboard:<widgetId>" price="β¦" />instead of content. - Server-side gating β premium dashboard endpoints are gated in
src/routes/api/[...path]/handlers/dashboard-license.ts(requireDashboardWidgetLicense), which callscheckExtensionLicense("dashboard", widgetId)and returns403 LICENSE_REQUIREDwhen the trial expired without a license. The endpoint β widget-id map (DASHBOARD_ENDPOINT_LICENSE) covers/api/dashboard/{audit, logs, security, scim, cache-metrics, online-user, metrics, commerce-orders, commerce-inventory}and/api/database/pool-diagnostics. Free widgets are not gated. When you add a premium widget backed by a new endpoint, register it in that map.
Example (cache-monitor/index.svelte):
{#if licenseStatus && !licenseStatus.active && !licenseStatus.hasLicense}
<!-- Upgrade prompt -->
{:else}
<BaseWidget endpoint="/api/dashboard/cache-metrics" ...>β¦</BaseWidget>
{/if}
7. Installed-Package Inventory
manifest-registry.ts exposes the installed inventory for consumers that must not compile Svelte:
import { getInstalledDashboardWidgets } from "./widgets/manifest-registry";
const packages = getInstalledDashboardWidgets(); // sorted by name
// β [{ id: "system-health", name: "System Health", license: "free", β¦ }]
Used by the marketplace catalog (marketplace-service.ts) and telemetry (telemetry-service.ts).