Back to Spree

5.6 Project Layout & Dashboard Scaffolding

docs/plans/5.6-project-layout-and-dashboard.md

5.6.039.5 KB
Original Source

5.6 Project Layout & Dashboard Scaffolding

Status: In progress Target: Spree 5.6 (Dashboard Developer Preview) Depends on: 5.4-spree-starter-and-create-spree-app.md (shipped), 6.0-admin-spa.md Author: Damian + Claude Last updated: 2026-07-06

Summary

Ship the React dashboard as part of create-spree-app in 5.6 (Developer Preview) and align the generated project layout with the future API-only Rails role. The Rails app directory is renamed backend/api/ for all new projects scaffolded on 5.6+ — the name change is more honest the moment a second frontend exists (the dashboard), even though Rails still serves the legacy admin until 6.0. Existing projects are unaffected: they continue to work as-is, and can opt in to the new layout via spree upgrade layout whenever they want. The dashboard scaffolds into apps/dashboard/ alongside the existing apps/storefront/, and a new spree add dashboard command lets existing projects bolt it on. By 6.0 GA the rename is well-trodden and the legacy backend/ layout becomes unsupported.

Problem

  1. backend/ no longer describes the role. Even in 5.6 the Rails app is already serving two distinct frontends (storefront and dashboard) plus background workers. Generic role names ("backend") stop working as soon as two frontends consume the API — "which backend?". In 6.0 the Rails app loses its admin UI entirely and becomes a thin REST API plus workers, making the name even more misleading.

  2. The 5.4 plan reserved apps/admin/ for the future dashboard. That name collides with the legacy Rails spree/admin engine and with @spree/dashboard, the package name used everywhere else in the monorepo. We should land on one name and use it consistently.

  3. No way to add the dashboard after the fact. A user who skipped the dashboard at scaffolding time (or generated their project before the dashboard existed) has no command to bolt it on. They have to hand-assemble the dashboard app — copy a starter, write .env.local, install deps — none of which is hard, but it's exactly the kind of glue create-spree-app already automates for the storefront.

  4. No upgrade path for existing 5.4 projects. Anyone who generated a project with 5.4's create-spree-app has backend/ baked into Dockerfiles, CI configs, and shell habits. Without a migration command, every user has to do the rename by hand when they want to align with the new layout.

Key Decisions (do not deviate without discussion)

  • api/ is the new name for the Rails app directory in generated projects (not backend/, not server/). Pairs with apps/dashboard/ + apps/storefront/ — three peers, three roles.
  • apps/dashboard/ is the canonical location for the React SPA admin (not apps/admin/, not packages/dashboard/). Matches the @spree/dashboard package name and avoids legacy spree/admin collision. Supersedes the apps/admin/ reference in 5.4-spree-starter-and-create-spree-app.md.
  • apps/ holds JS/TS deployables; api/ stays at the root. Don't move the Rails app under apps/api/. It's qualitatively different (different language, different runtime, different CLI surface via spree console/spree logs) and keeping it at the root signals "this is the core; the JS apps consume it."
  • The dashboard is optional, like the storefront. (Revised during the preview: the dashboard is hidden from the interactive prompts — a yes/no question reads as a recommendation while it's still WIP — and included only via the --react-dashboard flag, or later via spree add dashboard. The prompt returns when it's ready for prime time.)
  • @spree/dashboard publishes to npm and exports the app shell. The package exports a <Dashboard /> component (provider stack + router, bootstrap side-effects included); hosts own index.html, main.tsx, styles.css, and the Vite config. Distribution is source-only, same model as dashboard-core/dashboard-ui: the host's Vite compiles the shell, which keeps TanStack Router's generated route types first-class in the host program. Consequence: shell source uses relative imports only — no @/ alias in anything a host compiles.
  • The dashboard template is embedded in the npm packages, not a template repo. Canonical source lives in the monorepo at packages/dashboard-starter; at build time @spree/cli and create-spree-app render a standalone copy into their tarballs (workspace:^ rewritten to published versions, monorepo-only devDependencies stripped). (Superseded an earlier spree/dashboard-starter GitHub template-repo design — see "Dashboard starter" under Design Details for the rationale and the 6.0 GA option.) In the workspace, the starter doubles as the live consumer test — it compiles <Dashboard /> from source and activates @spree/dashboard-plugin-example through real auto-discovery.
  • The dashboard scaffold carries no credentials. apps/dashboard/.env.local holds only VITE_SPREE_API_URL. Admins sign in with email/password (JWT in memory + httpOnly refresh cookie, per 5.5-admin-auth-cookie-refresh.md). Anything VITE_-prefixed is compiled into the client bundle, so a secret key there would ship to every browser — the scaffold and spree add dashboard never mint keys.
  • Rename ships day-one in 5.6 for new projects. npx create-spree-app on 5.6+ produces api/ directly. We don't ship 5.6 with the dashboard inside backend/ only to rename in 6.0 — that'd give the Developer Preview audience a layout we know will change. Get it right once.
  • Existing projects are NOT auto-migrated in 5.6. They keep backend/ and continue to work. The CLI detects the layout per-command (see detectApiDir in Design Details). Users opt in to the rename via spree upgrade layout on their own schedule.
  • Dual-layout support is bounded. 5.6 and the 5.x line support both layouts; 6.0 GA drops backend/ detection. Users have a full minor-version cycle to run spree upgrade layout before it's required.
  • Two new CLI commands ship in 5.6:
    • spree add dashboard — clone dashboard-starter into apps/dashboard/, mint API keys, write .env.local, install deps. Idempotent (re-running with apps/dashboard/ present is a no-op with a warning).
    • spree upgrade layout — rename backend/api/, rewrite compose files, README, CI, .env. Refuses to run with a dirty working tree. Commits as a single atomic commit.
  • spree add dashboard works on both layouts in 5.6. No need to upgrade first — it detects whether the Rails dir is backend/ or api/ and configures accordingly. (spree upgrade layout is independent of add dashboard.)
  • 5.6 release notes call out the rename for new projects only. Existing-project users see no surprise — their CLI keeps working unchanged. The upgrade is presented as an optional alignment step, not a forced migration.

