docs/plans/6.0-admin-spa.md
Status: In Progress
Target: Spree 6.0
Depends on: 6.0-admin-api.md, 6.0-platform-auth.md
Author: damian
Last updated: 2026-04-30
A new single-page React admin dashboard that replaces the Rails spree/admin engine entirely. Client-side only (Vite SPA), built on the new Admin API via @spree/admin-sdk, deployed either as static assets bundled into the Spree gem or as a standalone hosted app. Mirrors all admin functionality from the Rails admin while giving plugin authors a modern extension model (table registry, navigation registry, component injection).
spree/admin is removed./$storeId/ for all authenticated routes (multi-store support). / redirects to the current/default store.5.5-admin-auth-cookie-refresh.md). 401 → auto-refresh via SDK onUnauthorized hook → retry.GET /api/v3/admin/me returns the CanCanCan rules serialized; SPA mirrors them via <Can I="..." a="..." /> and the usePermissions() hook. Server still enforces authorize! — SPA is UX only.render_admin_partials slots so plugin authors carry their mental model across.AuthProvider + PermissionProvider handle session. Add Zustand only if we hit a legitimate client-state problem.<BackButton> (in <PageHeader>), the page title itself, and the highlighted sidebar section. Do not reintroduce a breadcrumb component.6.0-fulfillment-and-delivery.md.<Field> / <Input> / <Controller> / <FieldError>) — no field/form wrapper. A <SpreeForm> + <SpreeField> abstraction was tried and rolled back (rationale in "Forms & i18n"). The compensating utilities — mapSpreeErrorsToForm for 422 → RHF translation, and i18n keys under admin.fields.<resource>.<attribute> — remain as small primitives that compose with raw RHF.useTranslation(). Translation keys mirror Rails I18n: admin.fields.<resource>.<attribute>.<facet> with fallback to admin.fields.<attribute>.<facet>. English-only at launch (packages/dashboard/src/locales/en.json); the infrastructure supports more locales but actually translating the admin UI is a separate, opt-in project. Bundle cost: ~14 KB gzipped.packages/dashboard/ (the app), packages/dashboard-core/ (framework), packages/dashboard-ui/ (design system). See "Package Split" below.@spree/admin-sdk (workspace), @spree/sdk-core (workspace)dist/ (static SPA). Optionally bundled into spree_admin gem at build time for zero-config local dev.pnpm dev → vite → http://localhost:5173, proxying /api/* to http://localhost:3000The dashboard is split into three workspace packages with a strict one-way dependency direction. The split lets plugin authors compose new pages from the same primitives core uses, and lets app developers build bespoke admin variants (vendor panels, white-labels) without forking the SPA.
@spree/dashboard (the deployable SPA — routes, resource hooks, Zod schemas, locales, app shell)
↓ depends on
@spree/dashboard-core (the framework — registries, providers, infra hooks, client, defineDashboardPlugin)
↓ depends on (peer)
@spree/dashboard-ui (the design system — primitives + headless compounds + tokens)
We looked at how Medusa, Vendure, and Saleor structure their admin packages:
@medusajs/dashboard + @medusajs/admin-sdk + @medusajs/ui + @medusajs/admin-vite-plugin): three small packages plus a Vite plugin. Discovery via file convention + AST scanning. Plugin authors only ever depend on the thin SDK + UI primitives. Feature pages are private.@vendure/dashboard): one big package with three subpath exports (., ./plugin, ./vite). Discovery introspects vendure-config.ts at build time. Plugin authors call defineDashboardExtension({...}). Feature pages are private; only the framework/ building blocks are exported.saleor-dashboard + @saleor/macaw-ui): the dashboard is not even published; it's a Vite app you fork. Extensions are external services iframed in via App Manifest mount points. There is no in-process plugin model.Industry pattern (3/3): UI primitives package + extension SDK package + dashboard app. Feature pages are never exported. Reasons:
orders/ becomes a semver event.Our split matches the industry pattern but goes one step further on UI reuse: instead of bundling primitives into the dashboard app (Vendure) or shipping primitives without compounds (Medusa), @spree/dashboard-ui carries both — the primitives and the composed components (PageHeader, ResourceTable, AppSidebar, …) — as headless components that accept data via props. This is the gap none of them got right: plugin authors and custom-admin builders can use the same composed components core uses.
We are not extracting per-feature packages (@spree/dashboard-orders, …). The competitor evidence is unanimous that this is the wrong cut, and the vendor-panel use case is better solved by forking the app shell with reuse of dashboard-ui/dashboard-core than by feature imports. Revisit only if a real consumer hits the boundary.
The three rules below decide which package each file belongs to. Apply mechanically.
@spree/dashboard-ui — design system. Source-only (no build).
@spree/dashboard.src/ui/ (shadcn primitives — Button, Input, Dialog, Sheet, Table, Calendar, …), src/spree/ (Spree composed components — PageHeader, ResourceTable, AppSidebar, BulkActionBar, TopBar, …), src/styles.css (design tokens + Tailwind theme), src/lib/ (cn, formatters, form-errors, validation-messages).react, react-dom.@spree/dashboard-core — framework. The extension API surface.
table-registry, nav-registry, slot-registry, settings-nav-registry), providers (AuthProvider, PermissionProvider, StoreProvider, ThemeProvider), generic infra hooks (use-auth, use-permissions, use-store, use-theme, use-resource-mutation, use-direct-upload, use-global-search, use-command-palette, use-copy-to-clipboard, use-scrolled, plus the cross-cutting infra hooks like use-countries, use-export, use-custom-fields), the admin SDK client singleton (client.ts), generic lib (filters-to-ransack, permissions, formatters), and the defineDashboardPlugin facade (/plugin subpath).react, react-dom, @spree/dashboard-ui. Critical: registries are module singletons. If a plugin bundles its own copy of @spree/dashboard-core, registerSlot writes to a different Map than <Slot> reads. Keep @spree/dashboard-core (and react, react-dom, @spree/dashboard-ui) as peer deps in every plugin package; pnpm dedupes to a single instance.@spree/dashboard — the deployable app.
src/routes/**), resource hooks (use-orders, use-products, use-customers, use-promotions, use-payment-methods, …), Zod schemas (src/schemas/), table definitions (src/tables/), navigation config (src/nav/), locale strings (src/locales/en.json), i18n.ts bootstrap (it owns the locales), App.tsx, main.tsx, index.html, vite.config.ts.bulk-price-editor/, promotion-editors/, price-list-editors/, products/, payment-method-editors/, custom-fields/, command-palette/, nav-main) stay here until they're refactored to headless and moved to dashboard-ui (see Phase 3 of "Migration Path" below).@spree/admin-sdk, @spree/dashboard-core, @spree/dashboard-ui.import.meta.env in dashboard-coreThe admin SDK client (client.ts) lives in @spree/dashboard-core and reads import.meta.env.VITE_SPREE_API_URL. This makes @spree/dashboard-core Vite-specific — acceptable because the dashboard SPA is Vite and plugin authors building dashboard plugins also use Vite. We considered provider-injecting the client via useAdminClient() for non-Vite consumers but rejected it: every hook would need refactoring, no real consumer exists for the abstraction, and we can switch to provider injection later without breaking plugin authors (the hook surface stays the same).
import { defineDashboardPlugin } from '@spree/dashboard-core/plugin'
import { Card, CardContent, CardHeader, CardTitle, Button } from '@spree/dashboard-ui'
defineDashboardPlugin({
nav: [{ key: 'reports', label: 'Reports', url: '/reports', position: 50 }],
slots: {
'product.form_sidebar': [{ id: 'my-widget', component: MyWidget, position: 50 }],
},
tables: [
{ table: 'products', addColumn: { id: 'wishlist_count', header: 'Wishlists', accessorFn: (p) => p.wishlist_count } },
],
})
Custom admin apps (vendor panels, etc.) compose the packages directly: import @spree/dashboard-ui for the components, mount @spree/dashboard-core's providers in their own app shell, and ship only the routes they want. No SDK abstraction to fight.
The headless rule (dashboard-ui components accept everything via props) is the operationally-hardest part of the split. The inventory of packages/dashboard/src/components/spree/ (54 files) breaks into three classes:
| Class | Description | Examples | Target package |
|---|---|---|---|
| A — generic, zero coupling | Components that already only import cn, primitives, and pure helpers | relative-time, back-button, drag-handle, country-flag, secret-input, data-grid/, metadata/, resource-combobox, resource-name-cell, resource-multi-autocomplete, route-error-boundary, tag-list, row-actions, row-click-bridge, bulk-dialog, calculator-summary, form-actions, storefront-visible-switch, color-picker, resource-layout | dashboard-ui (move as-is) |
| B — provider-coupled but data-agnostic | Components that import usePermissions, useStore, useTheme, useAuth, useCopyToClipboard, useScrolled, useIsMobile, or i18n hooks. Pure infra deps, no resource data. | page-header, resource-table, app-sidebar, settings-sidebar, top-bar, bulk-action-bar, can, currency-select, locale-select, store-date-picker, store-switcher, theme-toggle, table-toolbar, json-preview-drawer, confirm-dialog, preferences-form, page-tabs, address-form-dialog, ui/sidebar, ui/sonner | dashboard-ui (refactor to take provider data via props/render-props) |
| C — resource-data-coupled | Components that import resource-specific hooks or schemas | country-combobox (uses useCountries), country-state-fields, tag-combobox, export-button (uses useExport), nav-main, command-palette/, custom-fields/, bulk-price-editor/, payment-method-editors/, price-list-editors/, products/, promotion-editors/ | Component → dashboard-ui (refactor to take data via props). Hook → dashboard-core if it's infra (countries, export, custom fields, search, command palette) or dashboard if it's resource-specific (prices, products, promotions). |
The headless refactor is per-component: change the component to accept data via props, update every callsite in packages/dashboard/src/routes/** to pass those props, then move the file to dashboard-ui. Each refactor is a small PR; see Phase 3 in "Migration Path" below.
The split lands in four phases, each independently mergeable and each leaving the repo in a buildable state:
packages/admin → packages/dashboard and @spree/admin → @spree/dashboard. Create empty @spree/dashboard-ui and @spree/dashboard-core skeleton packages (package.json, tsconfig, biome.json, README, placeholder src/index.ts). Update CLAUDE.md, docs, and this plan. No semantic change to the app; just the foundation.@spree/dashboard-ui with Class A compounds + primitives + tokens. Rewrite @/components/ui/* and Class A @/components/spree/* imports in @spree/dashboard to point at @spree/dashboard-ui. Plugin authors can now import { Button, Card, ... } from '@spree/dashboard-ui'.@spree/dashboard-core with registries + providers + generic infra hooks + admin SDK client + defineDashboardPlugin. Move Class B compounds to @spree/dashboard (still importing providers, but now from @spree/dashboard-core); refactor + move to dashboard-ui happens in Phase 3. Plugin authors can now import { registerSlot, defineDashboardPlugin, useAuth, ... } from '@spree/dashboard-core'.dashboard-ui. Start with the most-reused: <PageHeader>, <ResourceTable>, <AppSidebar>.useCountries, useExport, useCustomFields, useGlobalSearch, useCommandPalette are domain-flavored but cross-cutting — every plugin will need them. They move to dashboard-core in Phase 2. Strictly resource-specific hooks (useOrders, useProducts, useCustomers, usePromotions, usePaymentMethods, etc.) stay in @spree/dashboard.<Slot>'s ambient context (permissions, store, user) is a TODO. Wire it up via providers from dashboard-core during Phase 2.i18n.ts initializes i18next with en.json. It stays in @spree/dashboard (which owns the locales). Components in dashboard-ui/dashboard-core that need translations call useTranslation() from react-i18next directly — the consuming app's i18n.ts side-effect import has already registered the resources.| Layer | Choice | Why |
|---|---|---|
| Build & dev | Vite | Fast HMR, minimal config, outputs static files |
| Routing | TanStack Router | Type-safe routes, file-based convention, loader + beforeLoad hooks integrate with TanStack Query |
| Data fetching | TanStack Query | Caching, background refetches, optimistic updates. @spree/admin-sdk calls wrapped in custom hooks (useProducts, useOrder, ...) |
| Forms | React Hook Form + Zod | Schemas mirror API contracts; Zod schemas can be derived from Typelizer-generated types |
| UI | shadcn/ui + Tailwind | Copy-paste ownership so extensions can override without fighting component APIs |
| Icons | lucide-react | |
| Auth | Custom AuthProvider | Access token in memory; refresh token in httpOnly cookie (5.5-admin-auth-cookie-refresh.md); 58-minute refresh timer; 401 auto-retry via SDK onUnauthorized |
| Permissions | Custom PermissionProvider | Loads /api/v3/admin/me on login, exposes usePermissions() + <Can> mirroring CanCanCan |
| State (session) | AuthProvider + PermissionProvider | Small context, no global store |
| Linting/formatting | Biome | Single tool, fast |
| Testing | Vitest + React Testing Library (unit/integration), Playwright (E2E) | |
| Fonts | Geist + Geist Mono | Same as old spree/admin |
Login → POST /auth/login → { token, user } (refresh token set as httpOnly cookie)
↓
Store access token in adminClient + AuthProvider state (in memory only)
↓
PermissionProvider fetches /me → { user, permissions: [...] }
↓
Router beforeLoad guards check `context.auth.isAuthenticated`
↓
Every 58 min: refresh → new access token; server rotates the cookie
↓
On 401 from any API call: SDK onUnauthorized → refresh → retry original request
Concurrent refresh calls are serialized via a shared promise to prevent double-rotation. Cookie migration plan: 5.5-admin-auth-cookie-refresh.md.
src/routes/
__root.tsx # router context types
login.tsx # public login page
_authenticated.tsx # auth guard, sidebar layout
index.tsx # redirects to /$defaultStoreId
$storeId.tsx # StoreProvider + setStore on adminClient
index.tsx # dashboard
orders/
index.tsx
$orderId.tsx
drafts.tsx
products/
index.tsx
$productId.tsx
customers/
...
/$storeId/*. No duplication of routes across storefront-prefix and non-prefixed variants (we had that briefly and removed it)./$storeId/{resource}/, detail pages at /$storeId/{resource}/$id.storeId and filtered by permissions.<AuthProvider>
<PermissionProvider>
<TooltipProvider>
<RouterProvider router={router} context={{ auth, permissions }} />
</TooltipProvider>
</PermissionProvider>
</AuthProvider>
Router context carries auth and permissions so beforeLoad guards can do:
beforeLoad: ({ context }) => {
if (context.permissions.cannot('manage', 'Spree::User')) {
throw redirect({ to: '/$storeId' })
}
}
The admin uses a declarative table registry to define resource list pages. Mirrors the Rails admin's Spree.admin.tables DSL.
A list page needs two things:
src/tables/orders.tsx)src/routes/_authenticated/$storeId/orders/index.tsx) that renders <ResourceTable>// src/tables/orders.tsx
import { defineTable } from '@/lib/table-registry'
import { StatusBadge } from '@/components/ui/badge'
import { formatRelativeTime } from '@/lib/formatters'
defineTable('orders', {
title: 'Orders',
searchParam: 'multi_search',
searchPlaceholder: 'Search orders...',
defaultSort: { field: 'completed_at', direction: 'desc' },
emptyMessage: 'No orders found',
columns: [
{ key: 'number', label: 'Number', sortable: true, filterable: true, default: true,
render: (o) => <Link to="/$storeId/orders/$orderId" params={{ orderId: o.id }}>#{o.number}</Link> },
{ key: 'fulfillment_status', label: 'Fulfillment', sortable: true, filterable: true, default: true,
filterType: 'status',
filterOptions: [
{ value: 'unfulfilled', label: 'Unfulfilled' },
{ value: 'fulfilled', label: 'Fulfilled' },
{ value: 'canceled', label: 'Canceled' },
],
render: (o) => o.fulfillment_status ? <StatusBadge status={o.fulfillment_status} /> : '—' },
{ key: 'payment_status', label: 'Payment', sortable: true, filterable: true, default: true,
filterType: 'status',
filterOptions: [
{ value: 'paid', label: 'Paid' },
{ value: 'balance_due', label: 'Balance due' },
{ value: 'refunded', label: 'Refunded' },
],
render: (o) => o.payment_status ? <StatusBadge status={o.payment_status} /> : '—' },
{ key: 'display_total', label: 'Total', sortable: true, default: true,
className: 'text-right tabular-nums',
render: (o) => o.display_total },
{ key: 'completed_at', label: 'Date', sortable: true, default: true, filterType: 'date',
render: (o) => o.completed_at ? formatRelativeTime(o.completed_at) : '—' },
],
})
// src/routes/_authenticated/$storeId/orders/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { ResourceTable, resourceSearchSchema } from '@/components/resource-table'
import { adminClient } from '@/client'
import '@/tables/orders'
export const Route = createFileRoute('/_authenticated/$storeId/orders/')({
validateSearch: resourceSearchSchema,
component: OrdersPage,
})
function OrdersPage() {
const searchParams = Route.useSearch()
return (
<ResourceTable
tableKey="orders"
queryKey="orders"
queryFn={(params) => adminClient.orders.list(params)}
searchParams={searchParams}
defaultParams={{ complete: 1 }}
/>
)
}
<ResourceTable> handles toolbar (search, filters, sort, column selector), data fetching, pagination, loading states, and empty states. URL state is persisted via TanStack Router search params (page, sort, dir, search, filters, columns).
defineTable(key, options) reference| Option | Type | Description |
|---|---|---|
title | string | Toolbar title |
columns | ColumnDef[] | Column definitions |
searchParam | string | Ransack search param name (default name_cont) |
searchPlaceholder | string | |
defaultSort | { field, direction } | Default { updated_at, desc } |
emptyIcon, emptyMessage | ReactNode, string |
ColumnDef reference| Property | Default | Description |
|---|---|---|
key | required | Unique identifier, used as Ransack attribute unless ransackAttribute is set |
label | required | Header + toolbar display |
sortable | false | |
filterable | false | Shows in filter drawer |
default | false | Visible by default |
filterType | 'string' | 'string' | 'status' | 'boolean' | 'number' | 'date' |
filterOptions | — | For status filter |
ransackAttribute | — | Override when Ransack attribute ≠ column key |
render | — | Custom cell renderer |
className | — | CSS class on <td> |
displayable | true | false for filter-only columns |
| Type | Operators |
|---|---|
string | contains, equals, does not equal, starts with, ends with, is set, is not set |
status | is, is not, is any of, is none of |
boolean | is |
number | eq, gt, gte, lt, lte |
date | is, after, before, on or after, on or before |
Three extension points only (do not add more without discussion):
Spree.admin.navigation.sidebar DSL (spree/admin/config/initializers/spree_admin_navigation.rb).tables.products.addColumn({
key: 'vendor',
label: 'Vendor',
sortable: true,
filterable: true,
default: true,
render: (p) => p.vendor_name ?? '—',
})
tables.products.removeColumn('inventory')
tables.products.updateColumn('sku', {
default: true,
label: 'Product SKU',
})
render_admin_partials(:slot_name, ...) mechanism (defined as the Environment Struct in spree/admin/lib/spree/admin/engine.rb). See Slot API below.The legacy Rails admin exposes ~80 named partial slots via Spree.admin.partials.<slot_name> << 'path/to/partial', rendered with render_admin_partials(:slot_name, locals: { ... }). The React equivalent uses the same slot names and roughly the same locals so plugin authors can carry their mental model across.
// Plugin code (registered at app start)
import { registerSlot } from '@spree/dashboard-core'
registerSlot('product_form_sidebar', {
id: 'vendor-info',
component: VendorInfoCard,
position: 100,
if: ({ permissions }) => permissions.can('read', 'Spree::Vendor'),
})
// Spree admin page (built-in)
<Slot name="product_form_sidebar" context={{ form, product }} />
<Slot> looks up all registered entries for that name, filters by if, sorts by position, and renders each component with the supplied context as props. Slots compose multiple registrations — same as Rails appending to a partials array.
registerSlot(name, entry) reference| Field | Type | Description |
|---|---|---|
id | string (required) | Unique within the slot — required so plugins can removeSlot(name, id) or updateSlot(name, id, …). |
component | ComponentType<SlotContext> | Receives the slot's context as props. |
position | number (default 100) | Sort order. Built-in entries use 100/200/300 to leave room. |
if | (ctx) => boolean | Optional gate; receives the slot context plus { permissions, store, user }. |
removeSlot(name, id) and updateSlot(name, id, patch) mirror the table registry methods.
Slot naming convention: drop the _partials suffix from the Rails struct member, keep the rest. Built-in slots, organized by location:
Layout-wide:
head — <head> injection (rare; analytics, custom fonts)body_start, body_end — adjacent to <App> root (banners, modals, devtools)Page chrome (replaces content_for seams):
page.title, page.subtitle, page.actions, page.actions_dropdown, page.alerts, page.tabs — consumed by <PageHeader> / <PageTabs>. Context: { resource, route }.Dashboard:
dashboard.sidebar, dashboard.analyticsProduct page (/$storeId/products/$productId):
product.form — main form body, after Variants card. Context: { form, product }.product.form_sidebar — right column. Context: { form, product }.product.dropdown — extra items in the more-actions dropdown.product.page_title — adjacent to the title (badges, indicators).Order page (/$storeId/orders/$orderId):
order.page_header — above the header row.order.page_body — main content area (extra cards).order.page_sidebar — right column.order.summary — inside the totals card.order.dropdown — more-actions dropdown items.{ order }.Customer page (/$storeId/customers/$customerId):
customer.page_body, customer.page_sidebar, customer.dropdown. Context: { customer }.Index pages — every resource gets a triplet:
<resource>.actions — top-right action buttons next to "Add"/"New".<resource>.header — banner/alert area above the table.<resource>.filters — extra filter controls.Resources covered (matches the Rails struct): admin_users, coupon_codes, customer_returns, customers, digital_assets, exports, gift_cards, integrations, invitations, oauth_applications, option_types, orders, pages, payment_methods, posts, price_lists, products, promotions, properties, refund_reasons, reimbursement_types, reports, return_authorization_reasons, roles, shipping_categories, shipping_methods, stock_items, stock_locations, stock_movements, stock_transfers, store_credit_categories, store_credits, tax_categories, tax_rates, users, zones.
Form pages (settings):
store.form — store edit form sections.shipping_method.form — form body for shipping methods.Payment method editors (per-provider):
Mirror the promotion.rule_form.<type> / promotion.action_form.<type> pattern — slot keys end in the provider's STI shorthand (bogus, stripe, …):
payment_method.guide.<type> — banner above the form (install instructions, "Connect via OAuth" CTAs, docs links).payment_method.form.<type> — replaces the generic <PreferencesForm> for that provider. Use when a gateway needs more than a key/value form (Stripe OAuth Connect, PayPal Onboarding, Adyen API-key wizard).payment_method.actions.<type> — footer actions (Test webhook, View webhook URL, Disconnect).All three receive PaymentMethodEditorContext from packages/dashboard/src/components/spree/payment-method-editors/types.ts: { mode: 'create' | 'edit', type, paymentMethod, preferenceSchema, preferences, onPreferencesChange, form }. Spree core registers nothing by default — <PreferencesForm> is the generic fallback for payment_method.form.
Sub-nav (was content_for(:page_tabs) plus _*_nav.html.erb):
nav.tax, nav.shipping, nav.stock, nav.returns_and_refunds, nav.team — consumed by <PageTabs> on settings pages where these existed in Rails.Slots are not free — every new slot is an API surface the SPA owes back-compat to. Rule: add a slot only when at least one real plugin needs it. To add: declare the name in src/lib/slot-registry.ts, drop a <Slot> in the page, document it here.
Render props give plugins more power but worse UX: they must understand the host component, the surrounding layout breaks when two plugins both want to wrap the same node, and merge conflicts on upgrades become routine. Named slots are append-only and composable, which matches how the Rails admin worked and keeps the upgrade path predictable.
| Rails admin | React admin |
|---|---|
Spree.admin.tables.register(:products, ...) | defineTable('products', { ... }) |
Spree.admin.tables.products.add :name, ... | Column in columns array |
type: :custom, partial: '...' | render: (row) => <Component /> |
Spree.admin.tables.products.add :col, ... (in initializer) | tables.products.addColumn({ ... }) |
Spree.admin.tables.products.remove :col | tables.products.removeColumn('col') |
render_table @collection, :products | <ResourceTable tableKey="products" ... /> |
ransack_attribute: 'master_sku' | ransackAttribute: 'master_sku' |
filter_type: :status, value_options: [...] | filterType: 'status', filterOptions: [...] |
displayable: false | displayable: false |
Spree.admin.partials.product_form << 'path' | registerSlot('product.form', { id, component }) |
render_admin_partials(:product_form, locals: { f:, product: }) | <Slot name="product.form" context={{ form, product }} /> |
content_for :page_actions { ... } | <Slot name="page.actions" context={{ resource }} /> |
Spree.admin.navigation.sidebar.add :menu, ... | nav.add({ key, label, url, icon, position, if }) |
Admin SPA forms are raw React Hook Form blocks — <Field> / <Input> / <Controller> / <FieldError> written out per attribute — combined with two small utilities:
mapSpreeErrorsToForm(error, setError) translates 422 SpreeError responses into form.setError(...) calls (flat attribute keys onto fields, :base and nested keys onto formState.errors.root).useTranslation() + admin.fields.<resource>.<attribute>.{label,placeholder,help} keys in packages/dashboard/src/locales/en.json keep labels translatable.<SpreeField> / <SpreeForm> wrapper?A short-lived branch shipped a Rails-FormBuilder-equivalent (<SpreeField attribute="name" /> collapsing label + input + error + i18n into one line; <SpreeForm resource="…"> context provider for the form + resource scope). It got reverted before merging. The win was ~5-10× LOC reduction per field; the cost was readability — every field's behavior (which input element, where the label came from, how errors rendered, how server validation got wired) became implicit, and stacking the layers (form context, key resolution with fallback, as= dispatch, error wiring) made forms harder to debug than the verbose original.
The Rails FormBuilder analogy was misleading: FormBuilder works because the model + view + controller share one naming convention and the framework owns all four sides. In a React SPA those layers are decoupled — RHF owns form state, Zod owns validation, the SDK owns API params, react-i18next owns labels — and bridging them through a single component pushed too much logic into implicit defaults.
What survives the rollback: the i18n keys, mapSpreeErrorsToForm, and useResourceMutation's 422-toast suppression all stay as small primitives that compose with raw RHF.
// packages/dashboard/src/locales/en.json
{
"admin": {
"fields": {
"name": { "label": "Name" },
"description": { "label": "Description" },
"code": { "label": "Code", "placeholder": "SUMMER2026" },
"starts_at": { "label": "Starts" },
"expires_at": { "label": "Expires" },
"usage_limit": { "label": "Usage limit", "placeholder": "Unlimited" },
// Per-resource overrides — picked when the consumer passes the resource key explicitly.
"promotion": { "name": { "label": "Name", "placeholder": "Summer Sale" } },
"option_type": { "name": { "label": "Internal name", "help": "Lowercase identifier. Must be unique across the store." },
"label": { "label": "Label", "help": "Shown to customers on the storefront." } },
"stock_location": { "name": { "label": "Name", "placeholder": "Brooklyn warehouse" },
"admin_name": { "label": "Internal name", "placeholder": "Optional — shown only in the admin" } }
},
"actions": { "create": "Create", "save": "Save", "saving": "Saving…", "cancel": "Cancel", "delete": "Delete" }
}
}
Resolution is per-call: callers reach for the per-resource key first (t('admin.fields.promotion.name.label')), or the cross-resource default when there's no override (t('admin.fields.name.label')). Facets are label | placeholder | help. Resource names use the snake-case singular API model name (promotion, option_type, stock_location, payment_method) so the SPA mirrors the serializer + Rails model layout.
function PromotionForm({ form, onSubmit }: Props) {
const { t } = useTranslation()
const { errors } = form.formState
async function handleSubmit(values: PromotionFormValues) {
try {
await onSubmit(values)
} catch (err) {
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
}
}
return (
<form onSubmit={form.handleSubmit(handleSubmit)}>
{errors.root?.message && (
<p className="text-sm text-destructive" role="alert">{errors.root.message}</p>
)}
<FieldGroup>
<Field>
<FieldLabel htmlFor="promo-name">{t('admin.fields.name.label')}</FieldLabel>
<Input
id="promo-name"
placeholder={t('admin.fields.promotion.name.placeholder')}
aria-invalid={!!errors.name || undefined}
{...form.register('name')}
/>
<FieldError errors={[errors.name]} />
</Field>
</FieldGroup>
</form>
)
}
Custom widgets (CurrencySelect, CountryCombobox, ResourceCombobox, TagCombobox, RichTextEditor, StoreDatePicker, ColorPicker, dnd-kit field-arrays) use inline <Controller> blocks. Validation lives in the Zod schema (zodResolver); per-field rules belong on z.object({ … }), not on the input.
// packages/dashboard/src/lib/form-errors.ts
export function mapSpreeErrorsToForm<T>(error: unknown, setError: UseFormSetError<T>): boolean
Walks SpreeError.details ({ field: string[] }), calls setError(field, { type: 'server', message }) for flat keys, and seeds formState.errors.root.message with the server's top-level summary so :base/nested errors still surface. Returns true when a SpreeError was rendered — callers re-throw anything else so the surrounding mutation hook can toast network/5xx errors.
The legacy Rails admin let plugins inject form fields into a host model's form via render_admin_partials(:product_form_partials, f:, product:) and friends. The SPA needs an equivalent — without it, the only way to add a single custom field to e.g. the Order form is to fork the route, which discards all upstream improvements forever.
The lightweight built-in mechanism (Spree::Metafields → <CustomFieldsCard>) covers ~80% of "add a field" use cases with zero code, but it renders fields in a dedicated card at the bottom of the page — not inline with the form's other fields, and no client-side validation. For the cases where a plugin needs a typed, validated, inline field, form slots are the answer.
Slot taxonomy. Mirror the existing <resource>.<surface> convention already used by promotion / payment-method editors, and align with the legacy Rails partial names (product_form_partials → product.form):
| Form | Slot points | Context shape |
|---|---|---|
| Product detail | product.form, product.form_sidebar, product.dropdown, product.page_title | { form: UseFormReturn<ProductFormValues>, product: Product, mode: 'edit' } |
| Order detail | order.page_header, order.page_body, order.page_sidebar, order.summary, order.dropdown | { order: Order } (no form — the order detail page is mostly read-only with inline edits) |
| New order | order_new.form, order_new.sidebar | { form: UseFormReturn<NewOrderFormValues>, customer: Customer | null } |
| Customer detail | customer.page_body, customer.page_sidebar, customer.dropdown | { customer: Customer } |
| Promotion form | promotion.sidebar, promotion.main | { form: UseFormReturn<PromotionFormValues>, mode: 'create' | 'edit' } |
| Store settings | store.form | { form: UseFormReturn<StoreSettingsFormValues>, store: Store } |
| Settings forms (payment_method, tax_category, customer_group, stock_location, …) | <resource>.form | { form: UseFormReturn<…FormValues>, resource: <Resource> | null, mode } |
Position semantics. Built-in cards reserve 100/200/300 (with gaps) so plugin slots can sort relative to them — "before Variants card" = < 100, "between Variants and Inventory" = 150, etc. Document the built-in positions per slot when the host page lands.
Usage from the host page (today):
// routes/_authenticated/$storeId/products/$productId.tsx
<ResourceLayout
main={
<>
<GeneralCard form={form} />
<MediaCard productId={productId} variants={product.variants ?? []} />
<InventoryCard form={form} storeId={storeId} hasVariants={hasVariants} />
<CustomFieldsCard ownerType="Spree::Product" ownerId={productId} resourceLabel="products" />
<Slot<ProductFormSlotContext>
name="product.form"
context={{ form, product, mode: 'edit' }}
/>
<MetadataCard metadata={product.metadata} />
</>
}
sidebar={
<>
<StatusCard form={form} />
<CategorizationCard form={form} />
<TaxCard form={form} />
<SEOCard form={form} product={product} />
<Slot<ProductFormSlotContext>
name="product.form_sidebar"
context={{ form, product, mode: 'edit' }}
/>
</>
}
/>
Plugin code (downstream @my-co/spree-warehouse-plugin):
// src/index.tsx — imported once from the app's main.tsx
import { registerSlot } from '@spree/dashboard-core'
import type { ProductFormSlotContext } from '@spree/dashboard/components/spree/products/slots'
import { WarehouseCard } from './warehouse-card'
registerSlot<ProductFormSlotContext>('product.form_sidebar', {
id: 'warehouse-plugin/preferred-location',
position: 250, // after Tax (200), before SEO (300)
component: ({ form, product }) => <WarehouseCard form={form} product={product} />,
})
// src/warehouse-card.tsx
import { Controller, type UseFormReturn } from 'react-hook-form'
import type { Product } from '@spree/admin-sdk'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@spree/dashboard-ui'
import { Field, FieldError, FieldLabel } from '@spree/dashboard-ui'
interface Props {
form: UseFormReturn<{ preferred_warehouse_id?: string; [k: string]: unknown }>
product: Product
}
export function WarehouseCard({ form, product }: Props) {
const { t } = useTranslation()
return (
<Card>
<CardHeader><CardTitle>{t('warehouse.title')}</CardTitle></CardHeader>
<CardContent>
<Field>
<FieldLabel>{t('warehouse.preferred_location')}</FieldLabel>
<Controller
name="preferred_warehouse_id"
control={form.control}
render={({ field }) => <WarehouseCombobox {...field} />}
/>
<FieldError errors={[form.formState.errors.preferred_warehouse_id]} />
</Field>
</CardContent>
</Card>
)
}
The field's value flows through RHF, gets submitted alongside the host form's values (the Admin API accepts unknown keys — extending the JSON payload is enough; the plugin's Ruby side reads it from params[:preferred_warehouse_id] and stores it on the new column it added). Server-side validation errors come back as details: { preferred_warehouse_id: [...] } and mapSpreeErrorsToForm routes them onto the inline field automatically — no host-page changes needed.
Schema augmentation is deferred. Injecting Zod refinements into a schema already wired into useForm has no clean answer (the host page would need to know about the plugin, breaking the one-way registration model). For now, plugin fields skip client-side validation and rely on the server — the 422 path already renders the error inline. We'll revisit when a concrete plugin asks for cross-field client rules.
Slot naming + ownership rules. Same as the existing slot API:
slots.ts module (components/spree/products/slots.ts, components/spree/orders/slots.ts, …) that exports both the slot name and the context type. Plugins import both.What we ship in this plan (Phase 4):
slots.ts files for Product / Order / Customer / Promotion / Store / New Order<Slot> placements in each host page at the documented positionspackages/dashboard/examples/ (a no-op "extra field" plugin so plugin authors have something to copy)What we explicitly defer:
useResolverWith) — wait for a concrete plugin asking for itpriority/replace/exclusive semantics — current position-only model is enough<Card>) — slots inject whole cards/rows. If a plugin needs to interleave fields, it owns its whole card.src/components/spree/)The shadcn copy-paste model is preserved for primitives (src/components/ui/). On top of those primitives we maintain a Spree compound layer at src/components/spree/ that supplies the page chrome, drawers, and form widgets used across resources. Plugin authors can fork any spree/* component into their own override file the same way they fork shadcn primitives — no framework lock-in.
| Component | Purpose | Replaces in Rails admin |
|---|---|---|
<PageHeader> | Title, subtitle, back button, status badges, primary actions, more-actions dropdown. Accepts a resource prop and auto-wires the dropdown items (Metafields, Translations, JSON, Audit, Copy ID/Number, Delete) by capability detection. Hosts <Slot name="page.actions"> and <Slot name="page.actions_dropdown">. | shared/_content_header.html.erb + content_for(:page_*) |
<PageTabs> | Tab strip below the header; integrates with TanStack Router (each tab = sub-route). Hosts <Slot name="page.tabs">. | shared/_page_tabs.html.erb |
<StickyFormFooter> | Save/cancel bar that sticks to viewport bottom; reads formState.isDirty / isSubmitting. | data-controller="sticky" + turbo_save_button_tag |
<ResourceLayout> | Two-column grid (8/4) with consistent gap; slots for header, main, sidebar. | _edit_resource.html.erb skeleton |
<EmptyState> | Icon + title + description + optional CTA. Used by ResourceTable and any "show" empty case. | _no_resource_found.html.erb |
<ErrorState> / <RouteErrorBoundary> | Friendly retry UI; wraps each route. | _error_messages.html.erb |
All drawer-based, opened from <PageHeader>'s actions dropdown.
| Component | Backed by |
|---|---|
<JsonPreviewDrawer> | Pretty-prints the resource via Admin SDK serializer + copy-to-clipboard. Replaces json_previews/show.html.erb. |
<CustomFieldsDrawer> | Per-resource custom fields editor. Supports text/longtext/rich_text/number/date/select/json kinds. Depends on the Custom Fields CRUD API in 5.4-6.0-custom-fields-rename.md. |
<TranslationsDrawer> | Locale tabs + side-by-side editor (see 5.4-centralized-translations-admin.md). |
<AuditLogDrawer> | Compact timeline of resource changes via the Audit Log API endpoint. |
<InternalNoteCard> | Card with rich text body + edit drawer. Reused for orders, customers, returns. Replaces shared/_internal_note.html.erb. |
<CopyButton> | Single-line clipboard wrapper; replaces the inline clipboard_controller divs. |
| Component | Replaces |
|---|---|
mapSpreeErrorsToForm(error, setError) | ActiveModel::Errors → form-builder error rendering; translates a thrown SpreeError into form.setError(...) calls so 422 responses light up aria-invalid + <FieldError> inline (and :base / nested keys land on formState.errors.root.message for a top-of-form banner). |
<MoneyInput> | money_field_controller.js; currency-aware decimal input. |
<SlugInput> | slug_form_controller.js; auto-generates from a watched field until edited manually. |
<SeoFields> | _seo.html.erb + seo_form_controller.js; meta_title + meta_description + slug + SERP preview + character counts. |
<UnitSystemToggle> / <DimensionsInput> | unit_system_controller.js; imperial/metric toggle for weight/dimensions. |
<CodeEditor> | codemirror_controller.js; CodeMirror 6 wrapper for JSON / HTML / Liquid / markdown. |
<ColorInput> | color_picker_controller.js; for option swatches (per 5.4-option-type-enhancements.md). |
<ImageGallery> | Drag-reorder + alt text + delete; wraps existing useDirectUpload. Replaces _media_form.html.erb + _media_asset.html.erb. |
<MultiProductPicker> | _multi_product_picker.html.erb; bundles, related products, gift card constraints. |
<SortableTree> | sortable_tree_controller.js; categories, taxons, navigation builder (uses dnd-kit). |
<ResourcePicker> | Combobox over an Admin API list endpoint; underlies <MultiProductPicker>. |
Layered onto the existing <ResourceTable>.
| Component | Replaces |
|---|---|
<BulkActionsBar> | _bulk_modal.html.erb + bulk_operation_controller.js; floating bar above selected rows; pluggable actions. |
<FilterChips> | _filter_badge_template.erb; active filters as removable pills. |
<SavedViews> | New (no Rails equivalent); persists URL search state to localStorage as named views. |
<ExportDropdown> | _export_modal.html.erb; "Export CSV" + format options. |
<ImportDialog> | imports/ + import_form_controller.js; file upload + column mapping wizard. |
| Component | Replaces |
|---|---|
<CommandPalette> | search_picker_controller.js; ⌘-K global search across products, orders, customers via Admin API. |
<KeyboardShortcuts> | admin_controller.js ⌘-S save shortcut + a ? help modal listing all shortcuts. |
<NotificationCenter> | Persistent dropdown for past Sonner notifications (optional, no Rails equivalent). |
src/components/ui/ — shadcn primitives, copy-paste owned.src/components/spree/ — Spree compound components built on ui/ primitives. Plugin authors override by forking a single file into their app, no framework hooks needed.src/components/*.tsx (top-level) — current home for app-sidebar.tsx, resource-table.tsx, nav-main.tsx, can.tsx, back-button.tsx, confirm-dialog.tsx, tag-combobox.tsx. Migrate gradually into spree/ as Tier 1 lands; do not bulk-rename.src/components/spree/. The exception is anything that's clearly a primitive (a generic MoneyInput could go in ui/); use spree/ whenever the component encodes Spree-specific knowledge (knows about resources, slots, permissions, the Admin SDK, etc.).packages/dashboard/
├── index.html
├── package.json
├── vite.config.ts
├── src/
│ ├── main.tsx # root provider tree
│ ├── router.tsx # TanStack Router config
│ ├── client.ts # admin SDK instance
│ ├── routeTree.gen.ts # generated
│ ├── index.css # Tailwind + shadcn theme + Geist fonts
│ ├── routes/
│ │ ├── __root.tsx
│ │ ├── login.tsx
│ │ └── _authenticated/
│ │ ├── index.tsx # redirects to /$defaultStoreId
│ │ ├── $storeId.tsx # StoreProvider
│ │ └── $storeId/
│ │ ├── index.tsx # dashboard
│ │ ├── orders/
│ │ ├── products/
│ │ └── ...
│ ├── providers/
│ │ ├── auth-provider.tsx # JWT + refresh token
│ │ ├── permission-provider.tsx # CanCanCan mirror
│ │ └── store-provider.tsx # current store context
│ ├── hooks/
│ │ ├── use-auth.ts
│ │ ├── use-products.ts # wraps adminClient.products
│ │ ├── use-orders.ts
│ │ └── ...
│ ├── components/
│ │ ├── app-sidebar.tsx
│ │ ├── nav-main.tsx
│ │ ├── can.tsx # <Can I="..." a="..." />
│ │ ├── back-button.tsx # history.back() with fallback
│ │ ├── resource-table.tsx
│ │ ├── table-toolbar.tsx
│ │ ├── ui/ # shadcn primitives (copy-paste owned)
│ │ └── spree/ # Spree compound components
│ │ ├── page-header.tsx # Tier 1
│ │ ├── page-tabs.tsx
│ │ ├── sticky-form-footer.tsx
│ │ ├── resource-layout.tsx
│ │ ├── empty-state.tsx
│ │ ├── route-error-boundary.tsx
│ │ ├── slot.tsx # <Slot name="..." context={...} />
│ │ ├── json-preview-drawer.tsx # Tier 2
│ │ ├── custom-fields-drawer.tsx
│ │ ├── translations-drawer.tsx
│ │ ├── audit-log-drawer.tsx
│ │ ├── internal-note-card.tsx
│ │ ├── copy-button.tsx
│ │ ├── money-input.tsx # Tier 3 (added as needed)
│ │ └── ...
│ ├── tables/
│ │ ├── orders.tsx # defineTable('orders', ...)
│ │ ├── products.tsx
│ │ └── ...
│ ├── schemas/
│ │ ├── product.ts # Zod form schemas
│ │ └── ...
│ ├── locales/
│ │ └── en.json # admin.fields.* + admin.actions.* + …
│ └── lib/
│ ├── query-client.ts
│ ├── table-registry.ts # defineTable, getTable, tables proxy
│ ├── slot-registry.ts # registerSlot, removeSlot, useSlots
│ ├── nav-registry.ts # nav.add, nav.remove, nav.update
│ ├── permissions.ts # Subject + Action constants
│ ├── formatters.ts # formatPrice, formatRelativeTime
│ ├── i18n.ts # i18next init + locale loading
│ ├── form-errors.ts # mapSpreeErrorsToForm
│ └── utils.ts
<Can> + sidebar filtering$storeId prefix)Highest-leverage work. Every detail page reuses the same chrome and gains the legacy "more actions" drawer toolkit.
<PageHeader>, <PageTabs>, <StickyFormFooter>, <ResourceLayout>, <EmptyState>, <RouteErrorBoundary>)registerSlot, <Slot>, removeSlot, updateSlot) with the built-in slot names from the Slot API sectionnav.add / remove / update / insert_before / insert_after)orders/$orderId.tsx and products/$productId.tsx onto the new chrome — proves the designsrc/hooks/ (currently inline in route files); add useResourceMutation helper for the toast + invalidate pattern<JsonPreviewDrawer> (cheapest, biggest debug payoff)<CustomFieldsDrawer> — depends on Custom Fields CRUD API + SDK (5.4-6.0-custom-fields-rename.md)<TranslationsDrawer> — per 5.4-centralized-translations-admin.md<AuditLogDrawer><InternalNoteCard> extracted from inline order codeBuild remaining resource pages on top of the chrome and Tier 3 form primitives (added as needed, not speculatively).
spree/ layer)<BulkActionsBar>, <FilterChips>, <SavedViews>, <ExportDropdown>, <ImportDialog> (Admin API batch operation support is a prereq)<CommandPalette>, <KeyboardShortcuts>, <NotificationCenter>spree/admin engine entirelycreate-spree-app scaffolding to install @spree/dashboard by default/_authenticated/$storeId/. Always prefix with store.useMemo/useCallback without reason. Keep components small and readable; optimize only when profiling says so.Link and navigate.<Field> / <Input> / <Controller> / <FieldError> blocks. No <SpreeField> / <SpreeForm> wrapper (see "Forms & i18n"). Labels come from i18n. Server validation errors route onto fields via mapSpreeErrorsToForm.src/hooks/. Never call adminClient directly from components.read denial.subject to <Can> or the equivalent check. Sidebar filtering already does this — keep it consistent.zipcode, firstname, shipment_state, line_items in SPA code — use postal_code, first_name, fulfillment_status, items. See 5.4-store-api-naming-standardization.md.src/components/spree/, not the top-level src/components/. The top-level dir is legacy; gradually migrate as Tier 1 lands. Primitives stay in src/components/ui/.<PageHeader> + <ResourceLayout> once those exist — do not reinvent the title row, more-actions dropdown, or two-column grid per page.import '@plugin/foo' in a project's entry file), but we want to support runtime plugin loading for hosted deployments.docs/plans/6.0-admin-api.md — backing APIdocs/plans/6.0-platform-auth.md — auth modeldocs/plans/5.4-store-api-naming-standardization.md — field naming rulesdocs/plans/5.4-6.0-custom-fields-rename.md — Metafields → Custom Fieldsdocs/plans/6.0-fulfillment-and-delivery.md — ShippingCategory dropped