docs/developer/dashboard/plugins/scaffolding.mdx
@spree/cli ships a plugin new command that scaffolds the whole plugin monorepo for you. Generated layout:
your-plugin/
├── package.json # workspace root
├── pnpm-workspace.yaml
├── LICENSE
├── README.md
├── .gitignore
└── packages/
└── dashboard/
├── package.json # @your-scope/dashboard-your-plugin
├── tsconfig.json
├── biome.json
└── src/
├── index.tsx # defineDashboardPlugin entry
├── client.ts # adminClient request wrappers
├── types.ts # plugin-specific TS types
├── pages/
│ └── <name>-list.tsx
├── routes/ # TanStack file routes, compiled into the host's typed route tree
│ └── <name>.index.tsx
├── slots/
│ └── product-<name>-card.tsx
└── locales/
└── en.json
You get a working "Brands"-style plugin out of the box: nav entry, a list page wired through React Query, a product-detail slot card, and English translations.
npx @spree/cli plugin new my-plugin
The CLI prompts for:
MyPluginCard)@acmepackage.json and LICENSE; prefilled from git config user.name / user.emailEvery prompt has a matching flag:
npx @spree/cli plugin new my-plugin \
--ruby-name spree_my_plugin \
--module-name MyPlugin \
--npm-scope @acme \
--author "Ada Lovelace" \
--author-email "[email protected]" \
--license MIT \
--no-install # skip pnpm install
For CI and scripted use, -y / --yes accepts the default for anything the flags leave unanswered — author and email come from your git config, everything else derives from the plugin name:
npx @spree/cli plugin new my-plugin -y
--force overwrites an existing directory. --no-dashboard skips the dashboard package (Rails-engine-only plugins). --no-engine is reserved — engine scaffolding lands later.
cd my-plugin
pnpm install # runs by default unless --no-install
pnpm build
You now have a buildable plugin. The next page covers what to put in package.json for publishing — but if you're using it internally and importing by path, you can stop here and skip Publishing.
packages/dashboard/src/index.tsx is where everything starts. The generated version, trimmed:
import { defineDashboardPlugin, defineTable, i18n } from '@spree/dashboard-core'
import { PackageIcon } from 'lucide-react'
import en from './locales/en.json'
import { MyPluginCard } from './slots/product-my-plugin-card'
// 1. Translations — merge our keys into the shared bundle.
i18n.addResourceBundle('en', 'translation', en, true, true)
// 2. Declare the table our list page renders with <ResourceTable>.
defineTable('my-plugin', {
title: i18n.t('admin.my_plugin.table.title'),
defaultSort: { field: 'name', direction: 'asc' },
columns: [/* … */],
})
// 3. Registry extensions. Routes are NOT registered here — they ship as
// file routes in src/routes/ (declared via the package.json marker) and
// compile into the host's typed route tree. See the routes guide.
defineDashboardPlugin({
nav: [{
key: 'my-plugin',
label: i18n.t('admin.my_plugin.title'),
path: '/my-plugin',
icon: PackageIcon,
position: 250,
}],
slots: {
'product.form_sidebar': [{
id: 'my-plugin-card',
component: MyPluginCard as never,
position: 50,
}],
},
})
That's the whole entry-point. Edit it to register what your plugin needs and remove what it doesn't.
The generated client.ts calls adminClient.request() against /<name> (relative to /api/v3/admin) — those endpoints don't exist by default. You'd add them on the Rails side (a controller, a model, a serializer) for the plugin to do anything end-to-end. The CLI doesn't scaffold the Rails engine today; for now, write that part by hand using the backend integration docs.
@spree/cli source.tt files are template-substituted)