Architecture
Two deployed apps, one catalogue, and a fallback that keeps the shop up when the backend is not.
Two apps are deployed, from one repository.
| App | Package | Stack | Host |
|---|---|---|---|
| Storefront | @glemor/storefront | Next.js 16 (App Router), React 19, Tailwind v4 | Vercel |
| Backend | @glemor/medusa | Medusa v2.20.1 | Railway, with Postgres and Redis |
| Docs | @glemor/docs | Next.js 16 + Fumadocs | this site |
How the catalogue flows
Medusa (Railway) Storefront (Vercel)
Postgres ─┐ src/lib/medusa.ts Store API client
Redis ─┼─ Store API ──────► src/lib/catalog.ts maps to Product, sen
Admin /app ┘ ├─ pages, cart
└─ POST /api/checkout (prices re-read here)Every page, the cart and the checkout route read from one accessor, getCatalog() in
apps/storefront/src/lib/catalog.ts. That is the whole reason a price edited in the admin
changes both what is displayed and what is charged: there is no second copy for the two to
disagree about.
Money
The storefront and Stripe work in integer sen (RM108.00 is 10800). Medusa v2 stores
decimal amounts (108). The conversion happens in exactly one place — toProduct() in
catalog.ts multiplies by 100 — and getting it backwards prices Woody Muse at RM10,800.
Money is never a float and is never parsed from client input.
Product metadata
Medusa's product model has no field for a scent family or a notes pyramid, so those ride in
product.metadata and are read back by toProduct():
| Key | Type | Notes |
|---|---|---|
series | premium | daily | set | Anything unrecognised falls back to daily |
ml | number | |
family | string | |
accent | hex string | Per-scent, tints the generated visual only. Defaults to the gold |
notes | JSON string | [{ position, name }], parsed defensively — malformed input yields [], never a crash |
verified_notes | boolean | Strict === true. Anything else keeps the disclosure |
sold, rating | number |
Failing safe, three times
getCatalog() returns the static catalogue in src/lib/products.ts rather than an empty
shop when:
NEXT_PUBLIC_MEDUSA_BACKEND_URLorNEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEYis unset, or Medusa is unreachable;- no region has
currency_code === "myr"— deliberately not "the first region", because Medusa's demo seed leaves a EUR region behind that sorts first and carries no MYR price; - any mapped product resolves to
0sen. A priced product can never legitimately be free, so a zero means the price did not resolve — and since/api/checkoutreads from this same accessor, serving it would build a Stripe session for nothing.
Every fallback is logged to the server console. None of them is silent.
Caching
Route segments that render catalogue data set export const revalidate = 300. An admin
edit is live within five minutes with no redeploy. That number is the entire "how long
until my change appears" answer in the team guide.
Payments
Checkout is Stripe Checkout, driven from the storefront — POST /api/checkout takes
{ items: [{ slug, qty }] }, looks every price up server-side, and returns a session URL.
With no STRIPE_SECRET_KEY the route answers 503 with a message pointing the customer at
WhatsApp, which is a designed state rather than an outage.
Medusa is not in the storefront's payment path: the checkout talks to Stripe directly, and
getCatalog() is the only thing the storefront asks Medusa for.