GlemorDocs
Engineering

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.

AppPackageStackHost
Storefront@glemor/storefrontNext.js 16 (App Router), React 19, Tailwind v4Vercel
Backend@glemor/medusaMedusa v2.20.1Railway, with Postgres and Redis
Docs@glemor/docsNext.js 16 + Fumadocsthis 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():

KeyTypeNotes
seriespremium | daily | setAnything unrecognised falls back to daily
mlnumber
familystring
accenthex stringPer-scent, tints the generated visual only. Defaults to the gold
notesJSON string[{ position, name }], parsed defensively — malformed input yields [], never a crash
verified_notesbooleanStrict === true. Anything else keeps the disclosure
sold, ratingnumber

Failing safe, three times

getCatalog() returns the static catalogue in src/lib/products.ts rather than an empty shop when:

  1. NEXT_PUBLIC_MEDUSA_BACKEND_URL or NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY is unset, or Medusa is unreachable;
  2. 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;
  3. any mapped product resolves to 0 sen. A priced product can never legitimately be free, so a zero means the price did not resolve — and since /api/checkout reads 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.

On this page