docs/developer/dashboard/plugins/distributing.mdx
This page is the consumer's perspective on installing your plugin. Cover it in your README so users have a one-page reference.
A consumer installing your plugin needs to:
pnpm add @your-scope/your-plugin, then restart the dev serverpnpm add @your-scope/your-plugin
# Rails side, if your plugin has a backend
bundle add your_plugin
bin/rails generate your_plugin:install
bin/rails db:migrate
No host-code edit. As long as your plugin's package.json declares the spree.dashboard.plugin marker (the CLI scaffold does this for you — see Publishing), spreeDashboardPlugin() walks the host's dependencies at build time and finds it automatically. Discovery feeds two things:
virtual:spree-dashboard-plugins (the dashboard app shell and the starter template already do), a module the Vite plugin synthesizes with one side-effect import per discovered plugin. Your defineDashboardPlugin call runs before first render without the host touching main.tsx.Installing a package activating its code in the admin is the same trust model as adding a gem to a Rails Gemfile — vet your dependencies. Hosts that want explicit control use the whitelist below.
Plugins that take configuration (the definePlugin(config) factory pattern — see below) are the exception: the factory call is host code, so the host imports and invokes it in main.tsx themselves.
For tightly controlled environments (a host that wants explicit allowlist semantics, or a plugin you want to load conditionally), pass the names explicitly:
// vite.config.ts (your dashboard app)
spreeDashboardPlugin({
plugins: ['@your-scope/your-plugin'], // disables auto-discovery
})
Passing plugins: [] disables auto-discovery entirely — useful if you want to ship a vanilla dashboard with zero third-party Tailwind sources.
If the consumer registers your plugin explicitly but it's not installed (or the spelling is off), @spree/dashboard-core/vite shows a Vite error overlay during dev with the package name and a fix-it checklist. The same overlay fires if a discovered plugin can't be resolved on disk. That replaces the older silent-failure mode where missing plugins emitted unstyled HTML.
A README that gets users to "working in five minutes" has:
@spree/dashboard >=6.0.0, Spree >=6.0.0, etc.)Skip the "philosophy" and the "alternative approaches" sections. Get users to working in minutes.
Plugins occasionally need runtime config — API endpoints, feature toggles, custom permissions. Two patterns:
configure call// in your plugin
let _config = { endpoint: '/default' }
export function configure(opts: { endpoint?: string }) {
_config = { ..._config, ...opts }
}
export function getConfig() { return _config }
// consumer
import { configure } from '@your-scope/your-plugin'
configure({ endpoint: '/custom' })
Simple, no dependencies — but mind the timing: ES module imports are hoisted, so the plugin module (including any top-level defineDashboardPlugin call) always executes before your configure(...) line runs. This pattern therefore only works for values the plugin reads lazily — at render or fetch time, like an API endpoint — never for anything its import-time registration depends on. If config shapes what gets registered (which nav entries, which routes), use the factory pattern below instead.
definePlugin(config) factory// in your plugin
export function definePlugin(config: PluginConfig) {
// call defineDashboardPlugin inside, closing over config
}
// consumer
import { definePlugin } from '@your-scope/your-plugin'
definePlugin({ endpoint: '/custom' })
A bit more boilerplate but easier to test, and the order is unambiguous — no side-effect-at-import-time. If you go this route, drop the auto-registering entry-point and document definePlugin as the way to install.
When you cut a major version of your plugin, the breaking-changes section of your changelog needs to call out:
nav.add({ key: ... }) changes are user-visible — their URLs depend on it)peerDependencies range bumps (consumers may need to upgrade Spree first)Treat the plugin's public surface as: nav keys, route paths, slot IDs, locale keys, and the exported configure / definePlugin API. Everything else is internal.
The registries are global singletons, so a key collision is the consumer's problem to debug. Mitigations:
key: 'acme-brands' not key: 'brands'.admin.acme_brands.title not admin.brands.title./api/v3/admin/acme/..., not /api/v3/admin/brands (which collides with core Brands if it ever ships).The dashboard logs a warning in dev mode when two registry entries share a key. That's the consumer's signal that two plugins are stepping on each other — and it falls back to "last-registered wins", which is rarely what either plugin wanted.
pnpm publish --access public.@your-scope:registry=https://npm.pkg.github.com to .npmrc.pnpm add github:your-org/your-plugin#main works for unpublished plugins, useful while iterating against a single consumer.For pre-1.0 plugins, ship under the next dist-tag (pnpm publish --tag next) so pnpm add @your-scope/your-plugin resolves to the stable line and pnpm add @your-scope/your-plugin@next opts in.
spreeDashboardPlugin looks like in productionbrands example) — what a finished plugin looks like end-to-end