Skip to content
weLabsweLabsStorefront Playbook
Live demoQuickstart

Developers

Conventions

The rules the storefront codebase follows — strict TypeScript, pnpm, Server Components first, logical CSS for RTL, message catalogs, SEO through PageSeo, centralised brand identity, money at the display edge, and ADR discipline.

These are the conventions the storefront repository holds itself to. Most are enforced by the type checker, ESLint, a CI guard or a smoke test, so breaking one usually fails a build rather than a review. Each section says what enforces it.

Rule Enforced by
TypeScript is fully strict everywhere: strict, noUncheckedIndexedAccess, verbatimModuleSyntax, isolatedModules tsconfig.base.json, which every package extends; pnpm typecheck in CI
pnpm only, at the pinned version. No npm or Yarn lockfiles packageManager in the root package.json; CI installs with --frozen-lockfile
Node 22 or newer engines in the root package.json; CI uses Node 22
Packages ship raw TypeScript through exports; no per-package build transpilePackages in apps/storefront/next.config.ts
Import packages through their public exports, never @welabs/*/src/... ESLint no-restricted-imports
Only the Composition Root names an adapter package ESLint no-restricted-imports, with a carve-out for lib/commerce/adapters/index.ts, tests and e2e/

With verbatimModuleSyntax, type-only imports must say so: import type { Product } from "@/lib/commerce".

Pages and layouts are Server Components by default. Keep "use client" boundaries small: a client island is the interactive piece (the gallery, add to cart, a form), not the page around it. Data is read on the server through the Port, usually with getLocalizedAdapter() or one of the thin wrappers in lib/server/, and passed down as canonical, serialisable values. Mutations go through Server Actions in features/*/actions.ts, or Route Handlers under app/api/ where a client needs an endpoint.

Arabic ships with the storefront, so every layout must mirror correctly. Components express direction in logical terms (start and end), never physical ones (left and right). ADR 0005 calls this RTL-first styling.

Don’t Do
ml-4, mr-4 ms-4, me-4
pl-2, pr-2 ps-2, pe-2
left-0, right-0 start-0, end-0
text-left, text-right text-start, text-end
rounded-l-md, rounded-r-md rounded-s-md, rounded-e-md
border-l, border-r border-s, border-e

Two guards enforce it. The ESLint rule noPhysicalDirectionUtilities (from eslint.rtl.mjs) flags those utilities in string literals and template strings. A grep step in CI’s checks job repeats the check across the app, storefront-ui and templates. A genuinely physical case (a popover positioned by computed side, say) carries an eslint-disable comment with its reason.

All UI copy comes from message catalogs (next-intl), never from string literals in components.

  • English is the Source Catalog. Other Locales merge over it, so a missing key renders English instead of failing.
  • Each layer owns its keys, beside the components that render them: the app (apps/storefront/messages/en.json, ar.json), storefront-ui (src/messages) and each Template Family in templates (src/messages). The app deep-merges them per request, packages first and the app last.
  • Preset data uses Translatable Markers. A Shell Preset label is written as a message-key marker and resolved at the chrome boundary. Backend-sent text is never a marker: translating backend content is the backend’s job.
  • Render the exact UI copy the SRS spec quotes. The §4 UI tables of each spec are the contract.
  • Adding a Locale is catalog-only: add the files and list the Locale in STOREFRONT_LOCALES. Direction is derived from the Locale (ar, fa, he, ur are right-to-left).

SEO: the storefront prints, the backend supplies words

Section titled “SEO: the storefront prints, the backend supplies words”

ADR 0023 (SEO) makes the storefront the only author of tags and structured data. The backend supplies words only: SEO Fields per item and SEO Patterns per item kind and Locale, from Yoast SEO, Rank Math or the Companion’s own box.

  • A public page describes itself once, as a PageSeo (lib/seo/metadata.ts), and hands it to both pageMetadata() (title, description, canonical address from SITE_URL, hreflang alternates, Open Graph and Twitter tags, robots) and <PageJsonLd> (the structured data). Building it once means the two cannot disagree.
  • Never assemble metadata or JSON-LD by hand. Any other JSON-LD block goes through <JsonLd>, which escapes it.
  • A private page (account, cart, checkout, sign-in, search, compare, order confirmation, email links) carries robots: NOINDEX from lib/seo/robots.ts: through its route-group layout, or spread into its own generateMetadata, because Next.js replaces robots rather than merging it.
  • Add every new route to e2e/smoke/seo.spec.ts, in the public or the private list.

This excerpt is abbreviated from the FAQ page:

async function faqSeo(title: string): Promise<PageSeo> {
const [menu, meta] = await Promise.all([getTranslations("chrome.menu"), getTranslations("meta")]);
return underHome({ kind: "faq", path: "/faq", title, description: meta("faq.description") }, menu("home"));
}
export async function generateMetadata(): Promise<Metadata> {
const { title } = await resolveFaq();
return pageMetadata(await faqSeo(title));
}
export default async function FAQPage() {
// …
return (
<article className="pb-16">
<PageJsonLd page={await faqSeo(title)} />
{jsonLd && <JsonLd data={jsonLd} />}
{/* … */}
</article>
);
}

And a private route group, from app/[locale]/(auth)/layout.tsx:

// Sign-in, registration and password pages are never indexed.
export const metadata: Metadata = { robots: NOINDEX };

Never hardcode the store name, tagline or currency. Brand identity lives in lib/config/site.ts (siteConfig, BRAND, siteTitle), store identity in lib/config/store.ts. Static informational content (About, store coordinates) is typed config in lib/config/pages/, read through the one swap seam getPageContent() and rendered with components/storefront/prose.tsx. Policy Pages are the exception: they come from the backend through the Port (ADR 0016). See Store identity.

Money is integer minor units from the adapter to the moment it is rendered. Format only there: formatMinor() on the server with the store Currency, useFormatMinor() in client components. Never divide by 100 and never format in an adapter or a Server Action. Details in Canonical model.

Before touching a button, pill call to action, icon disc or text link styled as a button, read the repository’s storefront-buttons skill. The pill geometry, the hover sweep and the cascade-layer rules they depend on are not guessable from the class lists.

  • Use the terms in CONTEXT.md in code, comments, tickets and docs: Shopper, not “user”; Processor, not “gateway”, above the Port; Capability, not “module”. Each entry lists the words to avoid.
  • When vocabulary changes, update CONTEXT.md in the same change.
  • Write an ADR in docs/adr/ for any decision that is hard to reverse, numbered after the last one. When a new decision withdraws part of an earlier one, say so in both status lines, as ADR 0025 and ADR 0022 do.
  • New backend-visible behaviour needs a contract-suite section that both adapters pass.
Storefront Playbook · Built by weLabsFeaturesFAQTalk to us