About spree-dashboard
spree-dashboard is a skill for developers customizing their own Spree 6 React admin dashboard. It explains how to extend the app with plugins, routes, navigation, slots, tables, forms, and permissions through the dashboard APIs.
Key features
- 01Register dashboard plugins
- 02Customize navigation and routes
- 03Add widgets through slots
- 04Extend tables and form fields
- 05Use dashboard permissions APIs
Use cases
- 01Add custom pages to a Spree admin dashboard
- 02Place widgets in built-in admin pages
- 03Extend admin tables and forms
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-dashboardAfter installing, type/spree-dashboardin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/public-api.mdrecipes.mdslots.mdDetails
From the project READMESpree Dashboard (React admin) — in-app customization
The Spree 6 admin is a React SPA that talks only to the Admin API (/api/v3/admin/*). It is a Developer Preview — packages are 1.0.0-beta.x and APIs can still move, so check the installed barrel (node_modules/@spree/dashboard-core/src/index.ts) when a name doesn't resolve. The Rails spree_admin gem no longer exists; there are no ERB views, partials, Stimulus or Turbo to override.
This skill is for customizing your own dashboard app. To ship a customization as an npm package other stores install, see spree-dashboard-plugins (same APIs, different packaging).
Where it lives
my-store/
├── server/ # Rails app (Spree gems, Admin API) — `backend/` in older projects
└── apps/
├── dashboard/ # the "host app" — you own this
│ ├── src/main.tsx # mounts <Dashboard>, imports plugins + virtual:spree-dashboard-plugins
│ ├── src/plugins.ts # ← your registrations
│ ├── src/styles.css # @import "@spree/dashboard/styles.css" + your token overrides
│ ├── src/routeTree.gen.ts # generated on every dev start/build (and by `spree add`) — commit it
│ └── vite.config.ts # spreeDashboardPlugin() + /api proxy
└── seller-dashboard/ # optional marketplace seller panel (same model)
- New project:
npx create-spree-app my-store→ answer Yes to the React Dashboard. Existing project:spree add dashboard(--template <git-url|path>,--no-install). - Dev:
spree devruns the API;cd apps/dashboard && pnpm dev→ http://localhost:5173. Sign in with an admin email/password — no API keys (JWT in memory + httpOnly refresh cookie). Vite proxies/apiand/railstoVITE_API_PROXY_TARGET(defaulthttp://localhost:3000). - Every page is store-scoped: URLs are
/$storeId/.... Registered paths are written without the store prefix.
Stack: Vite, React 19, TanStack Router (file-based) + TanStack Query v5, React Hook Form + Zod, Tailwind v4 + shadcn/ui + Base UI, lucide-react icons, i18next, Biome, @spree/admin-sdk.
Packages — import from @spree/dashboard
| Package | Contains |
|---|---|
@spree/dashboard-ui | Design system: shadcn primitives, ResourceLayout, Empty*, RelativeTime, useConfirm, tokens. Props in, no providers. |
@spree/dashboard-core | Framework: registries, defineDashboardPlugin, providers/hooks (useAuth, usePermissions, useStore), adminClient, ResourceTable, PageHeader, Slot. |
@spree/dashboard | The app shell (routes, resource pages, locales) and a facade re-exporting both packages above. |
In the host app, always import { … } from '@spree/dashboard'. Only a distributed plugin imports -core/-ui directly, and then only from their package entry points, never deep file paths. The packages ship source, and a deep import makes Vite leave a CommonJS dependency unconverted in an installed app, so the dashboard dies before rendering (1.0.0-beta.4's setup screen hit exactly this). Full list: references/public-api.md.
Where code goes (starter convention)
| Path | Holds |
|---|---|
src/plugins.ts | Registrations only — a map of what you added |
src/pages/ | One screen per file |
src/hooks/ | Admin API wrappers (use-brands.ts) — components never call adminClient directly |
src/tables/ | defineTable calls |
src/schemas/ | Zod schemas + form value types |
src/slots/ | Widgets injected into built-in pages |
src/locales/ | Your admin.* translation bundles |
defineDashboardPlugin — the one entry point
// src/plugins.ts
import { defineDashboardPlugin, i18n } from '@spree/dashboard'
import { BarChartIcon } from 'lucide-react'
import en from './locales/en.json'
import { AnalyticsPage } from './pages/analytics'
i18n.addResourceBundle('en', 'translation', en, true, true) // translations first
defineDashboardPlugin({
nav: [{ key: 'analytics', labelKey: 'admin.analytics.nav', path: '/analytics', icon: BarChartIcon, position: 650 }],
routes: [{ key: 'analytics', path: '/analytics', component: AnalyticsPage }],
})
| Key | Does |
|---|---|
locales | { en: bundle, de: bundle } — registered before everything else in the call |
nav | Array (adds) or { add, remove, update, addChildren } |
settingsNavGroups / settingsNav | Settings sub-shell groups, then entries (array or { add, remove, update }) |
routes | Runtime-registered pages under /$storeId/… |
slots | { 'slot.name': [{ id, component, position?, if? }] } |
tables | { products: { add, update, remove } } column mutations |
formFields | { product: [{ name, from }] } extension fields on built-in forms |
customFieldComponents | { 'namespace.key': Component } custom widgets for custom field definitions |
Registries are module singletons. Keys/ids must be unique (including against built-ins) — duplicates throw at boot; the facade collects every error and rethrows once (AggregateError). Each registry also has an imperative API (nav.add, registerSlot, tables.products.addColumn, …) — use it for fine control or strongly typed slots.
Navigation
import { nav, settingsNav } from '@spree/dashboard'
nav.addChild('products', { key: 'products.brands', labelKey: 'admin.brands.nav', path: '/brands', subject: 'Spree::Brand' })
nav.insertAfter('orders', { key: 'returns-hub', label: 'Returns hub', path: '/returns-hub' })
nav.update('orders', { position: 150 })
nav.remove('loyalty')
nav.add({ key: 'help', label: 'Help', path: '/help', section: 'bottom' }) // pinned to the footer
nav.add({ key: 'onboarding', label: 'Onboarding', path: '/onboarding',
if: ({ store }) => !isConfigured(store), badge: PendingCountBadge }) // badge is a component
NavEntry:key,label|labelKey,path(required, leading/),icon,section('main'|'bottom'),position,subject+action(default'read'),if,badge,children.- Built-in top-level keys/positions:
getting-started50,home100,orders200,products300,inventory350,customers400,sellers450,loyalty475,promotions500,reports600,settings(bottom). Use 650+ to append. update(parent, { children })replaces children — useaddChild/addChildrento nest under a built-in menu.- Prefer
labelKeyoverlabel: i18n.t(...): a literal label is frozen at registration and won't follow a language switch. - Settings groups:
store,selling,shipping,marketplace,team,developer. Settings paths are prefixed with/$storeId/settings. New group:settingsNavGroups: [{ key: 'integrations', labelKey: '…', position: 700 }], thensettingsNav: [{ key: 'erp', label: 'ERP', path: '/erp', group: 'integrations', subject: 'Spree::Store', action: 'update' }](comingSoon: trueshows a disabled "Soon" entry).
Routes (custom pages)
routes: [
{ key: 'reports', path: '/reports', component: ReportsPage, subject: 'Spree::Order' },
{ key: 'report', path: '/reports/$reportId', component: ReportPage },
]
function ReportPage({ params, storeId, searchParams }: { params: Record<string, string>; storeId: string; searchParams: Record<string, unknown> }) {
return <ResourceLayout header={<PageHeader title="Report" />} main={<ReportBody id={params.reportId} />} />
}
- Registry routes are matched at navigation time by a catch-all (
/_authenticated/$storeId/$), inherit the auth guard + chrome, and can be registered late.subjectrenders a 403 page on direct navigation — pair it with the samesubjecton the nav entry. - They're not in the typed route tree, so links need casts:
<Link to={'/$storeId/reports/$reportId' as string} params={{ storeId, reportId } as never}>. - Any compiled file route at the same URL silently wins. File routes (typed, code-split,
validateSearch) are the packaged-plugin mechanism — seespree-dashboard-plugins. - Keep page state in the URL (filters, sort, page, open sheet
?edit=prod_…), not React state.
Slots — widgets inside built-in pages
defineDashboardPlugin({
slots: { 'product.form_sidebar': [{ id: 'acme-brand-card', component: BrandCard as never, position: 50 }] },
})
// or fully typed:
registerSlot<{ product: Product }>('product.form_sidebar', { id: 'acme-brand-card', component: ({ product }) => <BrandCard product={product} /> })
- The component receives only the slot's context (
{ product },{ order },{ resource }, …). Ambientpermissions/store/userare not injected yet — callusePermissions(),useStore(),useAuth(). The entryifsees only the slot context. as neveris expected with the facade (slot map is type-erased); useregisterSlot<Ctx>for type safety.removeSlot(name, id)/updateSlot(name, id, patch)alter any entry, including another plugin's.- Main slots:
page.actions,page.actions_dropdown,page.tabs,product|category|collection|order|customer.form_sidebar,store.form_main,company.form_main|form_sidebar,seller.form_main|form_sidebar, and per-type editor slots (payment_method.form.<type>,promotion.rule_form.<type>, …). Full catalog with contexts: references/slots.md.
Tables
import { tables } from '@spree/dashboard'
tables.products.addColumn({ key: 'brand', label: 'Brand', default: true, render: (p) => p.brand?.name ?? '—' })
tables.products.updateColumn('price', { label: 'Retail price' })
tables.products.removeColumn('tags')
tables.orders.addColumn({ key: 'channel_id', label: 'Channel', displayable: false, filterable: true, filterType: 'enum',
filterOptions: [{ value: 'ch_web', label: 'Web' }] }) // filter-only entry
ColumnDef:key,label,render,className,default(visible by default),sortable,filterable,filterType(stringdefault |boolean|number|currency|date|enum+filterOptions|resource+filterResource { queryKey, search, hydrate, getOptionLabel }|tags+taggableType),ransackAttribute,displayable,expand,quickFilter.- Sorting/filtering run server-side through Ransack — the attribute must be in the model's
whitelisted_ransackable_attributes, or the request fails. - Mutations against a table not registered yet (built-ins register when their page first loads) are queued and replayed — safe even for optional features. Built-in table keys include
products,orders,customers,promotions,collections,stock-levels,gift-cards,store-credits,sellers,companies,returns,price-lists, … - Own list pages:
defineTable<T>('brands', { columns, searchParam, defaultSort, emptyMessage })+<ResourceTable tableKey queryKey queryFn searchParams />(pagination, filter panel, column picker, URL round-trip).bulkActions/rowActions/reorderare props on your own<ResourceTable>— you cannot inject them into built-in tables.
Form fields and custom-field widgets
- Custom field definitions (no code) already render in the Custom Fields card and save with the form. Swap one definition's widget:
customFieldComponents: { 'specs.color': ColorInput }(props:id,ariaLabel,value,onChange,definition). - Real column on a built-in form:
formFields: { product: [{ name: 'lead_time_days', from: (p) => p?.lead_time_days ?? null }] }+ a slot widget binding the input withuseHostForm()(form.register(...)or<Controller control={form.control}>). It hydrates, arms Save, ships in the page's PATCH, and shows server 422s inline. Host forms:product,category,collection,store. Never render a nested<form>; on order/customer pages useuseOptionalHostForm()and save yourself.
Worked code for both paths, plus page actions, a sidebar widget and a custom list page: references/recipes.md.
Permissions — UI gating is not authorization
import { Can, usePermissions } from '@spree/dashboard'
const { permissions, permissionKeys, rules, isLoading, refresh } = usePermissions()
permissions.can('update', 'Spree::Order') // class-level, mirrors server abilities
permissions.isConditional('update', 'Spree::Order') // true → record-level rules apply; expect a possible 403
permissionKeys.includes('write_orders') // flat catalog keys the role holds on this store
<Can I="destroy" a="Spree::Product" fallback={null}><DeleteButton /></Can>
subject(+action) on nav entries, settings entries and routes;ifon nav entries ({ permissions, store, user }); gate slot widgets inside the component.- Hiding a button is UX. The Admin API enforces per-controller
read_*/write_*permissions on every request — your custom endpoints must too (seespree-auth-permissions). - For a new resource, register a catalog scope on the backend so roles can be granted it and
permissions.can('read', 'MyApp::Report')resolves:
Abilities and# server/config/initializers/spree.rb Spree.permissions.register_scope(:reports, group: :analytics, resources: -> { [MyApp::Report] })permission_keysship withGET /api/v3/admin/me; permissions reload on store switch.
Talking to the backend
// src/hooks/use-brands.ts
import { adminClient, useResourceKey, useResourceMutation } from '@spree/dashboard'
import type { PaginatedResponse } from '@spree/admin-sdk'
import { useQuery } from '@tanstack/react-query'
export function useBrands(params?: Record<string, unknown>) {
return useQuery({
queryKey: useResourceKey('brands', params), // → ['brands', storeId, params]
queryFn: () => adminClient.request<PaginatedResponse<Brand>>('GET', '/brands', { params }),
})
}
export function useUpdateBrand(id: string) {
return useResourceMutation({
mutationFn: (body: Partial<Brand>) => adminClient.request<Brand>('PATCH', `/brands/${id}`, { body }),
invalidate: [['brands'], ['brands', id]], // store id injected for you
successMessage: 'Saved',
})
}
adminClientis the configured@spree/admin-sdksingleton: typed resources (adminClient.products.list/get/update…) andrequest<T>(method, path, { params, body })with paths relative to/api/v3/admin. Never create a second client or use rawfetch.- Every query key must be store-scoped:
useResourceKeyin hooks,useResourceKeyBuilder()when ids arrive later,withStoreScope(key, storeId)outside React. A bare['brands']serves one store's rows to another after a store switch. - Errors are
SpreeError(status,code,details).useResourceMutationtoasts non-422 failures and stays silent on 422 so the form can show them:catch (e) { if (!mapSpreeErrorsToForm(e, form.setError)) throw e }(:base→errors.root). On pages without a form (a header action, a dialog-less button), passshowValidationErrors: true— otherwise a server refusal (422 with the reason) shows the merchant nothing. - Filtering, sorting or aggregating across resources belongs in a backend endpoint, not N client requests.
- IDs are prefixed strings (
prod_…,or_…,cust_…) everywhere — never parse or strip them.
End to end: a new product attribute (form + column + filter + sort)
Prefer a custom field definition unless you need Ransack filter/sort or DB guarantees. For a real column:
# 1. server/db/migrate/…_add_lead_time_days_to_spree_products.rb
class AddLeadTimeDaysToSpreeProducts < ActiveRecord::Migration[8.1]
def change
add_column :spree_products, :lead_time_days, :integer
add_index :spree_products, :lead_time_days
end
end
# 2. server/config/initializers/spree.rb — writable, filterable/sortable
Spree::Product.additional_permitted_attributes += [:lead_time_days] # += (never << — it's frozen; never = — drops other extensions')
Spree::Product.whitelisted_ransackable_attributes |= %w[lead_time_days]
Spree.api.admin_product_serializer = 'MyApp::AdminProductSerializer'
# 3. server/app/serializers/my_app/admin_product_serializer.rb — readable
module MyApp
class AdminProductSerializer < Spree::Api::V3::Admin::ProductSerializer
typelize lead_time_days: [:number, nullable: true]
attributes :lead_time_days
end
end
# + a model validation — the dashboard renders its 422 message inline
// 4. apps/dashboard/src/plugins.ts
defineDashboardPlugin({
formFields: { product: [{ name: 'lead_time_days', from: (p) => p?.lead_time_days ?? null }] },
slots: { 'product.form_sidebar': [{ id: 'lead-time', component: LeadTimeCard as never, position: 60 }] },
tables: { products: { add: [{
key: 'lead_time_days', label: i18n.t('admin.fields.product.lead_time_days.label'),
sortable: true, filterable: true, filterType: 'number', default: true,
render: (p) => (p.lead_time_days != null ? `${p.lead_time_days} d` : '—'),
}] } },
})
LeadTimeCard uses useHostForm<{ lead_time_days: number | null }>() and form.register('lead_time_days', { setValueAs: (v) => (v === '' || v == null ? null : Number(v)) }). The form name, serializer attribute and permitted attribute must be the same string. Verify the backend first: spree api patch products/prod_… --data '{"lead_time_days":14}' and spree api get "products?q[lead_time_days_gt]=7&sort=-lead_time_days". Custom Admin controllers use resource_permitted_attributes instead (see spree-resource, spree-decorators).
Theming
Everything is CSS custom properties. Override below the import in src/styles.css, and set both :root and .dark (the theme provider only toggles the .dark class):
@import "@spree/dashboard/styles.css";
:root { --primary: oklch(0.55 0.22 264); --radius: 0.25rem; }
.dark { --primary: oklch(0.72 0.19 264); }
@theme inline { --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; --text-base: 0.9375rem; }
Tokens: --background/--foreground, --card*, --popover*, --muted*, --primary* (aliases --foreground by default), --secondary*, --accent* (+ -hover, -strong), --link, --ring, --destructive*, --status-{green,amber,red,blue}-{bg,border,fg}, --border*, --input, --radius, --sidebar*, --chart-1..5. In your pages use Tailwind utilities (bg-card text-muted-foreground) rather than raw var(--…), and className="link" for prose links. The seller panel (@spree/seller-dashboard/styles.css) uses the same tokens.
Translations
i18next, single translation namespace, every key under admin.. Register with i18n.addResourceBundle(locale, 'translation', { admin: { … } }, true, true) at the top of plugins.ts (or defineDashboardPlugin({ locales: { en, de } })), use useTranslation() in components and labelKey in registries. Field keys: admin.fields.<resource>.<attribute>.label|placeholder, falling back to admin.fields.<attribute>.*. Built-in locales: en, de, es, fr, pl, ar, zh-CN. A gem that registers a promotion rule/action, calculator, price rule, collection rule, order routing rule, delivery method rule, commission rule, seller requirement, integration or permission should ship admin.types.<family>.<code>.{name,description} — otherwise the API's label (resolved in the store's locale, not the admin's) is shown. Server 422 messages are displayed verbatim — localize them with Rails i18n.
Testing
cd apps/dashboard && pnpm test (Vitest, Node env, src/**/*.test.ts) and pnpm test:e2e (Playwright against spree dev + the dev server; E2E_BASE_URL to target a deployment).
- Unit-test the logic between UI and API — query keys, payload mappers, permission predicates, filter → Ransack mapping. Don't render components to assert markup; don't test one-line
adminClient.requestwrappers.import { withStoreScope } from '@spree/dashboard' import { QueryClient } from '@tanstack/react-query' it('invalidates only the current store', () => { const qc = new QueryClient() qc.setQueryData(withStoreScope(['brands'], 'store_a'), []) qc.setQueryData(withStoreScope(['brands'], 'store_b'), []) qc.invalidateQueries({ queryKey: withStoreScope(['brands'], 'store_a') }) expect(qc.getQueryCache().getAll().filter((q) => q.state.isInvalidated)).toHaveLength(1) }) - E2E: drive and assert on the UI (click the sidebar link rather than
goto('/brands')— real paths are/:storeId/…), suffix record names withDate.now()(specs share a DB; the config runs one worker). - Checks before pushing:
pnpm typecheck && pnpm lint && pnpm test && pnpm build.
Deployment (pointer — see spree-deployment)
- Single node (default): the
spree_dashboardgem serves a built dashboard at/dashboardfromSpree::Dashboard.dist_path/SPREE_DASHBOARD_DIST_PATH(seller panel at/sellers). The official image ships the stock build; bake your customized build withspree build --production [--tag …](ordocker build . -f server/Dockerfile) — it detectsapps/dashboard/and builds withVITE_BASE_PATH=/dashboard/and a relative API URL. - Static host / CDN:
VITE_SPREE_API_URL=https://api.example.com pnpm build, deploydist/with a/* → /index.htmlrewrite, HTTPS on both sides (refresh cookie isSameSite=None; Secure), and add the origin under Settings → Developer → Allowed Origins. One build per environment. VITE_*values are compiled into the bundle — never put secrets there.
Coming from spree_admin (Spree 5.x)
spree_admin | Dashboard |
|---|---|
Spree.admin.navigation.sidebar.add/remove/update | nav.add/remove/update, nav.addChild; settingsNav for settings |
spree:admin:scaffold + ERB CRUD views | defineTable + <ResourceTable> page + routes entry (API from spree:api_resource) |
Spree.admin.tables.<t>.add/remove/update | tables.<t>.addColumn/removeColumn/updateColumn |
Spree.admin.partials.<point> << 'partial' | slots: { '<slot>': [...] } |
Adding a field to _form.html.erb / product_form partial | formFields + slot widget with useHostForm(), or a custom field definition |
Admin controller decorators / permitted_attributes | Admin API: Model.additional_permitted_attributes +=, serializer via Spree.api.admin_*_serializer |
CanCanCan can? in views | usePermissions() / <Can> / subject (UX only) + backend permission keys |
| Stimulus controllers, importmap | React components + TanStack Query hooks |
| Overriding admin Tailwind/CSS | CSS token overrides in src/styles.css |
Spree.t admin strings | i18next admin.* bundles |
For the full 5.x → 6 migration, see spree-upgrade-5-to-6.
Gotchas
- Duplicate key/id throws at boot — namespace keys (
acme-reports), and remember built-in keys (orders,products, …). - Unscoped query keys leak data across stores. Always
useResourceKey. - Slot widgets must gate themselves —
page.actionsrenders on every page; permissions aren't passed as props. nav.update(parent, { children })drops built-in children — useaddChild.- Sortable/filterable column without a Ransack allowlist entry fails at request time, not at build time.
- Registry route shadowed by a file route at the same URL never renders — no error.
- Frozen labels:
label: i18n.t(...)doesn't follow language switches; uselabelKey. - Nested
<form>inside a host-form slot breaks the page's Save. UseuseHostForm(). - Never embed an SDK entity type in form-values types — RHF walks nested keys and the TS compiler blows up; map to a flat values type.
- Don't loosen the dashboard's dependency pins.
@spree/dashboard-uicompiles into your app, so its dependencies are pinned exactly (@base-ui/react1.8.0,recharts3.10.1, …) to what Spree tested. A newer Base UI floating in (via an override, a caret range, or dashboard packages older than 1.0.0-beta.7) fails before the first render with aSyntaxErroraboutuseSyncExternalStore. Upgrade the@spree/dashboard*packages together rather than bumping Base UI yourself. - Don't reach into Rails. A missing endpoint is added to the Admin API first (
spree-resource,spree-api-v3).
Where to read further
- Docs (installed):
node_modules/@spree/docs/dist/developer/dashboard/—overview.md,concepts.md,customization/{quickstart,navigation,routes,slots,tables,permissions,backend,theming,translations}.md,recipes/*.md,slots-catalog.md,public-api.md,deployment.md; tutorialdeveloper/tutorial/dashboard.mdanddeveloper/tutorial/testing.md. Online: https://spreecommerce.org/docs/developer/dashboard/overview - Your app's
apps/dashboard/AGENTS.md(conventions) andREADME.md. - Source:
packages/dashboard-core/src/{plugin.ts,lib/*-registry.ts},packages/dashboard/src/nav/default.ts(built-in nav),packages/dashboard-plugin-example(complete Brands example) in https://github.com/spree/spree - Related skills:
spree-dashboard-plugins,spree-auth-permissions,spree-api-v3,spree-typescript-sdk,spree-resource,spree-catalog(custom fields),spree-deployment.