This document explains how the pieces of the dashboard widget system relate to each other: the authoring convention, the build pipeline, the server-side registry, the client contract published as @wordpress/widget-primitives, and the hosts that render widgets.
Overview
A widget passes through several stages, each owned by a different part of the codebase:

Each stage hands the next a single artifact and nothing else: a folder convention, then the build manifest (build/widgets/registry.php), then the in-memory registry (WP_Widget_Type_Registry), then a REST record, then a WidgetType. No stage reaches into how another works.
That separation lets each stage evolve independently. It is also why the client contract lives in its own package.
Authoring: a widget is a folder
A widget is a directory under widgets/, discovered by convention; there is no registration call to write:
widgets/hello-world/
├── widget.json static metadata (name, title, description, help, actions, keywords, category, presentation, textdomain)
├── widget.ts metadata module: default-exports icon, attributes, example
├── render.tsx render module: default-exports the React component
└── style.module.css optional, injected at runtime by the build
The split between widget.json and widget.ts is deliberate. widget.json is build-time input: plain JSON the pipeline can read without executing code, including the translatable strings (title, description, help, keywords) the server localizes through textdomain.
Unlike the other translatable strings, help is an object: content plus optional links, meant for compact surfaces such as tooltips.
actions is a list of the declarative actions a widget exposes: each carries id, label, href, and optional download / openInNewTab, with the label translated. A host renders them as links and decides where; the dashboard surfaces them in a “More” menu. Because navigation and download are the browser’s, the widget declares an intent and a target without knowing its surface.
widget.ts is the live half of the metadata: values that only exist in JavaScript, such as the icon element or the attributes field schema (including optional relevance hints) that hosts feed into DataForm.
Its render component receives the widget’s attributes and, optionally, setAttributes:
export default function HelloWorld( { attributes } ) { ... }
Build: from folders to script modules
@wordpress/build (packages/wp-build/) is the generic build tool for packages, routes, and widgets. For widgets specifically, it:
- Discovers every directory under
widgets/and reads itswidget.json. - Compiles up to two ES script modules per widget:
render(fromrender.*) andwidget(fromwidget.*). Either may be absent; a missing source file produces no module. Each compiled module ships an*.asset.phplisting its module dependencies and a cache-busting version. - Emits
build/widgets/registry.php, the manifest: one entry per widget with its directory name, metadata, and which modules were built. - Emits
build/widgets.php, which atinitcallswp_register_script_module()for every built module, with IDs derived from the folder name (<prefix>/widgets/<dir>/renderand<prefix>/widgets/<dir>/widget).
The server registry
WP_Widget_Type_Registry (lib/experimental/dashboard-widgets/) is a singleton, hydrated at init from the manifest. Each entry becomes a WP_Widget_Type with name, render_module, widget_module, presentation, category, and the translatable title, description, help, actions, and keywords (localized at registration time using the widget’s textdomain).
The hydration is a deterministic copy, with no filters in between. The widgets/ folder is the single source of widget authorship in this codebase.
The registry is the server’s authoritative list of widget types for the site. Two consumers read it:
- The REST controller (
WP_REST_Widget_Modules_Controller) exposes it at/wp/v2/widget-modules, returning{ name, render_module, widget_module, presentation, category, title, description, help, actions, keywords }per record. - The dashboard page hooks its
dashboard-wp-admin_boot_dependenciesfilter, a per-page instance of the generic{page-slug}-wp-admin_boot_dependencies, and adds every registered module to its import map as adynamicdependency. A dynamic dependency is reachable byimport()but never executed eagerly.
Registration only makes the modules known to WordPress; loading them is a separate, per-host decision. Dynamic import() against the import map is how the dashboard loads widgets today. A host can load them another way: enqueue a module eagerly (wp_enqueue_script_module()), declare it as a static dependency of its own module, or, outside WordPress, skip the import map and resolve modules through its own ResolveWidgetModule.
The registry exists as a class, rather than having REST read the manifest directly, so the source of widget types stays an implementation detail. Today that source is only the build manifest. A future source, such as a plugin-facing registration API, would target the registry without touching the build pipeline.
The client contract: @wordpress/widget-primitives
Everything after the REST record is the job of @wordpress/widget-primitives, the contract both widget authors and hosts share. Its full surface (the contract types, the discovery hook, and the render component) is covered in the Widget Primitives / Introduction story. In the pipeline it does two things.
useWidgetTypes( records ) takes the host-supplied records, imports each record’s widget_module for the live metadata, and merges it with the record into WidgetType[]. The record’s presentation, category, title, description, help, actions, and keywords, all sourced from widget.json (with title, description, help, actions, and keywords localized server-side), win over the module’s value. The hook reaches for no store or endpoint; a host such as the dashboard reads its own widgetModule core-data entity (backed by /wp/v2/widget-modules) and passes the records in.
A module’s attributes may also reference field types by name (type: 'location'). The application registers those definitions up front through registerFieldType() (the dashboard route registers its own on boot), and useWidgetTypes resolves every named reference through that registry while building each WidgetType: the registered definition supplies the field’s behavior on top of its DataViews baseType, and hosts receive plain DataViews fields. The widget declaration stays serializable; resolution happens once, at this boundary.
<WidgetRender> then resolves a WidgetType.renderModule through a host-provided ResolveWidgetModule and mounts the component with the attributes / setAttributes contract. On a WordPress page the resolver can be as simple as ( id ) => import( id ); hosts with other loading strategies supply their own.
For what a widget declares through that contract, layer by layer, see Anatomy of a widget type.
Hosts
The dashboard engine (@wordpress/widget-dashboard, mounted by routes/dashboard/) is the host this repository ships today. It shows what a host owns:
- Registers the
widgetModulecore-data entity in its page init module, which@wordpress/bootruns before the dashboard renders. - Reads the entity’s records and passes them to
useWidgetTypes( records ). - Owns the layout array and its persistence.
- Wraps every instance in its own chrome: header, toolbars, error boundary, Suspense fallback.
- Passes
resolveWidgetModuledown through its context, overridable for tests and Storybook.
The same WidgetType could be rendered by any other host, and where and how to render belongs entirely to the host. Every host is a consumer of the package, but not every consumer is a host: tests, Storybook, or a picker that only lists widget types consume the same contract without rendering anything.
Why a standalone package
The pipeline above splits in one place: everything up to the REST endpoint is WordPress infrastructure, and everything after WidgetType[] is a host concern. The contract in between is small, stable, and needed by both sides, which is the shape of a package:
- Widget authors depend on it to type their metadata and render components.
- Hosts depend on it to discover and mount widgets without knowing how they were built or registered.
- Neither side needs the other’s dependencies: the package keeps its own footprint minimal (
element, plus type-onlydataviews); discovery is data-source agnostic, so it no longer depends oncore-data/data.