packages/dashboard-plugin-example/README.md
Reference implementation of a Spree dashboard plugin. Adds a "Brands" feature to the admin via defineDashboardPlugin from @spree/dashboard-core.
This package is the runnable companion to the Dashboard section of the developer docs. Start with overview.mdx for the lay of the land, then plugins/overview.mdx for the packaging story. Read the source here for a concrete template you can copy.
Six extension points, all in one plugin:
/$storeId/brands (list page using <ResourceTable>) and /$storeId/brands/$brandId (detail page reading the $brandId path param)defineTable('brands', ...) declares columns, default sort, search paramsproduct.form_sidebar slotadmin.brands_plugin.* keys deep-merged into the framework's i18next namespaceThe plugin calls adminClient.request('GET', '/brands') etc. against a hypothetical Brands Admin API. This package does not ship that backend — a real Brands plugin would publish a Rails engine (spree_brands) alongside this dashboard plugin that registers:
Spree::Brand modelSpree::Api::V3::Admin::BrandsController (a ResourceController subclass)Spree::Api::V3::Admin::BrandSerializer (Alba)/api/v3/admin/brandsSpree::BrandThe dashboard plugin is the front end; the gem is the back end. They ship together but live in separate packages.
For the request layer pattern (adminClient.request<T> against custom endpoints), see Custom Admin Endpoints in the Spree docs.
In this monorepo, the dashboard's vite.config.ts opts into plugin auto-discovery behind an env flag — no code changes needed:
cd packages/dashboard
VITE_EXAMPLE_PLUGIN=true pnpm dev
That exercises the exact path a real host uses: discovery finds this package via its spree.dashboard.plugin marker, and the virtual:spree-dashboard-plugins module imported by main.tsx activates it before the router mounts. Nav entry registers, /brands route mounts, slot widget appears on product pages, table column lands on the products table.
In your own dashboard app, pnpm add @spree/dashboard-plugin-example and restart the dev server — same mechanism, no host-code edit. If your entry doesn't import the virtual module (custom admin apps built directly on @spree/dashboard-core), a plain side-effect import does the same job:
import '@spree/dashboard-plugin-example'
src/
├── index.tsx ← plugin entry — defineDashboardPlugin call
├── types.ts ← Brand interface (would be Typelizer-generated in a real plugin)
├── client.ts ← adminClient.request wrappers
├── routes/
│ └── brands-list.tsx ← /brands page using <ResourceTable>
├── slots/
│ └── product-brand-card.tsx ← product.form_sidebar widget
└── locales/
└── en.json ← plugin translations
defineTable runs at import timeThe table definition (defineTable('brands', ...)) is declared at module scope so it's registered as soon as the plugin's entry is imported — before <ResourceTable tableKey="brands"> ever mounts. The catch-all route dispatcher reads from the route registry on every navigation, so plugin registration before first render isn't strictly required, but mountain-then-register patterns make for fragile UX. Always register at module top-level.
subject gates visibilitysubject: 'Spree::Brand' on the nav entry hides the sidebar item for admins without read permission on brands. The catch-all route checks the same subject and renders a 403 fallback if a user reaches /brands via a direct URL despite not having permission. Server-side enforcement still runs — this is UX, not authorization.
product.form_sidebar slots receive { product, permissions, store, user }. The plugin's <ProductBrandCard> types only the fields it reads ({ product: { id, brand_id } }); a slot author should never assume more than what's documented in the dashboard's slot catalogue. Slot names and their context shapes are documented per-page in @spree/dashboard.
addResourceBundle deep-merges, so two plugins both writing admin.actions.share would race — last write wins. Put plugin-specific text under your own namespace (admin.brands_plugin.*) to stay clear of the framework and other plugins. See the dashboard-core README for the full convention.
This package lives in the Spree monorepo but mirrors the shape of a real external plugin. Build it with the rest of the workspace:
pnpm install
pnpm -F @spree/dashboard-plugin-example typecheck
The dashboard imports it via the workspace symlink (@spree/dashboard-plugin-example in packages/dashboard/package.json once added). Run the dashboard with pnpm dev and the plugin's nav entry, slot widget, table column, and /brands route all light up.
MIT. Copy this package shape for your own plugin; it's intended as a template.