Back to Spree

Admin SPA

docs/plans/6.0-admin-spa.md

5.5.059.0 KB
Original Source

Admin SPA

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

Summary

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).

Key Decisions (do not deviate without discussion)

  • Drop the Rails admin panel entirely. No dual-maintenance. When the SPA reaches feature parity, spree/admin is removed.
  • Vite + TanStack Router + TanStack Query + React Hook Form + shadcn/ui + Tailwind + Biome + Vitest. Use the newest versions (as of March 2026).
  • No SSR. Pure client-side, outputs static files.
  • Admin API is the only data source. Never reach into Rails models, never import Rails-rendered HTML.
  • Route path includes /$storeId/ for all authenticated routes (multi-store support). / redirects to the current/default store.
  • Auth via JWT + refresh token. Access token in memory, refresh token in an httpOnly cookie (see 5.5-admin-auth-cookie-refresh.md). 401 → auto-refresh via SDK onUnauthorized hook → retry.
  • Permissions are server-driven. 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.
  • Shadcn/ui copy-paste ownership model — plugin authors can extend or override components without fighting a component library's abstraction.
  • Table registry + navigation registry + slot registry are the three extension points. Nothing else is pluggable yet. Slot names mirror Rails admin's render_admin_partials slots so plugin authors carry their mental model across.
  • No global state library. TanStack Query handles server state, React Hook Form handles form state, AuthProvider + PermissionProvider handle session. Add Zustand only if we hit a legitimate client-state problem.
  • No breadcrumbs. Legacy admin used breadcrumbs because deep flows (Product → Variants → Variant edit) were full-page navigations. The SPA collapses those into drawers and inline UIs, so the chains shrink to one or two segments — covered by <BackButton> (in <PageHeader>), the page title itself, and the highlighted sidebar section. Do not reintroduce a breadcrumb component.
  • shipping_category_id is not part of product forms — dropped in 6.0 per 6.0-fulfillment-and-delivery.md.
  • Forms stay as explicit React Hook Form blocks (<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.
  • i18next + react-i18next for admin UI strings. All form labels, placeholders, help text, button copy, and toast text resolve through 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.

Design Details

Monorepo location

  • Packages: packages/dashboard/ (the app), packages/dashboard-core/ (framework), packages/dashboard-ui/ (design system). See "Package Split" below.
  • Depends on: @spree/admin-sdk (workspace), @spree/sdk-core (workspace)
  • Output: dist/ (static SPA). Optionally bundled into spree_admin gem at build time for zero-config local dev.
  • Dev server: pnpm devvitehttp://localhost:5173, proxying /api/* to http://localhost:3000

Package Split

The 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)

Why three packages, not one or many

