docs/plans/5.6-project-layout-and-dashboard.md
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
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.
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.
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.
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.
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.
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."--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.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.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.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.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.backend/ detection. Users have a full minor-version cycle to run spree upgrade layout before it's required.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.)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/:
spree console, spree logs → implicitly mean "the api").api/ over server/ or backend/| Name | Reads as | TS-dev expectation | Verdict |
|---|---|---|---|
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.
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.
// 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:
api/; spree upgrade layout available.backend/ see no nag.spree dev (and other commands) print a deprecation warning when backend/ is detected, pointing to spree upgrade layout.detectApiDir drops the backend/ branch. Old-layout projects must run spree upgrade layout before any CLI command works.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:
@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).@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 dashboardVITE_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.
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:
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.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.@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.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.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./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.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):
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):
// 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:
// 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.
// 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:
apps/dashboard/ exists → warn and exit cleanly (no-op).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.
// 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:
git reset --hard HEAD~1 to undo.git mv preserves history. Doesn't matter for compose paths but matters for anyone who customized files under backend/.apps/, node_modules/, .git/ or anything outside the predefined file list.What it does NOT do:
git push — user reviews and pushes themselves.| Command | Purpose | New in 5.6? |
|---|---|---|
spree dev | Start services | No (uses ctx.apiDir internally) |
spree stop | Stop services | No |
spree eject | Switch to local Rails build | No (uses ctx.apiDir internally) |
spree console | Rails console in api/backend container | No |
spree logs [worker] | Stream logs | No |
spree update | Pull latest image + migrate | No |
spree seed | Run seeds | No |
spree sample-data | Load sample data | No |
spree user create | Create admin user | No |
spree api-key create/list/revoke | Manage API keys | No |
spree init | Bootstrap a fresh project | No |
spree add dashboard | Add dashboard to existing project (works on both layouts) | Yes |
spree add storefront | Add storefront to existing project | Yes (parity) |
spree upgrade layout | Rename backend/ → api/ | Yes |
The change touches several packages. Single-pass refactor:
packages/create-spree-app/:
src/backend.ts → src/api.ts (function downloadBackend → downloadApi)src/constants.ts — BACKEND_REPO → API_REPO; add DASHBOARD_REPO, DASHBOARD_PORTsrc/scaffold.ts — every backend/ path → api/; new dashboard phasesrc/templates/readme.ts — all references; add Dashboard sectionsrc/templates/claude-md.ts, env.ts, gitignore.ts, package-json.ts — same renamesrc/prompts.ts — add dashboard promptsrc/types.ts — add dashboard: boolean to ScaffoldOptionssrc/dashboard.ts (mirror of src/storefront.ts)tests/ — update fixtures and assertionsNew 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.src/commands/add.ts (with addDashboard, addStorefront).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):
api/, not the directory name.packages/dashboard-starter (new package, embedded at build time):
workspace:^ deps.@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.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.
detectApiDir(projectDir) in packages/cli/src/context.ts.ProjectContext with apiDir: 'api' | 'backend'.eject, init, anything that names the Rails dir) to read ctx.apiDir instead of a hardcoded string.@spree/dashboard (implemented)@/ 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).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).exports map to package.json (. → shell export, ./styles.css).spreeDashboardPlugin() to scan @spree/dashboard for Tailwind classes when installed (optional resolution — silent for custom dashboards without the shell).packages/dashboard-starter (implemented, incl. build-time embedding)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.localhost:3000 in dev. The proxy path is irrelevant to the Rails directory name — it's an HTTP URL.plugins.ts customizations, deploying.@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).spree/dashboard-starter template repo for browsability — reuse the same script with the repo as a second target, plus CI on that repo.backend/ → api/ in create-spree-app (new projects only)create-spree-app package itself.src/dashboard.ts mirroring src/storefront.ts.src/prompts.ts and src/index.ts.src/scaffold.ts to invoke the dashboard phase after the storefront phase.spree add and spree upgrade commands to CLI (add dashboard implemented)commands/add.ts with addDashboard and addStorefront.commands/upgrade.ts with upgradeLayout.isGitDirty, findCiWorkflows, rewriteBackendReferences, detectPackageManager.src/index.ts.--help examples.rewriteBackendReferences (pure function, easy to cover).upgrade layout against a fixture project.docs/developer/getting-started/ to show new layout (use api/ in examples).docs/developer/customization/ references.api/" (call out for newcomers)spree upgrade layout" (set expectations — not forced)5.4-spree-starter-and-create-spree-app.md with a banner noting the 5.6 rename and pointer to this plan.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.backend/): all existing commands (spree dev, spree console, spree eject) keep working unchanged.npx spree add dashboard clones, mints key, installs, dashboard boots and connects to API — without forcing the layout upgrade first.npx spree upgrade layout succeeds, commit looks clean, npx spree dev boots after rename.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).backend/ layouts. (Warnings ship in 6.0 RC, not 5.6.)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.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.ctx.apiDir, never a hardcoded 'backend' or 'api' string. This is non-negotiable for 5.6 because both layouts coexist.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.@spree/dashboard package must keep its current name. Renaming it would invalidate the entire "dashboard" naming convergence.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.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:
\bbackend/ in user files), orspree 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.
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.
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.
Build-time typed route composition — resolved and implemented; see 5.6-dashboard-typed-plugin-routes.md.
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).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.docs/plans/5.5-admin-api-key-scopes.md — scope schema for the secret key minted by spree add dashboard.docs/plans/5.5-admin-auth-cookie-refresh.md — auth model the dashboard uses against the API.packages/create-spree-app/ — source layout to modify.packages/cli/src/commands/eject.ts — pattern for new commands; needs ctx.apiDir refactor.packages/cli/src/context.ts — extended with detectApiDir and apiDir field on ProjectContext.spree/spree-starter repo — existing template, no structural change needed.@spree/cli / create-spree-app tarballs (an optional public spree/dashboard-starter template repo is deferred to 6.0 GA).