Design Details

Target generated layout (5.6, new projects)

my-shop/
├── api/                       ← Rails (API + admin engine in 5.6, workers); renamed from backend/
│   ├── Gemfile
│   ├── Dockerfile
│   ├── config/
│   ├── app/
│   └── ...
├── apps/
│   ├── dashboard/             ← new: React SPA (Vite, @spree/admin-sdk) — Developer Preview in 5.6
│   │   ├── package.json
│   │   ├── vite.config.ts
│   │   ├── src/
│   │   └── .env.local         ← VITE_SPREE_API_URL + admin secret key
│   └── storefront/            ← unchanged: Next.js (optional)
│       ├── package.json
│       └── ...
├── docker-compose.yml         ← prebuilt image by default
├── docker-compose.dev.yml     ← context: ./api (after eject)
├── package.json
├── README.md
├── .env                       ← SECRET_KEY_BASE, PORT
└── .gitignore

Why api/ at the root, not apps/api/:

  • Different language/runtime/deploy story than the JS apps.
  • The CLI surfaces it as a singleton (spree console, spree logs → implicitly mean "the api").
  • Visually signals the hub-and-spoke relationship: one API, multiple consumers.

Why api/ over server/ or backend/

NameReads asTS-dev expectationVerdict
backend/"the other thing, opposed to frontend"Generic, legacy-feeling; ambiguous once 2+ frontends exist
server/"a server doing some stuff"Implies generality (SSR, websockets, etc.); doesn't tell you what's served
api/"this is the API surface"Matches Vercel/Turborepo/T3 conventions for API-only services; instantly legible

Even in 5.6, where Rails still serves the legacy spree/admin engine, the contract exposed to JS consumers is the REST API. By 6.0 the admin engine is gone and the directory contents perfectly match the name. Sidekiq workers (webhook delivery, event processing) are part of that API surface from any external observer's perspective. The directory name should describe the role to the outside world, not the internal process model.

Dual-layout detection (detectApiDir)

In 5.6 the CLI must work against both backend/ (pre-5.6 projects, unmigrated) and api/ (new 5.6+ projects, or migrated older projects). One helper centralizes the detection so individual commands don't repeat the check.

typescript
// packages/cli/src/context.ts (extension)

export type ApiDirName = 'api' | 'backend'

export function detectApiDir(projectDir: string): ApiDirName {
  if (fs.existsSync(path.join(projectDir, 'api'))) return 'api'
  if (fs.existsSync(path.join(projectDir, 'backend'))) return 'backend'
  throw new Error(
    'Neither api/ nor backend/ found. Is this a Spree project?',
  )
}

// Extend ProjectContext
export interface ProjectContext {
  mode: 'docker'
  projectDir: string
  port: number
  apiDir: ApiDirName  // ← new
}

export function detectProject(cwd: string = process.cwd()): ProjectContext {
  // ... existing checks ...
  return {
    mode: 'docker',
    projectDir: cwd,
    port,
    apiDir: detectApiDir(cwd),
  }
}

