Ecommerce Boilerplate
Features

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:

OrderSectionComponentNotes
1Herohome/Hero.tsxHeadline, subtitle, two CTAs, offset photo pair
2Featured productshome/Products.tsxCurated products from the home-products collection
3Shop by categoryhome/ProductGroups.tsxBento grid of productCategories
4Testimonialshome/Testimonials.tsxThree quotes from the dictionary
5Newsletter signuphome/NewsletterSignup.tsxEmail capture through /api/newsletter
6Contacthome/ContactUs.tsxContact 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:

src/app/[locale]/page.tsx
<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.

The featured strip fetches its products through the commerce layer, not the raw catalog:

src/app/[locale]/page.tsx
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:

src/components/home/Products.tsx
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:

NameSlug
Audioaudio
Wearableswearables
Smart Homesmart-home
Camerascameras
Computingcomputing
Accessoriesaccessories

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:

src/components/home/NewsletterSignup.tsx
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.

src/app/[locale]/products/page.tsx
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:

src/components/product/ProductCatalog.tsx
const result = await listCatalogPage({
  category: activeCategory,
  pageSize: PAGE_SIZE,
  cursor,
);

Key constants:

ConstantValueMeaning
PAGE_SIZE12Products 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.

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.

ConstantValueMeaning
SEARCH_MIN_CHARS2Minimum characters before a search runs
SEARCH_DEBOUNCE_MS300Debounce delay on the input
SEARCH_MAX_RESULTS24Maximum results returned

The query goes through the commerce layer:

src/components/product/ProductCatalog.tsx
.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:

src/app/[locale]/products/[id]/page.tsx
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, else primaryImageURL, 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 categories slug links to /products?category=<slug>, labeled from productCategories.
  • Description and, when present, a datasheet link (product.pdfLink) opening in a new tab.
  • Add to cart — via AddToCartButton; see Cart & Quotes.
  • JSON-LD — ProductJsonLd emits structured data for SEO.

Stock indicator

The StockAvailability helper reads the resolved stock. When stock is undefined, inventory is not tracked and nothing renders:

StockLabelColor
undefined(nothing rendered)—
0Out of stockRed
1–5Only N leftAmber
> 5In stockGreen

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.

When the product has categories, related items come through the catalog seam (listRelatedProducts), which dispatches to the demo fixture, Firestore, or a SQL adapter:

src/app/[locale]/products/[id]/page.tsx
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.

PreferenceCookieClient controlResolvedDefault
LanguagelocaleLocaleSwitcherServer, getActiveLocale()en
CurrencycurrencyCurrencySwitcherServer, getActiveCurrency()USD
Brand themethemeThemeSwitcherClient, before first paintdefault

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:

CodeLabel
enEnglish
esEspañ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):

CodeLocaleRateLabel
USDen-US1USD ($)
EURde-DE0.92EUR (€)
GBPen-GB0.79GBP (£)
JPYja-JP155JPY (¥)

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:

PresetBrandAccent
defaultDeep navyVivid orange
emeraldGreenGreen
violetPurplePurple

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.

On this page