We looked at how Medusa, Vendure, and Saleor structure their admin packages:

  • Medusa (@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 (@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 (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:

  1. Public feature pages = public API surface → every refactor of orders/ becomes a semver event.
  2. Feature pages cross-reference each other (orders ↔ customers ↔ products); a clean split is a major refactor before you ship anything.
  3. Bundle bloat and leaked internals: feature packages always end up re-exporting private utilities.

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.

Package boundaries (the rules)

The three rules below decide which package each file belongs to. Apply mechanically.

@spree/dashboard-ui — design system. Source-only (no build).

  • Headless rule: components accept all their data via props. They never import providers, never call hooks, never touch the admin SDK. This is the rule that makes them reusable from outside @spree/dashboard.
  • Contents: 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).
  • Source-only because Tailwind v4 scans source files in the consuming app for utility classes; a pre-compiled CSS bundle would miss classes only used here. The consuming app's Vite/Tailwind setup compiles dashboard-ui transparently.
  • Peer deps: react, react-dom.

@spree/dashboard-core — framework. The extension API surface.

  • Contents: registries (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).
  • Peer deps: 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.

  • Contents: routes (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.
  • Resource-specific compound components that depend on resource hooks (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).
  • Depends on all three: @spree/admin-sdk, @spree/dashboard-core, @spree/dashboard-ui.

Client injection: import.meta.env in dashboard-core

The 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).

Plugin authoring (target API, lands with Phase 2)

tsx
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.

Component coupling — the headless rule in detail

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:

ClassDescriptionExamplesTarget package
A — generic, zero couplingComponents that already only import cn, primitives, and pure helpersrelative-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-layoutdashboard-ui (move as-is)
B — provider-coupled but data-agnosticComponents 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/sonnerdashboard-ui (refactor to take provider data via props/render-props)
C — resource-data-coupledComponents that import resource-specific hooks or schemascountry-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.

Phasing

The split lands in four phases, each independently mergeable and each leaving the repo in a buildable state:

  • Phase 0 (this PR): rename packages/adminpackages/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.
  • Phase 1: extract @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'.
  • Phase 2: extract @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'.
  • Phase 3 (ongoing): headless-refactor each Class B and Class C compound, one PR at a time. For each component: refactor to accept data via props, update every callsite in routes, then move the file to dashboard-ui. Start with the most-reused: <PageHeader>, <ResourceTable>, <AppSidebar>.

Open architectural follow-ups (deferred from Phase 0)

  • Cross-cutting infra hooks: 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 ambient context: today <Slot>'s ambient context (permissions, store, user) is a TODO. Wire it up via providers from dashboard-core during Phase 2.
  • Locale resource injection: 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.

Tech stack

LayerChoiceWhy
Build & devViteFast HMR, minimal config, outputs static files
RoutingTanStack RouterType-safe routes, file-based convention, loader + beforeLoad hooks integrate with TanStack Query
Data fetchingTanStack QueryCaching, background refetches, optimistic updates. @spree/admin-sdk calls wrapped in custom hooks (useProducts, useOrder, ...)
FormsReact Hook Form + ZodSchemas mirror API contracts; Zod schemas can be derived from Typelizer-generated types
UIshadcn/ui + TailwindCopy-paste ownership so extensions can override without fighting component APIs
Iconslucide-react
AuthCustom AuthProviderAccess 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
PermissionsCustom PermissionProviderLoads /api/v3/admin/me on login, exposes usePermissions() + <Can> mirroring CanCanCan
State (session)AuthProvider + PermissionProviderSmall context, no global store
Linting/formattingBiomeSingle tool, fast
TestingVitest + React Testing Library (unit/integration), Playwright (E2E)
FontsGeist + Geist MonoSame as old spree/admin

Auth flow

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.

Route structure

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/
      ...
  • All data-owning routes are /$storeId/*. No duplication of routes across storefront-prefix and non-prefixed variants (we had that briefly and removed it).
  • Table list pages live at /$storeId/{resource}/, detail pages at /$storeId/{resource}/$id.
  • Navigation is built dynamically from the current storeId and filtered by permissions.

Auth + Permission providers

tsx
<AuthProvider>
  <PermissionProvider>
    <TooltipProvider>
      <RouterProvider router={router} context={{ auth, permissions }} />
    </TooltipProvider>
  </PermissionProvider>
</AuthProvider>

Router context carries auth and permissions so beforeLoad guards can do:

tsx
beforeLoad: ({ context }) => {
  if (context.permissions.cannot('manage', 'Spree::User')) {
    throw redirect({ to: '/$storeId' })
  }
}

Table registry

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:

  1. A table definition (src/tables/orders.tsx)
  2. A route (src/routes/_authenticated/$storeId/orders/index.tsx) that renders <ResourceTable>

Example

tsx
// 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) : '—' },
  ],
})
tsx
// 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

OptionTypeDescription
titlestringToolbar title
columnsColumnDef[]Column definitions
searchParamstringRansack search param name (default name_cont)
searchPlaceholderstring
defaultSort{ field, direction }Default { updated_at, desc }
emptyIcon, emptyMessageReactNode, string

ColumnDef reference

PropertyDefaultDescription
keyrequiredUnique identifier, used as Ransack attribute unless ransackAttribute is set
labelrequiredHeader + toolbar display
sortablefalse
filterablefalseShows in filter drawer
defaultfalseVisible by default
filterType'string''string' | 'status' | 'boolean' | 'number' | 'date'
filterOptionsFor status filter
ransackAttributeOverride when Ransack attribute ≠ column key
renderCustom cell renderer
classNameCSS class on <td>
displayabletruefalse for filter-only columns

Filter operators per type

TypeOperators
stringcontains, equals, does not equal, starts with, ends with, is set, is not set
statusis, is not, is any of, is none of
booleanis
numbereq, gt, gte, lt, lte
dateis, after, before, on or after, on or before

Extension points

Three extension points only (do not add more without discussion):

  1. Navigation registry — plugins add sidebar items by registering nav entries with required permissions. Mirrors Rails admin's Spree.admin.navigation.sidebar DSL (spree/admin/config/initializers/spree_admin_navigation.rb).
  2. Table registry — plugins add/remove/update columns, filters, and sorts on existing tables:
tsx
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',
})
  1. Slot registry — plugins inject components into named slots on existing pages. Mirrors the Rails admin's render_admin_partials(:slot_name, ...) mechanism (defined as the Environment Struct in spree/admin/lib/spree/admin/engine.rb). See Slot API below.

Slot API

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.

Concept

tsx
// 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

FieldTypeDescription
idstring (required)Unique within the slot — required so plugins can removeSlot(name, id) or updateSlot(name, id, …).
componentComponentType<SlotContext>Receives the slot's context as props.
positionnumber (default 100)Sort order. Built-in entries use 100/200/300 to leave room.
if(ctx) => booleanOptional gate; receives the slot context plus { permissions, store, user }.

removeSlot(name, id) and updateSlot(name, id, patch) mirror the table registry methods.

Built-in slot names (mirror of Rails admin)

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.analytics

Product 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.
  • Context for all order slots: { 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.

Adding a new slot

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.

Why named slots over render-prop component overrides

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.

Comparison with Rails admin DSL

Rails adminReact 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 :coltables.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: falsedisplayable: 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 })

Forms & i18n

Admin SPA forms are raw React Hook Form blocks<Field> / <Input> / <Controller> / <FieldError> written out per attribute — combined with two small utilities:

  1. mapSpreeErrorsToForm(error, setError) translates 422 SpreeError responses into form.setError(...) calls (flat attribute keys onto fields, :base and nested keys onto formState.errors.root).
  2. useTranslation() + admin.fields.<resource>.<attribute>.{label,placeholder,help} keys in packages/dashboard/src/locales/en.json keep labels translatable.

Why not a <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.

Translation keys

jsonc
// 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.

Form pattern

tsx
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.

Server error mapping

ts
// 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.

Extending forms — per-resource form slots

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_partialsproduct.form):

FormSlot pointsContext shape
Product detailproduct.form, product.form_sidebar, product.dropdown, product.page_title{ form: UseFormReturn<ProductFormValues>, product: Product, mode: 'edit' }
Order detailorder.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 orderorder_new.form, order_new.sidebar{ form: UseFormReturn<NewOrderFormValues>, customer: Customer | null }
Customer detailcustomer.page_body, customer.page_sidebar, customer.dropdown{ customer: Customer }
Promotion formpromotion.sidebar, promotion.main{ form: UseFormReturn<PromotionFormValues>, mode: 'create' | 'edit' }
Store settingsstore.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):

tsx
// 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):

tsx
// 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} />,
})
tsx
// 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:

  • New slots cost forever — once published, removing or renaming them breaks plugins. Add one only when at least one real plugin needs it.
  • Slot keys live in a per-form 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.
  • Document every new slot in the table above in the same PR that adds it.

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 positions
  • Reference plugin example in packages/dashboard/examples/ (a no-op "extra field" plugin so plugin authors have something to copy)

What we explicitly defer:

  • Schema augmentation helper (useResolverWith) — wait for a concrete plugin asking for it
  • Slot priority/replace/exclusive semantics — current position-only model is enough
  • Per-field injection (between two existing fields in the same <Card>) — slots inject whole cards/rows. If a plugin needs to interleave fields, it owns its whole card.

Component library (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.

Tier 1 — page chrome (built first; every detail page sits on this)

ComponentPurposeReplaces 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

Tier 2 — resource meta tooling (the Rails "more actions" dropdown)

All drawer-based, opened from <PageHeader>'s actions dropdown.

ComponentBacked 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.

Tier 3 — form/data primitives

ComponentReplaces
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>.

Tier 4 — table/list enhancements

Layered onto the existing <ResourceTable>.

ComponentReplaces
<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.

Tier 5 — power-user / shell

ComponentReplaces
<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).

Naming and ownership rules

  • 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.
  • New compound components default to 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.).

File structure

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

Migration Path

Phase 1 — foundation (✅ done)

  • ✅ Package skeleton, Vite, TanStack Router, TanStack Query
  • ✅ Auth (login, logout, JWT refresh, 401 auto-retry)
  • ✅ Permission provider + <Can> + sidebar filtering
  • ✅ Table registry + resource table
  • ✅ Multi-store routing ($storeId prefix)

Phase 2 — page chrome + slot API (in progress)

Highest-leverage work. Every detail page reuses the same chrome and gains the legacy "more actions" drawer toolkit.

  • ⏳ Tier 1 components (<PageHeader>, <PageTabs>, <StickyFormFooter>, <ResourceLayout>, <EmptyState>, <RouteErrorBoundary>)
  • ⏳ Slot registry (registerSlot, <Slot>, removeSlot, updateSlot) with the built-in slot names from the Slot API section
  • ⏳ Navigation registry (nav.add / remove / update / insert_before / insert_after)
  • ⏳ Refactor orders/$orderId.tsx and products/$productId.tsx onto the new chrome — proves the design
  • ⏳ Normalize per-resource hooks into src/hooks/ (currently inline in route files); add useResourceMutation helper for the toast + invalidate pattern

Phase 3 — resource meta tooling (Tier 2)

  • <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 code

Phase 4 — resource parity with Rails admin

Build remaining resource pages on top of the chrome and Tier 3 form primitives (added as needed, not speculatively).

  • ⏳ Products list + detail/edit (extract sub-cards into spree/ layer)
  • ⏳ Orders list + detail/edit
  • ⏳ Customers
  • ⏳ Promotions
  • ⏳ Stock, categories, store settings, admin users, roles
  • ⏳ Reports, exports, imports, dashboard

Phase 5 — bulk + power-user (Tiers 4 + 5)

  • <BulkActionsBar>, <FilterChips>, <SavedViews>, <ExportDropdown>, <ImportDialog> (Admin API batch operation support is a prereq)
  • <CommandPalette>, <KeyboardShortcuts>, <NotificationCenter>
  • ⏳ Plugin loader / discovery mechanism (build-time first; runtime later)

Phase 6 — drop Rails admin

  • Remove spree/admin engine entirely
  • Update create-spree-app scaffolding to install @spree/dashboard by default
  • Update docs

Constraints on Current Work

  • No new routes outside /_authenticated/$storeId/. Always prefix with store.
  • Always use SDK types. Never hand-write API response shapes. If a type is wrong, fix the Rails serializer + run Typelizer, don't cast in the SPA.
  • No useMemo/useCallback without reason. Keep components small and readable; optimize only when profiling says so.
  • No imperative navigation hacks. Use TanStack Router Link and navigate.
  • Forms use React Hook Form + Zod with raw <Field> / <Input> / <Controller> / <FieldError> blocks. No <SpreeField> / <SpreeForm> wrapper (see "Forms & i18n"). Labels come from i18n. Server validation errors route onto fields via mapSpreeErrorsToForm.
  • All data fetching goes through TanStack Query hooks in src/hooks/. Never call adminClient directly from components.
  • Permission-gate UI optimistically. Show the button, handle 403 with a toast. Only hide when there's a clear read denial.
  • All nav items, routes, and action buttons that correspond to Admin API resources must pass a subject to <Can> or the equivalent check. Sidebar filtering already does this — keep it consistent.
  • Field names must match the API. No 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.
  • New compound components go in 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/.
  • Detail pages must use <PageHeader> + <ResourceLayout> once those exist — do not reinvent the title row, more-actions dropdown, or two-column grid per page.
  • Don't add new slot names speculatively. A slot is an API surface; only add one when a real plugin needs it. Document any new slot in this plan's Slot API section in the same PR.

Open Questions

  • Plugin distribution — npm packages? Dynamic imports? Build-time registration? Current default is build-time (import '@plugin/foo' in a project's entry file), but we want to support runtime plugin loading for hosted deployments.
  • Theme customization — how deep? Tailwind config overrides? CSS variables? shadcn theme tokens?
  • Bundled vs. standalone deployment — do we ship SPA compiled into the gem, or require a separate static host? Hybrid (gem ships fallback, ops can override URL)?
  • Offline / optimistic mutations — TanStack Query supports it but we haven't wired it in. Priority for Phase 5?
  • Storybook — light visual docs would help plugin authors discover components and slots. Likely Phase 3 deliverable, not a blocker.
  • Print views (packing slip, invoice) — Rails admin doesn't ship these in core (likely enterprise). Decide whether the SPA owns a basic print view or stays out.

References

  • docs/plans/6.0-admin-api.md — backing API
  • docs/plans/6.0-platform-auth.md — auth model
  • docs/plans/5.4-store-api-naming-standardization.md — field naming rules
  • docs/plans/5.4-6.0-custom-fields-rename.md — Metafields → Custom Fields
  • docs/plans/6.0-fulfillment-and-delivery.md — ShippingCategory dropped