Architecture
src/ React 19 storefront (Vite)src/admin/ Admin and setup wizard, loaded on demandserver/ Express 5 API (TypeScript)scripts/ The `npm run setup` CLI and the roadmap generatordb/ Drizzle schema, migrations, repositories, seedshared/ zod schemas and helpers, imported by both sidesemails/ Handlebars templatese2e/ Playwright specslegacy/ 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.
The contract
Section titled “The contract”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.
The seam
Section titled “The seam”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.
The database
Section titled “The database”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.
The admin
Section titled “The admin”/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.