# How the Instatic Dashboard Workspace and Widget Registry Work

> Explore the Instatic dashboard workspace and widget registry. Learn how its configurable tile grid and component registry enable custom layouts and widget contributions from core modules and plugins.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-07-29

---

**The Instatic admin dashboard is a configurable 12-column tile grid whose layout state is stored in a user-level preference, while a singleton registry maps widget IDs to React components that can be contributed by both core modules and plugins.**

The Instatic project (`CoreBunch/Instatic`) renders the admin home at `/admin/dashboard` as a personalized workspace. The system is built around two core abstractions: a persisted grid layout managed by [`useDashboardLayout.ts`](https://github.com/CoreBunch/Instatic/blob/main/useDashboardLayout.ts) and a central `dashboardWidgetRegistry` that dynamically assembles the available tiles. Together, these mechanisms let first-party code and third-party plugins define, position, and hydrate dashboard widgets without blocking one another.

## 12-Column Grid Layout and State Persistence

The visual surface is provided by `DashboardGrid`, a CSS grid component configured with twelve columns and fixed-height rows. Each occupied cell hosts a widget that can span multiple columns and rows.

When the application boots, the grid first renders the **`DEFAULT_LAYOUT`** constant. It then overwrites that baseline with any user-specific configuration stored in the **`dashboard-layout`** site preference. This hydration logic lives in [[`src/admin/pages/dashboard/hooks/useDashboardLayout.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/dashboard/hooks/useDashboardLayout.ts)](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/dashboard/hooks/useDashboardLayout.ts), which watches the preference and re-applies the saved positions, sizes, and order on every page reload.

## Widget Registry Architecture

All runnable widgets are tracked by the singleton `dashboardWidgetRegistry` exported from [[`src/core/dashboard/registry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/dashboard/registry.ts)](https://github.com/CoreBunch/Instatic/blob/main/src/core/dashboard/registry.ts). The registry maintains a live map of `DashboardWidgetDefinition` objects keyed by their namespaced `widgetId`.

Every definition must supply:

- `widgetId` — a namespaced string such as `core.pages` or `myPlugin.visitors`
- `component` — the React component rendered inside the widget card
- `defaultSize` — the initial `span` and `rows` consumed in the grid
- `iconName` — a pixel-art icon key or custom icon path
- `ownerId` — `core` for built-in widgets, or the plugin identifier for external contributions

### First-Party Widget Registration

Core widgets self-register when the dashboard mounts by way of the aggregate barrel file at [[`src/admin/pages/dashboard/widgets/index.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/dashboard/widgets/index.ts)](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/dashboard/widgets/index.ts). The file exports a `FIRST_PARTY_WIDGETS` array that loops through local definitions and enrolls them with the registry.

For example, a built-in stats widget is defined as:

```tsx
// src/admin/pages/dashboard/widgets/MyStatWidget.tsx
import { Widget } from '@ui/components/Widget';

export const MyStatWidget = ({ span, editing }) => (
  <Widget widgetId="my-stat" title="My Stats" tint="mint" span={span}>
    {/* widget content */}
  </Widget>
);

```

```tsx
// src/admin/pages/dashboard/widgets/index.ts
import { MyStatWidget } from './MyStatWidget';

export const FIRST_PARTY_WIDGETS = [
  {
    widgetId: 'my-stat',
    component: MyStatWidget,
    defaultSize: { span: 2, rows: 2 },
    iconName: 'chart-bar',
    ownerId: 'core',
  },
  // …other widgets
];

```

### Plugin Widget Registration via the Dashboard SDK

Plugins running inside the admin React process inject widgets through the **Dashboard SDK**. The concrete API is `api.dashboard.widgets.register`, which requires the caller to hold the `dashboard.widgets.register` capability.

A typical plugin registration looks like this:

```ts
// plugin admin entrypoint (runs in the admin React process)
api.dashboard.widgets.register({
  widgetId: `${pluginId}.visitors`,
  component: VisitorsWidget,
  defaultSize: { span: 3, rows: 2 },
  iconName: 'user-group',
  ownerId: pluginId,
});

```

As documented in the [plugin-system.md](https://github.com/CoreBunch/Instatic/blob/main/docs/features/plugin-system.md#dashboard-widget-registry) spec, the namespaced `widgetId` prevents collisions between core and plugin tiles.

## Customization Mode and Drag-and-Drop

When an admin clicks the customize toolbar button, the workspace enters an editing state. Unused widgets surface in a bottom-docked **Block Library** rendered by [`BlockLibrary.tsx`](https://github.com/CoreBunch/Instatic/blob/main/BlockLibrary.tsx).

### Draggable Sources and Valid Drop Targets

Both grid cells and library tiles act as draggable sources. `@dnd-kit` identifies them by structured IDs such as `widget:<widgetId>` or `library:<widgetId>`. Users can move an existing tile, pull a new widget from the library into the grid, or return an active tile to the library to remove it.

### Drop Validation and Ghost Behavior

The drag ghost is rendered only when the pointer hovers over a valid drop target. If the cursor overlaps an occupied or invalid cell, the target resolves to `null`, and the drop is prevented. This guards the 12-column invariant and stops widgets from colliding during layout edits.

## Data-Backed Widgets and Permission Gating

Widgets that surface live metrics—pages, storage, AI usage, activity, and so on—own isolated data hooks rather than sharing a global fetcher.

### Domain-Specific Data Hooks

Each domain exposes a dedicated endpoint under `/admin/api/cms/dashboard/<domain>`. The generic `apiRequest` client issues the call, and the response is validated through a TypeBox schema inside the hook. For instance, `usePagesStats` (part of the broader `useDashboardStats` family) consumes the pages domain endpoint and returns a typed payload.

A concrete consumption pattern is visible in widgets such as `PagesWidget`:

```tsx
// src/admin/pages/dashboard/widgets/PagesWidget.tsx
import { usePagesStats } from '../hooks/useDashboardStats';

export const PagesWidget = ({ span }) => {
  const { data, loading } = usePagesStats();
  return (
    <Widget widgetId="pages" title="Pages" tint="sky" span={span}>
      {loading ? <SkeletonBlock /> : <StatValue>{data?.total}</StatValue>}
    </Widget>
  );
};

```

While loading, the widget renders a skeleton; on error, it falls back to an empty state so one slow tile cannot block the rest of the Instatic dashboard workspace.

### API-Level Capability Checks

Notably, the `DashboardWidgetDefinition` does **not** embed a capability list. Authorization happens at the network boundary: the `handleDashboardRoutes` request handler invokes `requireCapability` before returning any payload. If the user lacks the required permission, the widget hook receives an empty or skeleton response, keeping sensitive data out of the UI without burdening the registry with role logic.

## Summary

- The **Instatic dashboard workspace** is a 12-column CSS grid whose layout state is the single source of truth persisted in the `dashboard-layout` user preference.
- **First-party widgets** register through the barrel file at [`src/admin/pages/dashboard/widgets/index.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/dashboard/widgets/index.ts), while **plugins** call `api.dashboard.widgets.register` after obtaining the correct capability.
- The `dashboardWidgetRegistry` singleton in [`src/core/dashboard/registry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/dashboard/registry.ts) stores every `DashboardWidgetDefinition` by its namespaced `widgetId`.
- **Customization mode** leverages `@dnd-kit` for moves, additions, and removals, with strict drop validation to prevent overlapping tiles.
- **Data hooks** fetch per-domain metrics independently and render skeleton states, and the API layer guards sensitive payloads via `requireCapability` rather than widget-level ACLs.

## Frequently Asked Questions

### How is the default dashboard layout restored on login?

The `useDashboardLayout` hook initially applies the hard-coded `DEFAULT_LAYOUT` and then merges any saved user preference named `dashboard-layout` from the `site_preferences` table. If the admin has never customized the grid, the fallback baseline remains visible.

### Can a plugin add widgets to the Instatic dashboard without modifying core code?

Yes. Plugins use the Dashboard SDK method `api.dashboard.widgets.register` inside their admin entry point, provided they declare or are granted the `dashboard.widgets.register` permission. The registry accepts the plugin’s `ownerId` and `widgetId` without changes to [`src/core/dashboard/registry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/dashboard/registry.ts).

### What prevents widgets from overlapping during drag-and-drop customization?

The `@dnd-kit` integration computes whether the dragged item’s target coordinates are valid. If the destination overlaps an occupied cell or lies outside the 12-column bounds, the drop target becomes `null`, the ghost disappears, and the drop action is cancelled.

### Where does the dashboard store each widget’s live data?

Individual widgets call their own hooks—such as `usePagesStats` or `useDashboardStats`—which hit per-domain routes like `/admin/api/cms/dashboard/<domain>`. The layout registry and the grid itself never manage or cache metric data; that responsibility stays isolated within each widget’s data layer.