docs/plans/5.6-dashboard-typed-plugin-routes.md
Status: In Progress (core + collision pre-flight + dep policy implemented; spike ✅ passed — remaining: preview-feedback review of registry deprecation)
Target: Spree 5.6 (React Dashboard Developer Preview)
Depends on: 5.6-project-layout-and-dashboard.md (shell export, packages/dashboard-starter), 6.0-admin-spa.md
Author: Damian + Claude
Last updated: 2026-07-06
Plugin routes originally went only through a runtime registry: defineDashboardPlugin({ routes }) entries matched by a catch-all $ route at navigation time. That works, but plugin pages were second-class — <Link>s to them needed as never casts, they got no route-level code splitting, and no router features (loaders, search-schema validation, per-route error boundaries). This plan makes packaged plugins ship ordinary TanStack file routes that the host build compiles into its route tree, giving plugin pages full typed-router citizenship, while the runtime registry remains the API for dynamic, host-app cases.
The enabling insight: TanStack Router's generator parses route files statically — it never executes them — so plugin route files can be composed at build time without running plugin code in Node (the fragility we deliberately avoid; compare Vendure executing the server config inside Vite).
"spree": { "dashboard": { "plugin": true, "routes": "./src/routes" } }. Route files may import anything (they compile in the browser build); the plugin's entry module no longer registers routes.createFileRoute('/_authenticated/$storeId/brands/')). Plugin routes mount under the shell's authenticated store scope. The generator verifies literals and rewrites mismatches — committing them correctly means it never writes into the package (verified: files pass through untouched).src/routeTree.gen.ts, committed). @spree/dashboard/vite's spreeDashboardPlugin() wraps @tanstack/router-plugin with a @tanstack/virtual-file-routes config: a hand-maintained skeleton of the shell's top-level layout (__root → login/accept-invitation → _authenticated → $storeId) that physical()-mounts the shell's $storeId pages plus each discovered plugin's routes directory. Regeneration happens on every dev start and build, from installed package versions — no publish-time snapshot exists to go stale, and upgrades that add/rename pages show up as a reviewable gen-file diff and as type errors on stale links.@spree/dashboard exports createDashboardRouter(routeTree) and <Dashboard router={...} />; the Register/FileRoutesByPath augmentations live in host code only (the shell's own live in its main.tsx dev harness, which hosts never import). Two augmentations of the same interface in one program conflict — this split is load-bearing.plugins.ts, conditional routes). File routes always win: the catch-all $ is by definition the lowest-priority match.@spree/dashboard/vite, not dashboard-core/vite. Custom dashboards built on core + ui have no shell routes to compose; they keep using @spree/dashboard-core/vite.tsc ("exclude": ["src/routes"]). createFileRoute paths only type-check against a generated tree, which only exists in a host program. In this monorepo the starter compiles them (it is the standing consumer test); out-of-repo authors develop against a host app.my-plugin/packages/dashboard/
├── package.json # "spree": { "dashboard": { "plugin": true, "routes": "./src/routes" } }
└── src/
├── index.tsx # entry: slots, nav, tables, translations — NO route registration
├── pages/ # page components
└── routes/ # TanStack route files, full final path literals
├── brands.index.tsx → /$storeId/brands
└── brands.$brandId.tsx → /$storeId/brands/br_123
Route files use everything the router offers — validateSearch (e.g. resourceSearchSchema from dashboard-core for <ResourceTable> pages), loaders, error components. Links to plugin routes are fully typed in the host program: no as string / as never casts.
spreeDashboardPlugin() from @spree/dashboard/vite:
routes/ directory from the host's installed @spree/dashboard.discoverDashboardPluginManifests (same discovery/whitelist as activation and Tailwind scanning — one plugin list drives all three).TanStackRouterVite with the virtual skeleton + physical('') mounts and generatedRouteTree: src/routeTree.gen.ts (host-relative; option to override).Host main.tsx (the starter ships this):
import { createDashboardRouter, Dashboard } from '@spree/dashboard'
import 'virtual:spree-dashboard-plugins'
import './plugins'
import './styles.css'
import { routeTree } from './routeTree.gen'
const router = createDashboardRouter(routeTree)
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
createRoot(document.getElementById('root')!).render(<Dashboard router={router} />)
<Link to="/$storeId/brands/$brandId" params={{ brandId }}> — typed plugin links proven end-to-end.@tanstack/react-router as a direct dependency: its gen file augments the router module, and augmentation only lands on the copy the host resolves.Packed the example plugin (pnpm pack) and installed it into a throwaway host via file:<tarball> — a real extracted directory in node_modules, not a workspace symlink (@spree/* were symlinked; the plugin was the variable under test). Ran a real vite build through spreeDashboardPlugin():
routesDir to …/node_modules/@spree/dashboard-plugin-example/src/routes and the generator composed both brands routes (46 total) with correct full paths/IDs; the emitted import points into node_modules.createFileRoute literals intact. (The generator only rewrites a file whose literal disagrees with the composed path; committing the final literal keeps published packages read-only.)enforce: 'pre' / buildStart, before Tailwind and bundling — confirmed by isolating it (a config without spreeDashboardPlugin()'s virtual module still generated the full tree before failing at the unrelated virtual:spree-dashboard-plugins bundle step).Conclusion: published plugins are cleared. The ../node_modules/… relative mount that physical() + path.relative produce resolves correctly through the generator in the mixed symlink-shell + tarball-plugin case (the harder resolution shape); a uniform registry install is strictly simpler.
@/ → relative imports, createDashboardRouter + <Dashboard router> exports, Register moved to main.tsx.@spree/dashboard/vite route composition; the dashboard app itself and packages/dashboard-starter build through it.spree.dashboard.routes marker; example plugin and CLI scaffold template migrated to file routes.node_modules and never writes into the package; published plugins cleared.router-plugin → dependency).routes: stays indefinitely for in-app use; revisit whether to deprecate it for packaged plugins after the preview feedback round.packages/dashboard/src/vite/index.ts (files under _authenticated/$storeId/ need nothing — they're physically mounted).Register/route-tree augmentations in shell code hosts compile — host main.tsx only.src/pages/, route files in src/routes/, and exclude src/routes from standalone tsc.routeTree.gen.ts anywhere; it regenerates on every build.node_modules and never writes into the package; published plugins are cleared.@spree/dashboard/vite pre-flights collisions before invoking the generator (assertNoRouteCollisions, src/vite/route-collisions.ts) and throws an error naming the conflicting packages (the shell counts as @spree/dashboard), the route path, and each file — replacing TanStack's file-path-only Conflicting configuration paths error. Only cross-package collisions are pre-flighted; within-package duplicates stay the generator's to report. Covered by unit tests and verified live against two plugins colliding on /$storeId/brands/.@tanstack/react-router is a peerDependency (^1.x) — the host owns the singleton router; a single copy is required for the Register augmentation and the router instance. Matches dashboard-core/dashboard-ui. (Peer-only, no dev copy, per the repo's existing convention — the workspace root hoists it for in-repo build/test.)@tanstack/router-plugin and @tanstack/virtual-file-routes are dependencies — they load through @spree/dashboard/vite at the host's vite.config.ts evaluation, so they must install transitively for consumers. (This is a fix: router-plugin was a devDependency, which would have left a published @spree/dashboard's /vite import unresolved in host projects — verified via the starter, which builds with no direct router-plugin dep.)1.x major, not a shared minor band: the three packages version independently (observed react-router 1.170, router-plugin 1.168, virtual-file-routes 1.162). Floors are set to the known-good lockfile versions. On a router major bump, bump all three together and re-run the composition + starter typecheck.docs/plans/5.6-project-layout-and-dashboard.md — shell export, starter, packaging decisions (open question 5 there is resolved by this plan)docs/plans/6.0-admin-spa.md — SPA architecture and extension pointspackages/dashboard/src/vite/index.ts — the compositionpackages/dashboard-core/src/lib/route-registry.ts — the runtime registry this coexists with