Hermes started as one storefront: one brand, one catalogue, pages written by hand. It became a kit when the question changed from "can I build a store" to "can one codebase serve a US sports shop and a German electronics shop at the same time, and could the people who own those shops change them without me?"
It now runs three live stores from one Next.js build: Stride in the US, Voltra in the EU in English, German and French, and V2 Mall, an Indian fashion marketplace. Beside them runs the Hermes Console, where a client re-themes a store, rearranges its homepage, previews the draft in the real storefront and publishes it. Both are public: the stores and the Console, whose sign-in page lists the demo logins.

How it is put together
A Turborepo monorepo. Four packages hold the kit: shared types, core (store
config, routing, pricing and tax, translations, the commerce interface),
ui (primitives styled only through theme tokens) and storefront-kit (every
page, section and component, behind one createHermes() call). A client
project such as the demo store holds config, catalogue data and route files
that are two lines long. It contains no page code at all.
Pages are Server Components, statically generated or revalidated per store.
TanStack Query owns server state, Zustand owns UI state only (drawers, toasts,
an anonymous wishlist kept as product handles), and nothing copies server data
into the client store. Money is integer minor units, formatted only through
Intl. The browser never computes a total: it displays the one the server
sent.
A store is configuration, never a branch
Two stores in two currencies and three languages, with opposite tax rules, from one codebase, and every fix has to reach all of them.
The obvious way to get a second store is to copy the first. It is also the failure this kit exists to avoid: every fix lands twice, and within a quarter the copies have drifted apart.
So everything that differs between stores is a field on a typed StoreConfig:
theme, logo, locales, currency, tax policy, shipping, navigation, the homepage
as an ordered list of sections, feature flags, page templates. The repo's rule
is blunt: adding a per-store difference means adding a config field, never an
if (store.id === …).
Types cannot express everything, so defineHermes() rejects bad config at
build time: two stores claiming one domain, a path prefix that collides with
a locale, a tax rate written as 8 instead of 0.08. A store that would break
at runtime does not build.
Tax shows the approach in miniature. A US store adds tax at checkout; an EU
store shows VAT-inclusive shelf prices, as EU price-display rules require, and
extracts the VAT for the receipt. That is one switch, region.taxInclusive,
read by one pricing function, with both paths tested.
Theming works the same way. A store's theme sets CSS variables on :root, and
the primitives use only semantic tokens, never a colour. So one component
library renders a light, uppercase sports brand and a dark electronics brand
with no extra JavaScript, and the section registry is exhaustive by type: a
new section with no renderer fails the build.
What it bought
- A new store is a config file and a catalogue, not a fork
- A fix lands once and reaches every store
- Theme, copy and features are reviewable as data in a pull request
What it cost
- Every new per-store need has to be designed as a field first
- Config validation is code that needs its own tests
- A truly one-off client request has nowhere to go but the kit
A publish moves one pointer
A client changes the theme, the navigation and the homepage together. The store must never be seen with one change live and the others not.
For "run it yourself" to be true, the client-editable parts of a store have to be data that a non-developer edits, previews and publishes. I built the content store myself rather than adopting a headless CMS, for one main reason: the schemas are the component props. Every section has a TypeScript shape in the kit and a Zod schema in the content package, and a compile-time assertion fails the build if the two drift. A client cannot publish something the kit cannot render. With a third-party CMS I would maintain the same model twice, and the drift would be silent.
Each editable area of a store is a document with a mutable draft and a revision counter, so two Console tabs cannot silently overwrite each other. Publishing validates every draft, writes an immutable version of each one that changed, and creates a release: a snapshot naming one version of every document. The store's live pointer then moves to the new release in one transaction. That pointer is what makes a publish atomic: theme, navigation and homepage go live together or not at all.
Rollback creates a new release that copies an old one's entries, so history only grows. Scheduled publishes store only the documents that changed, and lay them over whatever is live when they fire, so a Friday sale banner does not undo a navigation fix published on Wednesday.
Publishing is also where the guardrails sit. A missing translation for any of the store's languages, an image without alt text, a link to a page that does not exist, or a theme whose colours fail WCAG AA contrast blocks the publish. The Console shows the same checks while the client edits, so nobody meets them for the first time at the publish button.
The preview is the real storefront in draft mode, framed by the Console at phone, tablet or desktop width. A small bridge, active only when the frame's parent is the Console's own origin, reports clicks on sections back to the editor, so a client selects what they want to change by clicking it.
Making an in-memory demo survive serverless
The public demo had to cost nothing, so there is no hosted commerce backend — and on the first live run, checkout failed almost every time.
Hermes has a real commerce backend: an adapter for Medusa v2, contract-tested against Medusa on Postgres. But hosting Medusa costs money, and the demo had to be free. So the demo runs on the kit's in-memory adapter, which keeps carts, orders and accounts in process memory.
That worked on a laptop and failed on Vercel. "Add to bag" landed on one
function instance and the next request landed on another, which had never heard
of that cart. Checkout answered cart_not_found.
The fix keeps the adapter and gives it somewhere to put its state. Both memory
adapters take an optional persistence store. Around every method that touches
state, the adapter loads the store's state document, runs the call, and saves
only if something changed. The save is optimistic: it succeeds only if the
stored version is still the one it loaded, and a lost race reloads and
re-runs, up to four times. Catalogue reads skip all of it, so browsing never
touches the database. The demo keeps the state in the Neon Postgres database it
already had, and the functions are pinned to the database's region.
The part I would insist on again is the test strategy: the memory adapters' contract suites run twice, with persistence and without, so the persisted version is held to exactly the behaviour of the original.
What it bought
- Checkout and sign-in work across serverless instances, for free
- The adapter's behaviour is unchanged, and proved unchanged by its tests
- The database driver stays in the demo app, out of the kit
What it cost
- Whole-document writes serialise every cart change in a store
- A retried call can repeat a side effect, such as a second Stripe PaymentIntent
- It is a demo mechanism, never a commerce backend
Where it ended up
Three stores live, the Console live, and the content database on Neon, all on free plans. Around 61,000 lines across 618 TypeScript files, 548 unit tests in 61 files, and 61 Playwright tests in 14 specs, including an axe accessibility pass and a client-persona suite that signs into the Console, edits and publishes. Twenty-two architecture decision records explain the choices above and the ones this page leaves out.

The B2B layer is the deepest single piece of work, and it lives in the original single-store app: organisation hierarchies, cost centres, spend limits, multi-level cart approval and quote negotiation. Two rules from it are worth repeating. A cart locks while an approval is pending, in the adapter rather than the UI, because an approver signs off a specific basket at a specific price. And an accepted quote is a price, not a permission: a buyer with a $100 limit who negotiates a $50,000 basket down to $40,000 still needs the approval chain to buy it. A test pins that rule.
What I’d do differently
The B2B layer is not on the kit yet. It was built before the kit existed, and until it moves, the live stores are B2C only. That migration is the next large piece of work.
The demo does not run on Medusa. The adapter and its contract tests exist and pass against a local Medusa, but nothing hosted has been checked against it. Stripe is in test mode, and paying with a test card in every store has not been verified end to end yet.
The Console's promise is that a non-technical person can run a store with it. I have not watched one try. Until that usability test happens, "no developer needed" is a design goal, not a result.
The demo logins are public on purpose, which means anyone can change the demo stores. History and rollback make an unwanted edit a one-click fix, but a pitch demo should have its own login.
The commit history is milestone-shaped, nineteen commits for sixty thousand lines. Each one lands a phase with its decision record, which reads well as a log and badly as evidence of incremental work. On a team I would commit far more often.