Examples here import from
@spree/dashboard, which re-exports the framework and the design system for host applications. A distributed plugin imports @spree/dashboard-core and @spree/dashboard-ui directly instead — it extends the shell rather than shipping it. See plugin overview.customers/, orders/, products/, …) are file-based via TanStack Router and live in @spree/dashboard/src/routes/. Custom pages have two mechanisms:
- File routes (packaged plugins — preferred): the plugin ships ordinary TanStack route files, and the host build compiles them into its typed route tree. Typed
<Link>s with no casts, route-level code splitting,validateSearch, loaders — full router citizenship. - Route registry (in-app & dynamic):
defineDashboardPlugin({ routes })entries matched at navigation time by a catch-all dispatcher at/_authenticated/$storeId/$. Use for host-app customizations (plugins.ts) and anything registered conditionally at runtime. File routes always win over the registry — the catch-all is the lowest-priority match.
File routes (packaged plugins)
Declare a routes directory in your plugin’s marker and put TanStack route files in it:- Commit the final composed path literal. Plugin routes mount under the dashboard’s
_authenticated/$storeIdlayout; the route generator verifies the literal (and would rewrite a wrong one). - The host regenerates its
routeTree.gen.tson every dev start and build from installed package versions — updating your plugin picks up new routes automatically (dev servers need a restart, same as installing a plugin). - Exclude
src/routesfrom your package’s standalonetsc—createFileRoutepaths only type-check against a generated tree, which exists in host programs. Develop against a dashboard host (the scaffold’s tsconfig ships this exclusion).
Route collisions
Two packages can’t own the same route path. If a plugin declares a path that another plugin — or a built-in dashboard page — already claims, the build fails before generating the tree with an error naming both packages, the path, and each file:Runtime route registry (in-app & dynamic)
Register from your host app’splugins.ts (or a plugin that genuinely needs runtime registration):
path is relative to /$storeId — the dispatcher prepends the prefix at match time. So /reports matches the URL /store_xyz/reports. The leading / is required.
A registry route can sit under a built-in section, next to its record pages: /sellers/shopify renders your page, not a seller whose ID is shopify. A registry route wins only where it is more specific than the built-in page — fixed segments beat $params — so it can never replace a built-in page at the same path, or claim every record with a pattern such as /sellers/$id.
Pages outside a store
Most pages live inside a store, under/$storeId. Some don’t belong to one: an onboarding flow, a sign-up page, an account page, terms of service. Both mechanisms can mount pages at the root instead, in one of two scopes:
These pages render on a blank canvas. To match the login page’s look, wrap them in
AuthShell from @spree/dashboard/components/spree/auth-shell.
What these pages can use. There is no current store here, so anything that reads one is unavailable: useStore(), store permissions (usePermissions(), <Can>), the sidebar and top bar, and the store pages’ slots. Everything else works — useAuth() (the signed-in user on authenticated pages, with the stores they can open in user.stores), adminClient for calls that don’t need a store, i18n, and the @spree/dashboard-ui components. On public pages nobody may be signed in, so only call endpoints that need no session.
Reserved paths. The dashboard owns /, /login, /setup, /forgot-password, /reset-password, /accept-invitation/$invitationId and /$storeId. A static path such as /sign-up takes priority over /$storeId, which means no store can be reached at that path. If a future dashboard release adds a page at a path you use, the build (or, for registry routes, the router) fails with an error naming both, instead of one page silently replacing the other. Prefer specific paths (/sign-up, /account) over generic ones.
Keep them out of the main bundle. A sign-up page is opened by people who never see the rest of the dashboard. Load the page component on demand: lazyRouteComponent(() => import('../pages/sign-up'), 'SignUpPage') from @tanstack/react-router in a file route, or lazy(() => import('./pages/sign-up')) from React as a registry route’s component.
File routes
Declare a separate directory per scope in your plugin’s marker, next toroutes:
_authenticated layout; public pages sit at the root:
/sign-up that clashes with another plugin’s — or a signed-in /account that clashes with a public /account — fails the build with both packages named. Built-in pages such as /login and the signed-in landing page at / can’t be claimed; to change the landing page, register on the store_selection slot instead.
Registry routes
Give the entry ascope. Its path is then the full URL path, not one relative to /$storeId:
params and searchParams, but no storeId. subject isn’t available: permissions belong to a store.
Unlike store routes, these become real routes when the router is built, so register them before createDashboardRouter runs. Your plugins.ts and every installed plugin already are — main.tsx imports them first. A path already used by a dashboard page or another root registry route throws when the router is built, naming the route’s key.
Path parameters
TanStack-Router-style$name tokens match a single non-empty segment:
Receiving params
Your component receives three props from the dispatcher:searchParams is typed as Record<string, unknown> because the dispatcher can’t statically know your page’s shape. If you’re handing it to <ResourceTable>, cast to ResourceSearch:
Permission gating
Asubject on the route entry renders a 403 fallback (instead of the page) when the user lacks read permission:
subject on the nav entry so users without permission never see the link in the first place. The route subject is the defensive layer for direct URLs.
subject is a registry-route feature — the dispatcher applies it before rendering. File routes don’t pass through the dispatcher, so they gate themselves: check permissions inside the component with <Can> or usePermissions() from @spree/dashboard-core and render a fallback. Either way, this is UX only — the backend still authorizes every API call.
Layout
Store routes inherit the dashboard’s_authenticated + $storeId layout (auth guard, sidebar, top bar, etc.) — your component renders inside the <Outlet />. Use <ResourceLayout> to get the same header/main/sidebar grid as core pages:
Linking to a registry route
Registry routes aren’t in the generated route tree, so links to them bypass static checking:$name token.
Order of operations
Routes register at module-load time. The dispatcher reads from the registry on every navigation, so route registration is much more forgiving than nav: even a route registered after first render works as soon as the user navigates to it. The only ordering rule is the usual i18n one — register your translation bundle above any registration that callsi18n.t(...) (see Translations).
Reference
RouteEntry— full type- Catch-all dispatcher source — see how matching works

