Storefront
The customer-facing shopping experience: catalog, search, product pages, and preferences.
The storefront is the public, customer-facing side of the boilerplate. It runs on a fresh clone with no credentials at all — the commerce layer resolves to the bundled demo backend — and its default flow is quote-based: shoppers browse the catalog, add items to a cart, and submit a quote request. Prices render everywhere, but no payment is required unless you opt into Stripe. This page covers the browsing half; for the cart and checkout side see Cart & Quotes.
Routes are locale-prefixed under src/app/[locale]/. In this doc the paths are written without the prefix (e.g. /products); links inside the app are localized automatically by useLocalePath() / localizeHref().
Landing page
The home page (src/app/[locale]/page.tsx) is a Server Component that resolves the active locale, builds a translator, and renders six sections in order:
| Order | Section | Component | Notes |
|---|---|---|---|
| 1 | Hero | home/Hero.tsx | Headline, subtitle, two CTAs, offset photo pair |
| 2 | Featured products | home/Products.tsx | Curated products from the home-products collection |
| 3 | Shop by category | home/ProductGroups.tsx | Bento grid of productCategories |
| 4 | Testimonials | home/Testimonials.tsx | Three quotes from the dictionary |
| 5 | Newsletter signup | home/NewsletterSignup.tsx | Email capture through /api/newsletter |
| 6 | Contact | home/ContactUs.tsx | Contact details + message form |
Hero
The hero renders a localized title/subtitle plus a primary and secondary CTA. Out of the box the primary CTA links to /products and the secondary to /platform:
<Hero
title={t("home.heroTitle")}
subtitle={t("home.heroSubtitle")}
imageAlt={t("home.heroImageAlt")}
primaryCta={{ label: t("home.heroPrimaryCta"), href: "/products" }}
secondaryCta={{ label: t("home.heroPlatformCta"), href: "/platform" }}
locale={locale}
/>The subtitle supports lightweight **bold** markup, and all copy comes from the i18n dictionary (t("home.*")). The photography is generated: npm run generate:images writes the whole demo set into public/generated/ with fal.ai and repoints catalog.json at it, building each product's prompt from that product's own name and description. Swap the Image src values for your own assets, or regenerate with FAL_MODEL pointed at a better model.
Featured products
The featured strip fetches its products through the commerce layer, not the raw catalog:
const featured = await getCachedFeaturedProducts().catch((error) => {The page fetches on the server and passes the list down as a prop, so the featured rail is in the first HTML response and a backend failure degrades the section rather than the page. listFeatured() reads the curated set you manage from the admin dashboard (the home-products collection on Firestore, featured_products on the SQL backends), separate from the main catalog. Because a featured entry is stored under its own auto-generated document id, each one carries a productId field pointing back at the catalog document. The card uses that catalog id for links and add-to-cart:
const product: HomeProduct = {
...homeProduct,
id: homeProduct.productId ?? homeProduct.id,
};While loading, six ProductCardSkeletons are shown; cards fade up on scroll (respecting prefers-reduced-motion).
Shop by category
ProductGroups maps over productCategories from src/config/site.ts into a responsive bento grid. The six categories shipped in the boilerplate are:
| Name | Slug |
|---|---|
| Audio | audio |
| Wearables | wearables |
| Smart Home | smart-home |
| Cameras | cameras |
| Computing | computing |
| Accessories | accessories |
Each card links into the catalog filtered to that category (/products?category=<slug>). Edit or extend the list in src/config/site.ts — the grid, catalog chips, and product-page category tags all read from the same source.
Newsletter signup
The newsletter form validates the email with Zod and posts it to
/api/newsletter:
const { stored } = await subscribeToNewsletter({
email: values.email,
source: "home",
locale,
});On success it swaps to a confirmation message; on failure it surfaces a form error, and a rate-limited visitor gets a distinct one. No email provider is involved — this only records the address.
Why a route rather than a direct write. The form used to call addDoc
against Firestore from the browser, which meant two things. It silently did
nothing on Postgres, SQLite and Supabase. And on Firebase the rule guarding the
collection was allow create: if true — an unauthenticated, unvalidated,
unlimited write into a collection you pay for. Both are closed now: client
writes to newsletter_signups are refused outright, and the endpoint carries
an IP rate limit, a strict schema (a capped address, closed sets for source
and locale), and an upsert keyed on the normalized email so a flood of one
address is one row.
The endpoint also answers identically whether or not the address was already subscribed, so it cannot be used to test whether someone is on your list.
Where signups go is NEWSLETTER_PROVIDER (see .env.example). Unset, it
follows the commerce backend: the store's own database when one is configured,
and accept-and-discard in demo mode, so a fresh clone's home page works. On
Firebase, persistence needs FIREBASE_SERVICE_ACCOUNT_JSON, because the write
is now server-side; without it the signup is accepted, a warning is logged, and
the UI quietly notes that nothing was saved.
Swapping the store for an ESP — Mailchimp, Klaviyo, Resend Audiences — is a new
file under src/lib/newsletter/providers/ and one case in the registry. That
is why this is its own seam rather than part of CommerceServices: a mailing
list is not a commerce concept, and its real backend usually is not the
commerce database.
Contact
The contact section renders your store details from siteConfig.contact (email, phone, address) as mailto:/tel: links, alongside a name/email/message form.
The contact form is intentionally a stub — on submit it just shows a success state. Wire handleSubmit in ContactUs.tsx to your backend (an API route, a form service, or Firestore) before relying on it in production.
Product catalog
The catalog lives at /products (src/app/[locale]/products/page.tsx). The page is a Server Component: it resolves the category from searchParams, fetches the first page through the commerce layer, and hands it to the client ProductCatalog as a prop. The products are therefore in the initial HTML, and the client keeps only what needs the browser (debounced search and "load more"). Because switching category is a navigation, the page re-fetches on the server and remounts the component with a key, so no stale grid survives the change.
const category = resolveCategory((await searchParams).category);
initialPage = await getCachedCatalogPage(category, PAGE_SIZE);
return (
<ProductCatalog
key={category}
activeCategory={category}
initialPage={initialPage}
/>
);ProductCatalog (src/components/product/ProductCatalog.tsx) drives three behaviors: category filtering, cursor pagination, and name search.
Category filtering
The active category comes from the URL, resolved on the server and validated against productCategories (an unknown slug falls back to all). The backend applies the filter — listCatalogPage calls products.listByCategory, which is an indexed query on every adapter (Firestore array-contains, a product_categories join on the SQL backends). Filtering after pagination instead would drop matches the page had no room for, so it is not an option the seam offers. A chip row renders an All chip plus one chip per configured category, each a link to /products?category=<slug>. The active chip is highlighted and marked aria-current="page".
The page heading reflects the active category name (or "All products" for all), resolved from productCategories.
Pagination
Category browsing is cursor-paginated through the commerce layer's catalog seam (listCatalogPage), which serves the demo fixture, Firestore, or a SQL adapter depending on the backend:
const result = await listCatalogPage({
category: activeCategory,
pageSize: PAGE_SIZE,
cursor,
);Key constants:
| Constant | Value | Meaning |
|---|---|---|
PAGE_SIZE | 12 | Products fetched per page |
Results accumulate into a single grid. A footer shows Showing {shown} of {total} — every backend reports a real count now, via products.countByCategory — and a Load more button appears while products.length < totalCount and a cursor exists. If a count is ever unavailable the footer is hidden and "load more" falls back to "a full page plus a cursor means try again", so pagination degrades rather than stopping. The button is disabled and shows a loading label while the next page is in flight.
Name search
Typing in the search box runs a debounced, prefix-based name search that is independent of the category grid. When a search is active it replaces the paginated grid with a results grid.
| Constant | Value | Meaning |
|---|---|---|
SEARCH_MIN_CHARS | 2 | Minimum characters before a search runs |
SEARCH_DEBOUNCE_MS | 300 | Debounce delay on the input |
SEARCH_MAX_RESULTS | 24 | Maximum results returned |
The query goes through the commerce layer:
.search(searchTerm, SEARCH_MAX_RESULTS)Under the Firebase adapter this is a prefix range query on productNameLower — a derived, lowercased copy of productName that the data layer maintains automatically on write. Search matches the start of the product name only, and does not combine with category or brand filters (that would require composite indexes). A monotonic request id guards against stale responses overwriting newer ones.
The UI shows a result count, a clear ("X") button, skeletons while searching, and an empty state with a Clear action when nothing matches.
Search is name-prefix only by design, to stay index-free on Firestore. For fuzzy or full-text search, point commerce.products.search at a dedicated search backend behind the same interface — see Commerce Layer.
Product detail page
Each product renders at /products/[id] (src/app/[locale]/products/[id]/page.tsx), a Server Component that loads the product by id and reuses that one read for both generateMetadata and the body:
loadProduct(id)An unknown id calls notFound() — a real 404, not a 200 carrying a "not found" screen. That depends on nothing above the page having flushed the response yet, which is why neither this segment nor its parent has a loading.tsx: a segment-level Suspense boundary sends the shell first and freezes the status at 200. Only the interactive half — option selection, the variant-resolved price, add to cart — is a client island. Pages revalidate hourly, so a price or stock edit in the admin shows up without a redeploy.
What it displays
- Image — the selected variant's
imageURL, elseprimaryImageURL, else/placeholder.svg. - Brand, name, and item code — the item code is the product
id. - Price — formatted for the shopper's active currency via
formatMoneyIn(displayPrice, currency). - Stock availability — a colored label driven by stock level (see below).
- Category tags — each
categoriesslug links to/products?category=<slug>, labeled fromproductCategories. - Description and, when present, a datasheet link (
product.pdfLink) opening in a new tab. - Add to cart — via
AddToCartButton; see Cart & Quotes. - JSON-LD —
ProductJsonLdemits structured data for SEO.
Stock indicator
The StockAvailability helper reads the resolved stock. When stock is undefined, inventory is not tracked and nothing renders:
| Stock | Label | Color |
|---|---|---|
undefined | (nothing rendered) | — |
0 | Out of stock | Red |
1–5 | Only N left | Amber |
> 5 | In stock | Green |
Variants
If a product has variants, the page derives one selector group per option axis (from the product's options, or inferred from the variants when absent). Selecting a value in every group resolves a specific variant, and the displayed price, stock, and image switch to that variant's values. Until every axis is chosen, Add to cart is disabled with a "select options" label. Products without variants skip all of this and behave as a simple product.
Related products
When the product has categories, related items come through the catalog seam (listRelatedProducts), which dispatches to the demo fixture, Firestore, or a SQL adapter:
const related = (await getCachedRelatedProducts(product)) as Product[];On Firebase that is an array-contains-any query on categories; the SQL adapters ask listByCategory for four and keep three. Either way the current product is filtered out and up to three cards render in a "Related products" section beneath the detail. The rail is decorative, so listRelatedProducts swallows backend errors and returns an empty list — a failure there drops the section rather than the page.
Header preferences
The header (src/components/home/Header.tsx) exposes a Preferences popover (components/preferences/PreferencesMenu.tsx) with three controls: language, currency, and brand theme. On mobile these move into the hamburger menu.
All three are cookie-backed, but they do not resolve the same way — and the difference is the whole reason the storefront can be cached.
| Preference | Cookie | Client control | Resolved | Default |
|---|---|---|---|---|
| Language | locale | LocaleSwitcher | Server, getActiveLocale() | en |
| Currency | currency | CurrencySwitcher | Server, getActiveCurrency() | USD |
| Brand theme | theme | ThemeSwitcher | Client, before first paint | default |
Language and currency are read on the server: changing one writes the cookie and calls router.refresh(), so translated copy and prices re-render. Both resolvers are async.
The theme is not. Every preset ships as attribute-scoped CSS in one stylesheet, a pre-paint script sets data-theme from the stored choice, and ThemeSwitcher swaps that attribute directly with no round trip and no flash of the wrong brand. It used to have a server resolver like the other two; reading a cookie on the server makes a route dynamic, so removing it is what lets the response be byte-identical for every visitor.
All three cookies are written with path=/ and a ~1 year max-age (SameSite=Lax). The two server resolvers validate against siteConfig — an absent or unknown value falls back to the default.
src/lib/i18n/server.ts and src/lib/currency/server.ts import next/headers, so never import them from a Client Component. getActiveCurrency() is now the last cookie read in the root layout, and therefore the last thing keeping every route dynamic — see branding & theming and the static-rendering entry in ROADMAP.md.
Language
Locales are declared in siteConfig.locales. The boilerplate ships two:
| Code | Label |
|---|---|
en | English |
es | Español |
Every user-facing string resolves through a keyed dictionary (src/lib/i18n), so adding a language is a data change: drop a src/lib/i18n/<locale>.ts dictionary, register it, and add it to siteConfig.locales. See Internationalization.
Currency
Prices are stored in the store's base currency (USD) and converted at display time by formatMoney / formatMoneyIn. siteConfig.currencies ships four options — the first must be the base (rate 1):
| Code | Locale | Rate | Label |
|---|---|---|---|
| USD | en-US | 1 | USD ($) |
| EUR | de-DE | 0.92 | EUR (€) |
| GBP | en-GB | 0.79 | GBP (£) |
| JPY | ja-JP | 155 | JPY (¥) |
The rates in siteConfig.currencies are static demo values. Wire a live FX source before using multi-currency pricing in production.
Brand theme
The theme control switches the four --brand* CSS custom properties at runtime by choosing a preset from siteConfig.theme.presets. Three presets ship:
| Preset | Brand | Accent |
|---|---|---|
default | Deep navy | Vivid orange |
emerald | Green | Green |
violet | Purple | Purple |
Selecting a preset persists the theme cookie and refreshes the server tree, which re-runs the server-rendered ThemeStyle so the injected --brand* variables update live. Because both light and dark mode read the same tokens, restyling is a one-place change. See Theming and Site Config.