Usage pattern: every command that references the Rails directory uses ctx.apiDir instead of a hardcoded string. Example: spree eject builds path.join(ctx.projectDir, ctx.apiDir) and looks for docker-compose.dev.yml whose context: already matches whatever the project was scaffolded with (compose files don't need rewriting — they were written correctly at scaffold time).

Deprecation timeline:

  • 5.6: both layouts supported; new projects scaffold as api/; spree upgrade layout available.
  • 5.x: same. No CLI warning yet — users on backend/ see no nag.
  • 6.0 RC: spree dev (and other commands) print a deprecation warning when backend/ is detected, pointing to spree upgrade layout.
  • 6.0 GA: detectApiDir drops the backend/ branch. Old-layout projects must run spree upgrade layout before any CLI command works.

Dashboard starter (packages/dashboard-starter, embedded at build time)

Canonical source in the monorepo at packages/dashboard-starter (doubles as the plugin pipeline's CI consumer test). Distribution is Vendure-style npm embedding, not a template repo: at build time, @spree/cli runs scripts/sync-dashboard-starter.mjs to render a standalone copy into its templates/dashboard-starter/ (gitignored, shipped in the tarball via the dist copy) — rewriting workspace:^ deps to the published versions, stripping monorepo-only devDependencies, inlining the workspace biome.json, and shipping .gitignore as gitignore.template (npm never packs .gitignore; the scaffolder renames it back). prepublishOnly rebuilds on publish, so the template's version pins are in lockstep with every CLI release by construction — no repo to drift, no sync token, no 404 window. The CLI is the single template source: create-spree-app doesn't embed a copy — its dashboard phase delegates to the project-local npx spree add dashboard (the generated project already depends on @spree/cli, installed with root deps before the phase runs, and the command reads the API port from the project .env). Decision informed by a Medusa/Vendure comparison: Vendure embeds templates in @vendure/create and gets alignment for free; Medusa's separate starter repo needs a scheduled bot to stay current. A public spree/dashboard-starter template repo (for browsability / "Use this template") can be added at 6.0 GA — the sync script takes any target directory, so it would reuse this machinery verbatim. The scaffolded app:

dashboard-starter/
├── package.json               # depends on @spree/dashboard, @spree/admin-sdk
├── vite.config.ts             # proxies /api/* to localhost:3000 in dev
├── tsconfig.json
├── biome.json
├── .env.example
├── .gitignore
├── README.md
├── index.html
├── public/
└── src/
    ├── main.tsx               # renders <Dashboard /> from @spree/dashboard
    ├── plugins.ts             # user's defineDashboardPlugin() registrations
    └── styles.css

The starter is intentionally minimal — @spree/dashboard is the actual app shell; the starter is the user's customization point for plugins, theming, and deployment config. Same boundary as spree-starter vs the Spree gems. Notes:

  • No TanStack Router plugin in the starter's Vite config — the route tree ships pre-generated inside @spree/dashboard. The starter needs only spreeDashboardPlugin() + react().
  • spreeDashboardPlugin() scans @spree/dashboard for Tailwind classes when it's installed (optional — custom dashboards built directly on dashboard-core/dashboard-ui skip it silently).
  • In the monorepo, the starter carries @spree/dashboard-plugin-example as a devDependency, so booting it exercises the full discovery → virtual:spree-dashboard-plugins → registration chain; the sync job strips that dep from the published template.

.env.local generated for dashboard

bash
VITE_SPREE_API_URL=http://localhost:3000

No credentials. The dashboard authenticates admins interactively — email/password exchanged for a JWT held in memory plus an httpOnly refresh cookie (5.5-admin-auth-cookie-refresh.md). VITE_-prefixed variables are baked into the client bundle at build time, so secrets must never appear in this file. Integrations that need a secret API key mint one with spree api-key create, independent of the dashboard scaffold.

Single-node hosting (default deployment) (implemented: Rails host + basepath)

The default production topology is single node: the Spree server serves the built dashboard at /dashboard, same origin as the Admin API — no CORS, SameSite=Lax cookies, one deployable. The pieces:

  • Rails host (implemented): the spree_dashboard gem (opt-in via Gemfile; the successor slot to spree_admin at 6.0) serves Spree::Dashboard.dist_path (env fallback SPREE_DASHBOARD_DIST_PATH) at /dashboard with SPA semantics — path fallback to index.html (no-cache), assets/ immutable, traversal-guarded, 404 when unconfigured. Deliberately host-only for the preview: vendoring the prebuilt stock dist inside the gem (vendor/dist fallback — zero-toolchain admin for gems-only Rails apps, dashboard version locked to the gem release) is an explicit 6.0 GA decision, when the release cadences converge and the stock-admin merchant segment matters; during the preview it would couple fast dashboard iteration to gem releases for an audience that customizes immediately.
  • Basepath (implemented): builds take VITE_BASE_PATH=/dashboard/ (Vite base), and createDashboardRouter(routeTree, { basepath: import.meta.env.BASE_URL }) keeps the router aligned. VITE_SPREE_API_URL stays unset → the SDK emits origin-relative URLs, so one bundle works on every host/environment with no per-env rebuild.
  • Official image (pending, spree-starter repo): a multi-stage Dockerfile build — a Node stage extracts the stock dashboard template from the published @spree/cli tarball, pnpm installs the pinned @spree/dashboard* packages from npm, builds with VITE_BASE_PATH=/dashboard/; the final Rails stage copies only dist/ and sets SPREE_DASHBOARD_DIST_PATH. No Node in the final image. Known caveat: the template pins ^0.x carets with no lockfile, so image rebuilds can drift across dashboard patch releases — acceptable for the preview; exact-version build args are the later mitigation.
  • Customized dashboards in self-built images (implemented): the starter Dockerfile selects its dashboard stage via ARG DASHBOARD_SOURCE (stock default extracts the CLI-embedded template; custom builds a dashboard-src named build context — BuildKit resolves stages lazily, so plain docker build backend/ needs no extra flags and never evaluates the custom stage). spree build --production wires it: detects apps/dashboard, stages a filtered copy (named contexts don't apply .dockerignore) and passes the build-arg + context; warns when the Dockerfile predates dashboard support instead of silently baking stock over a customized app; projects without apps/dashboard build identically to plain docker build backend/. Note spree eject needed no changes — it drives the dev compose (target: dev), where the dashboard runs on Vite, not from a baked dist.
  • Render (implemented): spree add dashboard amends the project Blueprint's backend service instead of adding a static site — appends the dashboard build to buildCommand (Render's Ruby runtime includes Node; the repo is fully cloned even with rootDir: backend) and sets SPREE_DASHBOARD_DIST_PATH=../apps/dashboard/dist. Single service, builds the user's customized dashboard, zero manual steps. Unrecognized (hand-edited) Blueprints are left untouched with a docs pointer.
  • 6.0: the SPA stays at /dashboard permanently — /admin remains reserved for the Classic Admin (whether it retires or someone keeps maintaining it), so the dashboard URL is stable across upgrades with no route migration and no collision. spree_dashboard still becomes the default admin delivery at 6.0; only the path never changes. The static/CDN topology (see the deployment docs) remains the alternative for customized dashboards on independent release cadences.

Scaffolding flow changes (create-spree-app)

Prompts (interactive):

✔ Project name … my-shop
✔ Include the storefront (Next.js)? … Yes
✔ Include the dashboard (React SPA admin)? … Yes  ← new
✔ Start Docker services now? … Yes

Flags (non-interactive):

bash
npx create-spree-app my-shop \
  --storefront \
  --dashboard \           # new
  --no-start

Defaults (revised during the preview): dashboard excluded unless --react-dashboard is passed — no prompt while it's WIP.

New scaffolding phase (runs after the optional storefront phase):

typescript
// scaffold.ts (excerpt — new phase 2b: dashboard)
if (dashboard) {
  s.start('Downloading dashboard template...')
  await downloadDashboard(projectDir)
  s.stop('Dashboard template downloaded.')

  // Point the dashboard at the API. No credentials — admins sign in
  // interactively; see "`.env.local` generated for dashboard" above.
  writeDashboardEnv(projectDir, port)

  s.start('Installing dashboard dependencies...')
  await installDashboardDeps(projectDir, options.packageManager)
  s.stop('Dashboard dependencies installed.')
}

Mirror file: src/dashboard.ts following src/storefront.ts shape exactly.

Constants additions:

typescript
// src/constants.ts
export const DASHBOARD_PORT = 5173
// (no DASHBOARD_REPO — the template is embedded in the package at build time)
// rename:
export const API_REPO = 'https://github.com/spree/spree-starter.git'  // was BACKEND_REPO

spree add dashboard command (new)

For existing projects that didn't include the dashboard at scaffolding time.

typescript
// packages/cli/src/commands/add.ts
program
  .command('add <thing>')
  .description('Add an optional component to your project (dashboard, storefront)')
  .action(async (thing: string) => {
    const ctx = detectProject()

    switch (thing) {
      case 'dashboard':
        return addDashboard(ctx)
      case 'storefront':
        return addStorefront(ctx)
      default:
        console.error(`Unknown component: ${thing}`)
        process.exit(1)
    }
  })

async function addDashboard(ctx: ProjectContext) {
  // 1. Idempotency check
  const dashboardDir = join(ctx.projectDir, 'apps', 'dashboard')
  if (existsSync(dashboardDir)) {
    log.warn('apps/dashboard/ already exists. Nothing to do.')
    return
  }

  // 2. Clone starter
  await execa('git', ['clone', '--depth', '1', DASHBOARD_REPO, dashboardDir])
  rmSync(join(dashboardDir, '.git'), { recursive: true, force: true })

  // 3. Write .env.local — just the API URL; the dashboard talks to Rails
  //    over HTTP and admins sign in interactively (no keys minted).
  writeDashboardEnv(ctx.projectDir, ctx.port)

  // 4. Install deps (package manager detected from project root)
  const pm = detectPackageManager(ctx.projectDir)
  await execa(pm, ['install'], { cwd: dashboardDir, stdio: 'inherit' })

  // 5. Note — suggest the layout upgrade if user is still on backend/
  const note = [
    `Dashboard added at ${pc.bold('apps/dashboard/')}`,
    '',
    `Start it with:`,
    `  ${pc.cyan(`cd apps/dashboard && ${pm} run dev`)}`,
    '',
    `Then open ${pc.bold(`http://localhost:${DASHBOARD_PORT}`)}`,
  ]
  if (ctx.apiDir === 'backend') {
    note.push(
      '',
      pc.dim('Tip: align with the new layout via `npx spree upgrade layout`.'),
    )
  }
  p.note(note.join('\n'), 'Dashboard added!')
}

Why no layout-upgrade gate: the dashboard is a separate Node app that talks to Rails over HTTP. It doesn't care whether the Rails directory is backend/ or api/. Forcing the rename first would be friction without benefit.

Idempotency rules:

  • If apps/dashboard/ exists → warn and exit cleanly (no-op).
  • If apps/dashboard/.env.local is missing but the directory exists → write .env.local and exit (recovery mode). User can opt in by running spree add dashboard --force to regenerate.

spree upgrade layout command (new)

Renames backend/api/ and rewrites every file that references the old name.

typescript
// packages/cli/src/commands/upgrade.ts
program
  .command('upgrade <target>')
  .description('Upgrade your project to a newer layout or version')
  .action(async (target: string) => {
    if (target !== 'layout') {
      console.error(`Unknown upgrade target: ${target}`)
      process.exit(1)
    }
    await upgradeLayout(detectProject())
  })

async function upgradeLayout(ctx: ProjectContext) {
  // 1. Refuse if already on new layout
  if (existsSync(join(ctx.projectDir, 'api'))) {
    log.warn('Project already uses the api/ layout. Nothing to do.')
    return
  }
  if (!existsSync(join(ctx.projectDir, 'backend'))) {
    log.error('No backend/ directory found. Is this a Spree project?')
    process.exit(1)
  }

  // 2. Refuse if working tree is dirty
  const dirty = await isGitDirty(ctx.projectDir)
  if (dirty) {
    log.error('Working tree has uncommitted changes.')
    log.info('Commit or stash before running upgrade so the rename is a clean diff.')
    process.exit(1)
  }

  // 3. Stop any running containers (compose paths will change)
  if (await isAnyContainerRunning(ctx)) {
    log.info('Stopping running containers...')
    await dockerCompose(['down'], ctx.projectDir)
  }

  // 4. Git mv (preserves history)
  await execa('git', ['mv', 'backend', 'api'], { cwd: ctx.projectDir })

  // 5. Rewrite text files with backend/ references
  const filesToRewrite = [
    'docker-compose.yml',
    'docker-compose.dev.yml',
    'README.md',
    'package.json',
    '.env',
    '.env.example',
    '.gitignore',
    'CLAUDE.md',
    'AGENTS.md',
    ...(await findCiWorkflows(ctx.projectDir)),
  ]
  for (const file of filesToRewrite) {
    const abs = join(ctx.projectDir, file)
    if (!existsSync(abs)) continue
    const original = readFileSync(abs, 'utf-8')
    const updated = rewriteBackendReferences(original)
    if (updated !== original) {
      writeFileSync(abs, updated)
      await execa('git', ['add', file], { cwd: ctx.projectDir })
    }
  }

  // 6. Stage everything and commit as one atomic commit
  await execa('git', ['add', '-A'], { cwd: ctx.projectDir })
  await execa('git', [
    'commit',
    '-m',
    'Upgrade project layout: backend/ → api/\n\nGenerated by `npx spree upgrade layout`.',
  ], { cwd: ctx.projectDir })

  p.note(
    [
      `Layout upgraded. Review the commit:`,
      `  ${pc.cyan('git show HEAD')}`,
      '',
      `To restart services with the new layout:`,
      `  ${pc.cyan('npx spree dev')}`,
      '',
      `Want the dashboard too? Run:`,
      `  ${pc.cyan('npx spree add dashboard')}`,
    ].join('\n'),
    'Upgrade complete!',
  )
}

function rewriteBackendReferences(content: string): string {
  return content
    // docker-compose: context: ./backend → context: ./api
    .replace(/context:\s*\.\/backend(?=\s|$)/g, 'context: ./api')
    // Path references in scripts, README, CI: backend/ → api/
    .replace(/\bbackend\//g, 'api/')
    // Standalone "backend" word references in prose
    .replace(/\bbackend\b(?=\s+(directory|folder|Dockerfile|Gemfile))/g, 'api')
}

Safety:

  • Dirty tree refuses. No way to lose uncommitted work to an automated rename.
  • Single commit. User can git reset --hard HEAD~1 to undo.
  • Stops containers first. Avoids docker-compose getting confused mid-rename.
  • git mv preserves history. Doesn't matter for compose paths but matters for anyone who customized files under backend/.
  • Does not touch apps/, node_modules/, .git/ or anything outside the predefined file list.

What it does NOT do:

  • Doesn't run git push — user reviews and pushes themselves.
  • Doesn't restart services — the success note tells them to.
  • Doesn't update upstream dependencies. Pure rename, nothing else.

CLI surface summary

CommandPurposeNew in 5.6?
spree devStart servicesNo (uses ctx.apiDir internally)
spree stopStop servicesNo
spree ejectSwitch to local Rails buildNo (uses ctx.apiDir internally)
spree consoleRails console in api/backend containerNo
spree logs [worker]Stream logsNo
spree updatePull latest image + migrateNo
spree seedRun seedsNo
spree sample-dataLoad sample dataNo
spree user createCreate admin userNo
spree api-key create/list/revokeManage API keysNo
spree initBootstrap a fresh projectNo
spree add dashboardAdd dashboard to existing project (works on both layouts)Yes
spree add storefrontAdd storefront to existing projectYes (parity)
spree upgrade layoutRename backend/api/Yes

File rename map (across the toolchain)

The change touches several packages. Single-pass refactor:

packages/create-spree-app/:

  • src/backend.tssrc/api.ts (function downloadBackenddownloadApi)
  • src/constants.tsBACKEND_REPOAPI_REPO; add DASHBOARD_REPO, DASHBOARD_PORT
  • src/scaffold.ts — every backend/ path → api/; new dashboard phase
  • src/templates/readme.ts — all references; add Dashboard section
  • src/templates/claude-md.ts, env.ts, gitignore.ts, package-json.ts — same rename
  • src/prompts.ts — add dashboard prompt
  • src/types.ts — add dashboard: boolean to ScaffoldOptions
  • New: src/dashboard.ts (mirror of src/storefront.ts)
  • tests/ — update fixtures and assertions

New projects scaffold straight to api/. There is no dual-layout output mode in create-spree-app — it's "new projects always use api/". The dual-layout work is entirely in the CLI (which has to handle pre-existing 5.4 projects).

packages/cli/:

  • src/context.ts — add detectApiDir(projectDir) helper; extend ProjectContext with apiDir: 'api' | 'backend'; detectProject() populates it.
  • src/commands/eject.ts — replace hardcoded 'backend' with ctx.apiDir. Error message: No ${ctx.apiDir}/ directory found.
  • src/commands/init.ts — same pattern.
  • Any other command that references the Rails directory by name — same pattern.
  • New: src/commands/add.ts (with addDashboard, addStorefront).
  • New: src/commands/upgrade.ts (with upgradeLayout).
  • src/index.ts — register add and upgrade commands.
  • src/lib/ — new helpers: isGitDirty, findCiWorkflows, rewriteBackendReferences, detectPackageManager.

spree/spree-starter (separate repo):

  • No structural changes needed — it's the contents of api/, not the directory name.
  • README references to "the backend" → "the API server" or just "this app".

packages/dashboard-starter (new package, embedded at build time):

  • Canonical source in the monorepo, following the shape above, with workspace:^ deps.
  • Rendered standalone into the @spree/cli tarball at build time (pins stamped from the versions being released); create-spree-app delegates to the project-local CLI. No separate repo; a public template repo remains an option at 6.0 GA.

Migration Path

All phases ship in 5.6. The dashboard ships as Developer Preview alongside @spree/admin-sdk@next. Versioning rolls forward to ^6.0.0 at 6.0 GA.

Phase 1: Add dual-layout detection to CLI

  1. Implement detectApiDir(projectDir) in packages/cli/src/context.ts.
  2. Extend ProjectContext with apiDir: 'api' | 'backend'.
  3. Refactor existing commands (eject, init, anything that names the Rails dir) to read ctx.apiDir instead of a hardcoded string.
  4. Unit test against fixture projects for both layouts.
  5. Ship this first — it's the prerequisite for everything else. Without it, the rename in Phase 2 would break existing-project CLI usage.

Phase 2: Export the shell from @spree/dashboard (implemented)

  1. Rewrite the package's internal @/ alias imports to relative paths — a source-only library can't ship alias-dependent source (the host's Vite has no mapping for it, and hosts need @/ for their own code).
  2. Add src/dashboard.tsx exporting <Dashboard />: the provider stack + RouterProvider, with the bootstrap side-effect imports (i18n, default nav, search) inside the module. The package's own main.tsx becomes the first consumer (dev harness).
  3. Add an exports map to package.json (. → shell export, ./styles.css).
  4. Teach spreeDashboardPlugin() to scan @spree/dashboard for Tailwind classes when installed (optional resolution — silent for custom dashboards without the shell).

Phase 3: Create packages/dashboard-starter (implemented, incl. build-time embedding)

  1. Scaffold the thin host in the monorepo per the shape above, with workspace:^ deps. It compiles <Dashboard /> from source and (dev-only) activates @spree/dashboard-plugin-example through auto-discovery — the standing consumer test for the plugin pipeline.
  2. Write minimal Vite config that proxies to localhost:3000 in dev. The proxy path is irrelevant to the Rails directory name — it's an HTTP URL.
  3. Write README covering: dev workflow, installing plugins, plugins.ts customizations, deploying.
  4. Embed the rendered template in the @spree/cli tarball at build time (implemented — scripts/sync-dashboard-starter.mjs runs in the CLI's build script; prepublishOnly guarantees fresh pins at publish; .gitignore ships as gitignore.template because npm never packs .gitignore files, and the scaffolder renames it back). create-spree-app carries no copy — its dashboard phase runs the project-local npx spree add dashboard, keeping the CLI the single template source. This superseded the earlier template-repo + CI-sync-job design after comparing Medusa (separate starter repo kept current by a scheduled bot) and Vendure (templates embedded in @vendure/create, lockstep for free). Remaining manual step for the preview: the first npm publish of @spree/dashboard{,-ui,-core} (Trusted Publishing can only be configured once a package exists).
  5. (Deferred to 6.0 GA, optional) Public spree/dashboard-starter template repo for browsability — reuse the same script with the repo as a second target, plus CI on that repo.

Phase 4: Rename backend/api/ in create-spree-app (new projects only)

  1. Update all source files per "File rename map" above.
  2. Update tests — fixtures, snapshots, integration assertions.
  3. Update README of create-spree-app package itself.
  4. Add changeset (minor bump — output layout changes, but no API/import contract is broken; existing CLI install continues to work against either layout).

Phase 5: Add dashboard scaffolding to create-spree-app (implemented)

  1. Implement src/dashboard.ts mirroring src/storefront.ts.
  2. Add prompt + flag in src/prompts.ts and src/index.ts.
  3. Update src/scaffold.ts to invoke the dashboard phase after the storefront phase.
  4. Update README, CLAUDE.md, and Dependabot templates with Dashboard sections.
  5. Update tests.

Phase 6: Add spree add and spree upgrade commands to CLI (add dashboard implemented)

  1. Implement commands/add.ts with addDashboard and addStorefront.
  2. Implement commands/upgrade.ts with upgradeLayout.
  3. Add helpers: isGitDirty, findCiWorkflows, rewriteBackendReferences, detectPackageManager.
  4. Register both commands in src/index.ts.
  5. Update CLI README + --help examples.
  6. Add unit tests for rewriteBackendReferences (pure function, easy to cover).
  7. Add integration test that runs upgrade layout against a fixture project.

Phase 7: Documentation

  1. Update docs/developer/getting-started/ to show new layout (use api/ in examples).
  2. Update docs/developer/customization/ references.
  3. Add a release-notes section in the 5.6 release notes:
    • "Dashboard Developer Preview" (the headline)
    • "New project layout: api/" (call out for newcomers)
    • "Existing projects: optional upgrade via spree upgrade layout" (set expectations — not forced)
  4. Update 5.4-spree-starter-and-create-spree-app.md with a banner noting the 5.6 rename and pointer to this plan.

Phase 8: Smoke test the full flow

Manual QA checklist:

  • npx create-spree-app fresh --react-dashboard --storefront produces working project with api/ + apps/dashboard/ + apps/storefront/.
  • npx create-spree-app fresh --no-storefront produces api-only project (single api/ directory at root).
  • npx create-spree-app fresh --react-dashboard --no-storefront works.
  • In a 5.4-generated project (with backend/): all existing commands (spree dev, spree console, spree eject) keep working unchanged.
  • In the same 5.4 project: npx spree add dashboard clones, mints key, installs, dashboard boots and connects to API — without forcing the layout upgrade first.
  • In the same 5.4 project: npx spree upgrade layout succeeds, commit looks clean, npx spree dev boots after rename.
  • After upgrade: apps/dashboard/.env.local still points at the API correctly (no rewrite needed — it's an HTTP URL).
  • npx spree upgrade layout refuses on a dirty tree.
  • npx spree upgrade layout is a no-op on an already-upgraded project.
  • npx spree add dashboard is idempotent (second run is a no-op).
  • In 5.6 RC: confirm no deprecation warning fires on backend/ layouts. (Warnings ship in 6.0 RC, not 5.6.)

Constraints on Current Work

  • Stop using apps/admin/ in new docs, comments, or plans. The dashboard lives at apps/dashboard/. The 5.4 plan's apps/admin/ reference is superseded by this plan.
  • Use api/ in new user-facing documentation that targets 5.6+. For docs that span pre-5.6 and 5.6+ (release notes, migration guides), either say "the Rails directory" generically or call out both names explicitly. Don't hardcode backend/ as the permanent name.
  • Every new CLI command that touches the Rails directory must use ctx.apiDir, never a hardcoded 'backend' or 'api' string. This is non-negotiable for 5.6 because both layouts coexist.
  • New create-spree-app templates and prompts must read the dashboard flag even if dashboard scaffolding isn't fully wired up yet — so the option exists from the moment the dashboard-starter repo ships.
  • The @spree/dashboard package must keep its current name. Renaming it would invalidate the entire "dashboard" naming convergence.
  • No secrets in apps/dashboard/ env files. The dashboard scaffold writes only VITE_SPREE_API_URL; VITE_-prefixed values are compiled into the client bundle. No command may write an sk_ key into any file the dashboard build reads.
  • spree upgrade layout must not run automatically as part of any other command. Even if a user runs spree update and we know they're on backend/, do not silently rename. Layout migration is opt-in only.

Open Questions

  1. spree upgrade layout and user customizations. If the user has added files under backend/ (custom decorators, gems, etc.), git mv backend api moves them along. But if they've referenced backend/ paths in their own code (e.g., a script that does cd backend && rails c), we won't catch that. Should spree upgrade layout:

    • (a) Scan and warn about likely user references (grep for \bbackend/ in user files), or
    • (b) Just do the rename and leave it to the user.
    • Leaning toward (a) with a "review these files manually" report at the end.
  2. spree add storefront symmetry. This plan proposes it for symmetry with add dashboard, but the storefront has been around since 5.4. Is there demand for adding the storefront to an existing api-only project, or is it always present? If demand is low, we could ship add dashboard first and defer add storefront.

    • Leaning toward shipping both — symmetry matters for the mental model ("if I can add a dashboard, I can add a storefront").
  3. Should spree upgrade be a single command with subcommands or spree upgrade <target>? This plan uses spree upgrade layout, leaving room for spree upgrade gems, spree upgrade image, etc. later. Alternative: spree upgrade --layout flag-style. Subcommand form scales better.

  4. When exactly does backend/ support get dropped? The plan says "by 6.0 GA". Open: do we want a 5.7 deprecation warning step in between, or jump straight from "silent dual-support in 5.6" to "warning in 6.0 RC" to "removed in 6.0 GA"? The 5.6/6.0 cycle is short enough that one warning step at 6.0 RC is probably enough.

  5. Build-time typed route composition — resolved and implemented; see 5.6-dashboard-typed-plugin-routes.md.

References

  • Supersedes (partial): docs/plans/5.4-spree-starter-and-create-spree-app.md — specifically the apps/admin/ reference (now apps/dashboard/) and the backend/ directory name (now api/ in 5.6+ generated projects).
  • Depends on: docs/plans/6.0-admin-spa.md — defines @spree/dashboard, @spree/dashboard-core, @spree/dashboard-ui packages and the defineDashboardPlugin API consumed by the starter.
  • Related: docs/plans/5.5-admin-api-key-scopes.md — scope schema for the secret key minted by spree add dashboard.
  • Related: docs/plans/5.5-admin-auth-cookie-refresh.md — auth model the dashboard uses against the API.
  • Current packages/create-spree-app/ — source layout to modify.
  • Current packages/cli/src/commands/eject.ts — pattern for new commands; needs ctx.apiDir refactor.
  • Current packages/cli/src/context.ts — extended with detectApiDir and apiDir field on ProjectContext.
  • spree/spree-starter repo — existing template, no structural change needed.
  • No new repos: the dashboard template is embedded in the @spree/cli / create-spree-app tarballs (an optional public spree/dashboard-starter template repo is deferred to 6.0 GA).