Skip to main content
This page is a five-minute orientation for the dashboard’s technology choices and how they cooperate. If you’re already familiar with React + TanStack + Tailwind, skim it for the Spree-specific bits and move on.

Tech stack at a glance

LayerWhat we useWhy it matters
BuildViteFast HMR, dev server, source consumption of TS/TSX
UI runtimeReact 19Components, hooks, suspense
RoutingTanStack Router (file-based)Type-safe URLs, nested layouts, search params
DataTanStack Query v5Caching, refetch, mutation tracking
FormsReact Hook Form + ZodUncontrolled inputs + schema validation
StylingTailwind v4 + shadcn/ui + Base UIUtility classes + headless primitives
i18ni18nextTranslations, fallback chains
Lint/formatBiomeESLint + Prettier in one tool
HTTP@spree/admin-sdkTyped Admin API client
The dashboard is a Single Page Application: one HTML page, all routing client-side. No Rails views, no Turbo, no server-rendered partials. Everything the user sees comes from React components fetching JSON from /api/v3/admin/*.

The three-package split

Overview covers the split in more detail. The short version: -ui has no providers or hooks (data comes via props); -core is the extension API and runtime services; -dashboard is the deployable app shell. Plugins and customizations consume -core and -ui, never the app shell.

How customization plugs in

Five global, in-memory registries hold the things you add or modify:
RegistryAddsSurfaced by
navSidebar entries<AppSidebar>
settingsNavSettings sub-shell entries<SettingsSidebar>
Route registryCustom pages mounted under /$storeId/*Catch-all dispatcher route
Slot registryComponents injected into named slots in built-in pages<Slot name="...">
Table registryColumns (and their filters) on built-in list tables<ResourceTable>
Each is a module singleton with mutators to add, remove, and patch entries (nav can also insert relative to an existing entry or nest children under one). The consumer components subscribe to changes, so registering late still updates the UI on the next render. The facade defineDashboardPlugin({ nav, routes, slots, … }) groups all five into one declarative call — convenient for plugins, optional for in-app customizations (you can call nav.add() directly).

Auth, context, providers

The signed-in admin’s identity and abilities are exposed via three providers wrapping the app:
  • <AuthProvider> — the current admin user; useAuth() returns { user, signIn, signOut, … }
  • <PermissionProvider> — CanCanCan abilities; usePermissions() returns { can, cannot }
  • <StoreProvider> — current store + timezone + currency; useStore() returns { storeId, store, … }
When you write a custom page or hook, pull from these. Never reach into local state for identity — it changes on store switch or session refresh, and the providers wire all that for you.

Data fetching

Every screen in the dashboard follows the same pattern:
Wrap SDK calls in custom hooks under src/hooks/, never call adminClient directly from components. See Backend integration for the full pattern, error mapping, and the useResourceMutation helper that wires up 422 handling.

URL = state

The dashboard treats the URL as the single source of truth for page state:
  • Filters, sort, page number → URL search params
  • Selected row → URL path param
  • “Sheet open?” → URL search param (?edit=prod_xxx)
This is why links work, deep-links restore filter state, and the browser back button does the right thing. When you build a custom page, follow the same rule — don’t stash filter state in React state where it’s invisible to bookmarks and analytics.

i18n is not optional

The dashboard ships with English, German, French, Polish, Arabic, and Simplified Chinese out of the box. Every label, placeholder, error message goes through i18n.t(). When you add a feature:
  1. Add the keys to your customization’s locales/en.json
  2. Register the bundle: i18n.addResourceBundle('en', 'translation', bundle, true, true)
  3. Use useTranslation() in components and i18n.t() at registration sites
Translations covers the field-key convention.

Prefixed IDs everywhere

The Admin API uses Stripe-style prefixed IDs: prod_86Rf07xd4z, cust_k5nR8xLq. The dashboard does too — URLs, hook params, table rows, SDK calls. Never coerce IDs to integers. Never strip the prefix. Pass them around as opaque strings.

Where to go next