Skip to content

Architecture

src/ React 19 storefront (Vite)
src/admin/ Admin and setup wizard, loaded on demand
server/ Express 5 API (TypeScript)
scripts/ The `npm run setup` CLI and the roadmap generator
db/ Drizzle schema, migrations, repositories, seed
shared/ zod schemas and helpers, imported by both sides
emails/ Handlebars templates
e2e/ Playwright specs
legacy/ An earlier codebase, kept for reference. Nothing builds from it.

In development there are two processes: Vite on port 5173 serving the storefront with hot reload, and the API on port 4000. In production there is one: the Express app serves the built client from dist/ and uploads from the assets directory, so a deployment is a single Node process on a single port.

shared/schema.ts is the shape of a store: products, variants, options, images, collections, pages, theme, hero, shipping countries. It is a set of zod schemas, and both the storefront and the API validate against them. The storefront parses /api/store through storeSchema before rendering anything; the API parses every admin write through the input schemas in shared/api.ts before it reaches a repository.

That is what makes a missing step a type error rather than an undefined in production, and it is why adding a field touches shared/ before it touches a route.

src/lib/store-source.ts is the only place the storefront learns where its data comes from. loadStore() fetches /api/store; set VITE_BELUGA_API=false and it validates the bundled demo fixture through the same schema instead, so you can do UI work with no database running.

The worked example is Beluga’s own history: swapping the Phase 1 fixture for the Phase 2 database changed exactly that one file. The seams lists the others.

SQLite by default, because a store should run without provisioning anything. Point DATABASE_URL at Postgres when a catalogue outgrows a file. The query layer is written once against Drizzle, and db/dialect.test.ts runs the same assertions against both engines, so a query that only works on one fails there first.

Schemas are two files, db/schema.sqlite.ts and db/schema.pg.ts, edited together. Migrations run automatically at boot and are idempotent.

/admin is a second application, loaded only when someone goes there. A shopper downloads none of it. It talks to /api/admin/*, which sits behind a session and a CSRF check applied to the whole router at once, so a new admin route cannot be mounted unprotected by accident. The API has the surface.

Every amount, everywhere, is an integer number of minor units. The cart holds product and variant identifiers only, never a price. Line items sent to Stripe are built on the server from prices read out of the database. Only Stripe’s webhook marks an order paid. Those four sentences are most of what is worth knowing, and the invariants say them properly.