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 withcreate-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 callrequest 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
/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.
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 inplugins.ts, defineDashboardPlugin wires the page into the app:
apps/dashboard/src/plugins.ts
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.
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
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:
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.
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

