Skip to main content
Staff need a place to manage brands. The back office is a React app that lives in your project at apps/dashboard/, and you add screens to it by editing that app’s own code. The dashboard talks to the Admin API you generated in step 1. That is the only data source — it never reaches into the Server app’s models or renders server HTML.

Where this code goes

A project created with create-spree-app has this shape:
apps/dashboard/src/plugins.ts ships empty, imported once by main.tsx before the dashboard renders. Everything in this step goes there or in files next to it. The file and the defineDashboardPlugin function are named for the registries they talk to, not for how the code ships. A distributed package and your own app call the same function with the same options — the only difference is where the file lives.
Everything here is an ordinary file in your dashboard app, committed and deployed with the rest of your project. Publishing a package is only worth it when you want to reuse the same screens across several stores — the registrations are identical either way, so moving one into a package later is a copy, not a rewrite. See dashboard plugins.
@spree/dashboard-core (registries, providers and hooks) and @spree/dashboard-ui (the design system) come installed with the dashboard, so you can import from them straight away.

Step 1: A typed client for the endpoint

Wrap the Admin API calls in one file so pages never call request directly. adminClient carries authentication, retries and error handling for you — see talking to the backend for custom endpoints, React-Query hooks and error handling:
apps/dashboard/src/brands/client.ts
Paths are relative to /api/v3/admin, so '/brands' reaches the controller from step 1. The Brand interface matches what your Admin serializer returns, timestamps included.

Step 2: Describe the table

defineTable declares the columns, sorting, search and empty state. The dashboard renders it with the same table chrome as Products and Orders:
apps/dashboard/src/plugins.ts
name is sortable and filterable because the model allowlisted it in step 1. A column that sorts by an attribute the backend does not permit fails at request time, so the two lists have to agree. This is the short version. Tables documents every field of a column definition, plus bulk actions, row actions and drag reordering — and how to add or remove columns on a built-in table such as products or orders.
Every visible string goes through i18next — column labels, titles, buttons, empty states. Never hardcode English into a table definition or into JSX, and add each new key to every locale file your project ships. See translations for the field-key convention and how server-side validation messages are resolved.

Step 3: Build the list page

ResourceTable supplies filtering, sorting and pagination against the table you just declared:
apps/dashboard/src/brands/list-page.tsx

Step 4: Register navigation, the route and a product card

Back in plugins.ts, defineDashboardPlugin wires the page into the app:
apps/dashboard/src/plugins.ts
Three extension points are doing the work, each with its own reference page: product.form_sidebar is what makes brands useful to the person editing a product: the product page knows nothing about brands, and the slot registry is what puts your card there.
Hiding a nav entry or a column behind a permission is one option away — but hiding is never authorising. The Admin API still has to refuse the request, which is why step 1 left authorization to you.
Showing the current brand is not much use on its own — the point is to change it. The product page is one big form with a single Save button, and product.form_sidebar renders inside that form, so the card can own an input without owning any save logic. Two pieces make that work. First, tell the form the field exists and where its value comes from on load:
apps/dashboard/src/plugins.ts
Then render the input, binding it to the host form with useHostForm(). No <form> of your own, no Save button, no mutation — dirty tracking, the PATCH payload and re-baselining after save all stay with the page:
apps/dashboard/src/brands/product-card.tsx
ResourceCombobox searches the API as the merchant types rather than loading every brand up front, which is what keeps the picker usable on a catalogue with thousands of them. hydrate resolves the saved ID back to a name so the trigger reads correctly on first render. For this to persist, the Admin API has to accept brand_id on the product — the resource_permitted_attributes half of the association you set up in step 1.
A field that merchants should be able to define themselves, rather than one your code adds as a column, is usually better as a custom field — no code at all. See adding a custom form field for both paths.

Step 5: Run it

spree dev runs the Server app and the dashboard together:
Open the dashboard at http://localhost:5173. Brands appears in the sidebar, the list reads your Admin API endpoint from step 1, and the product edit page shows the card.
Adding a create or edit form next? Wrap handleSubmit in a try/catch that calls mapSpreeErrorsToForm so 422 responses land on the right fields, and gate any delete that fires straight from a click behind useConfirm() with variant: 'destructive'.

Going further

This step used a handful of the dashboard’s extension points. The reference documentation covers the rest:

Customization quickstart

The same ground in five minutes, without the Brands feature around it

Public API

Every component, hook, registry and provider you can safely import

Concepts

The mental model — what each layer does and where your code fits

Recipes

Worked examples: a custom form field, a page action, a sidebar widget

Next step

Staff can manage brands. Now show them to customers: Storefront.