Ecommerce Boilerplate
Getting Started

Project Structure

An annotated tour of the repository layout and where each concern lives.

A map of the repository. Every path below is real — open them alongside this page.

AGENTS.md at the repository root is the short version of this page's conclusions, written for a coding agent: the conventions that are not visible in any single file, and the constraints that turn a reasonable-looking change into a broken one on three of the four backends. Read it before a first contribution, whether or not an agent is doing the typing.

How it fits together

Before the file list, the shape. Almost everything follows from one decision: no page, component, or route ever names a backend. They import commerce (or the server-side catalog helpers) and the wiring decides what that is.

                    src/config/site.ts        ← branding, nav, locales, currencies
                            │
  ┌─────────────────────────┴──────────────────────────┐
  │  src/app/[locale]/**   server components + client islands
  │  src/components/**     UI, grouped by surface
  └─────────────────────────┬──────────────────────────┘
                            │  commerce.*  /  lib/commerce/catalog.ts
                            ▼
                 src/lib/commerce/index.ts        ← binds ONE adapter
                            │
        ┌───────────────────┼───────────────────┐
        ▼                   ▼                   ▼
      demo/            firebase.ts            http.ts
   (fixture, no      (Firestore from        (POST /api/commerce/…)
    credentials)      the browser)                  │
                                                    ▼
                                    src/app/api/commerce/[service]/[method]
                                    verify ID token → access matrix → audit
                                                    │
                                        getServerCommerce()
                                        ┌───────────┼────────────┐
                                        ▼           ▼            ▼
                                  server/prisma  server/     server/
                                  (postgres,     supabase    firebase-admin
                                   sqlite)                   (admin surface)

Three things are worth knowing about that picture:

  1. activeBackend() (lib/commerce/backend.ts) is read in the browser too, which is why the choice lives in NEXT_PUBLIC_COMMERCE_BACKEND and its server twin COMMERCE_BACKEND — the two must name the same backend.
  2. Server components never take the HTTP hop. lib/commerce/catalog.ts resolves the server adapter when typeof window === "undefined", so a catalog page on Postgres queries in-process instead of fetching a relative URL Node cannot resolve.
  3. Privileged writes always cross the server, even on Firebase. That is what makes server/audit.ts able to record who changed what with a clock the acting admin does not control.

What a page actually does

Take /{locale}/products?category=audio:

StepWhere
Resolve locale and currency from the URL and cookies[locale]/layout.tsx + lib/{i18n,currency}/server.ts
Fetch the page of productslib/commerce/server/cached-catalog.ts → catalog.ts → the active adapter's listByCategory
Render the grid server-side, hydrate the filtersproducts/page.tsx → components/product/ProductCatalog.tsx
Add to cartcontexts/cartContext.tsx (guest carts in localStorage)
Check outapi/checkout re-prices from the catalog → Stripe → api/webhooks/stripe → orders.recordPaidSession on the active backend

The catalog read is cached for five minutes and tagged catalog; an admin product write through the RPC route purges the tag, so the storefront updates without waiting for the TTL.

Top level

Repository root
ecommerce-boilerplate/
├─ content/docs/          # This documentation (Fumadocs MDX source)
├─ src/                   # All application code (see below)
├─ public/
│  ├─ generated/          # Demo product photography (npm run generate:images)
│  └─ media/              # App screenshots the README and /platform embed
│                         # (npm run capture:media — never hand-cropped)
├─ prisma/
│  ├─ schema.prisma       # Postgres schema (COMMERCE_BACKEND=postgres)
│  └─ schema.sqlite.prisma # Same models, SQLite provider (=sqlite)
├─ supabase/
│  └─ schema.sql          # Supabase DDL (=supabase), idempotent
├─ scripts/
│  ├─ seed.ts             # Demo-data seeding, any backend (tsx)
│  ├─ generate-images.ts  # Product photography via fal.ai (opt-in, costs money)
│  ├─ capture-media.ts    # Screenshots, driven through the e2e harness
│  └─ media-fixtures.ts   # Sample sales history, for the dashboard screenshots
├─ tests/                 # Vitest suite (see "tests/" below)
├─ e2e/                   # Playwright: env, provisioning scripts, specs, helpers
├─ .github/workflows/     # CI: lint, typecheck, build, tests, e2e
├─ .env.example           # Every variable the app reads, annotated
├─ firestore.rules        # Firestore access control (the `admins` role)
├─ storage.rules          # Firebase Storage access control
├─ next.config.mjs        # Next config + Fumadocs MDX plugin
├─ tailwind.config.ts     # Tailwind theme (reads the --brand* design tokens)
├─ source.config.ts       # Fumadocs content-source config
├─ prisma.config.ts       # Prisma CLI config (picks the schema by backend)
├─ playwright.config.ts   # E2E projects, the built-server webServer block
├─ eslint.config.mjs      # Flat config
├─ postcss.config.mjs
├─ components.json        # shadcn/ui generator config
├─ vitest.config.ts
├─ tsconfig.json
├─ AGENTS.md              # Conventions for coding agents working in this repo
├─ CONTRIBUTING.md
├─ README.md
├─ ROADMAP.md
├─ LICENSE
└─ SECURITY.md

A fresh clone needs none of the optional directories. With no .env.local the store runs on the bundled demo catalog (src/lib/commerce/demo/), so prisma/, supabase/, and the Stripe/Resend wiring stay dormant until you set their variables. Checkout is card payment when Stripe is configured, and a quote-request flow otherwise. See environment variables.

The src/ tree

src/
src/
├─ app/                   # Next.js App Router
│  ├─ [locale]/           # Locale-prefixed storefront + admin (de-facto root layout)
│  ├─ api/                # Route handlers (server endpoints)
│  ├─ docs/               # Renders /docs from content/docs (NOT locale-prefixed)
│  ├─ globals.css         # Design tokens + Tailwind layers
│  ├─ robots.ts           # /robots.txt
│  ├─ sitemap.ts          # /sitemap.xml
│  └─ favicon.ico
├─ components/            # React components, grouped by surface (see below)
├─ config/
│  └─ site.ts             # Single-file branding: name, contact, nav, currencies…
├─ contexts/              # Client React context providers (cart, user)
├─ hooks/                 # Reusable client hooks
├─ lib/                   # Framework-agnostic logic (see "src/lib" below)
├─ types/                 # Shared domain types (product, quote, customer…)
└─ proxy.ts               # Locale-prefix redirect (Next 16's middleware file)

src/proxy.ts is what earlier Next versions called middleware.ts — Next 16 renamed the file. Same job, same config.matcher export.

src/app/[locale] — the routed app and de-facto root layout

Every storefront and admin page lives under a [locale] segment, so URLs are locale-prefixed (/en/products, /es/cart). There is no src/app/layout.tsx — so src/app/[locale]/layout.tsx is the root layout. It owns <html lang> and <body>, mounts every top-level provider, and wraps the page in the storefront's chrome:

src/app/[locale]/layout.tsx
return (
  <html lang={locale}>
    <body className={`${display.variable} ${GeistSans.variable} ${GeistMono.variable} font-sans`}>
      <ThemeStyle />
      <OrganizationJsonLd />
      <UserProvider>
        <LocaleProvider initialLocale={locale}>
          <CurrencyProvider initialCurrency={currency}>
            <CartProvider>
              <div className="storefront relative min-h-[100dvh] overflow-clip bg-background text-foreground">
                <AuroraBackground />
                <div className="relative z-10">
                  <SkipLinkGate />
                  <BoilerplateBar />
                  <HeaderGate />
                  <div id="main-content" tabIndex={-1} className="outline-none">
                    {children}
                  </div>
                  <FooterGate locale={locale} />
                </div>
              </div>
              <Toaster />
            </CartProvider>
          </CurrencyProvider>
        </LocaleProvider>
      </UserProvider>
    </body>
  </html>
);

The Gate suffix is the pattern for chrome a route may not want. HeaderGate, FooterGate, and BoilerplateBar all ask the same predicate in src/components/home/chromeRoutes.ts and render nothing on /{locale}/platform, which ships its own dark nav and footer — one place to change, so the three can never disagree about which routes are "storefront".

#main-content is the skip link's target. Wrapping {children} here rather than putting an id on each route's own <main> means every page an adopter adds is covered without remembering to; tabIndex={-1} is what makes focus actually land there instead of the browser scrolling and leaving focus in the nav. The admin layout has its own <main id="admin-main">, and SkipLinkGate picks the right target by pathname.

generateStaticParams() pre-renders one route tree per configured locale, and an unknown prefix (/fr/...) calls notFound() instead of silently falling back.

Alongside the layout, error.tsx and not-found.tsx give the whole storefront tree its failure states.

There is deliberately no loading.tsx under products/. A segment-level one wraps every route beneath it in a Suspense boundary, which flushes the shell — and a 200 — before products/[id] can call notFound(), so a deleted product answered 200 with noindex instead of a real 404. The catalog skeleton lives inside the page as an explicit <Suspense> (components/product/CatalogSkeleton.tsx) so the loading UI survives without wrapping the sibling route. tests/product-not-found.test.ts keeps it that way.

The page segments under [locale]:

SegmentRoutePurpose
page.tsx/{locale}Landing page (hero, categories, featured, contact)
products//{locale}/productsCatalog + product detail pages
cart//{locale}/cartCart review
checkout//{locale}/checkoutQuote-request (and optional Stripe) checkout
login//{locale}/loginGoogle + email-link sign-in
complete-login//{locale}/complete-loginPasswordless email-link landing
account//{locale}/accountProfile, addresses, quote/order history
admin//{locale}/adminAdmin dashboard (role-gated)
platform//{locale}/platformMarketing page for the boilerplate itself
about//{locale}/aboutAbout page
privacy-policy/, terms-of-service/, cookie-policy//{locale}/…Legal pages

src/app/api — route handlers

Server endpoints, one folder per resource. See the API reference for full request/response detail.

src/app/api/
api/
├─ store/                          # Public read-only catalog API
│  ├─ route.ts                     # GET /api/store
│  ├─ categories/route.ts          # GET /api/store/categories
│  └─ products/
│     ├─ route.ts                  # GET /api/store/products
│     └─ [id]/route.ts             # GET /api/store/products/:id
├─ commerce/[service]/[method]/route.ts   # POST RPC layer for SQL backends
├─ auth/[...nextauth]/route.ts     # Auth.js handler (AUTH_PROVIDER=authjs)
├─ newsletter/route.ts             # POST /api/newsletter (the signup seam)
├─ checkout/
│  ├─ route.ts                     # POST /api/checkout (Stripe, optional)
│  └─ config/route.ts              # GET /api/checkout/config
├─ webhooks/stripe/route.ts        # Stripe webhook (optional)
├─ quotes/notify/route.ts          # Quote email notifications (optional)
└─ notifications/status/route.ts   # Email-integration status probe

The /api/store/* catalog endpoints are always available (rate-limited, cache headers). The commerce/[service]/[method] route is the RPC layer used only when a SQL backend is selected — see commerce backends. The checkout, webhooks, and quotes routes are env-gated, and auth/[...nextauth] only answers on the Auth.js identity path (authentication).

src/app/docs — this documentation

src/app/docs/[[...slug]]/page.tsx and src/app/docs/layout.tsx render the Fumadocs site from the MDX in content/docs/. This subtree is deliberately not locale-prefixed — the locale proxy excludes /docs, /api, and static files from the locale redirect. The MDX is compiled at build time by the Fumadocs plugin wired into next.config.mjs:

next.config.mjs
const withMDX = createMDX();
export default withMDX(nextConfig);

src/components

Presentational and interactive components, grouped by the surface they serve:

FolderWhat lives here
home/Landing sections plus the shared Header, Footer, their gates, and the newsletter signup form
product/Product cards, galleries, detail views
cart/Cart drawer, line items
admin/Admin dashboard UI
login/, userProfile/Auth and account UI (MaskedPhoneInput lives here)
header/, preferences/Header controls and preference menus
i18n/, currency/, theme/Locale / currency / theme switchers (ThemeStyle emits the --brand* presets)
emailing/, legal/, seo/React Email templates, legal copy, JSON-LD (OrganizationJsonLd)
marketing/The standalone /platform landing and the BoilerplateBar above the storefront
a11y/SkipLink and the gate that aims it at the right <main>
ui/, icons/30+ shadcn/ui (Radix) primitives — buttons, dialogs, toaster — plus the brand logo marks

src/lib

Framework-agnostic logic — the parts you can unit-test without a browser. The subdirectories are the important seams:

src/lib/
lib/
├─ commerce/       # Backend-agnostic commerce service layer (see below)
├─ auth/           # Identity seam: getAuthUser(request), firebase | authjs
├─ newsletter/     # Newsletter seam: getNewsletterProvider(), store | demo
├─ firebase/       # Firebase SDK wiring: auth, Firestore, Storage, admin
├─ i18n/           # Dictionaries + locale resolution
├─ currency/       # Currency provider + server-side active-currency resolution
├─ theme/          # presets.ts — the brand presets, emitted as scoped CSS
├─ orders/         # order-number.ts, paid-session.ts — shared by every adapter
├─ api/            # store.ts — helpers behind the public /api/store endpoints
├─ money.ts        # formatMoney — the single price-formatting seam
├─ cart.ts, order-total.ts, checkout-line.ts, checkout-metadata.ts
├─ inventory.ts, discounts.ts            # Stock + discount logic
├─ analytics.ts                          # Pure revenue/pipeline math for /admin
├─ phone-mask.ts                         # Mask + pattern behind MaskedPhoneInput
├─ email.ts, stripe.ts                   # Optional Resend / Stripe helpers
├─ audit.ts, rate-limit.ts               # Cross-cutting concerns
├─ motion.ts, utils.ts                   # Animation + misc helpers
└─ source.ts                             # Fumadocs content source loader

auth/ and newsletter/ are the two newest seams, and both copy the shape commerce/ established: one env var names an implementation, the implementations are import()ed lazily so choosing one never bundles the other's dependencies, and the resolution is memoized. See authentication for the identity half.

orders/ is the pure half of recording a payment: paid-session.ts turns the line refs in a provider's session metadata into order items against your catalog, and order-number.ts owns the one O-YYYYMMDD-NNNNXXXX recipe. Both are shared by the Prisma, Supabase, and Firebase-admin adapters, so a fix lands in all three at once.

src/lib/commerce — the backend-agnostic service layer

The single seam between the app and its commerce backend. Every adapter satisfies the same CommerceServices interface and emits the same lifecycle events, so swapping backends is a data/config change, not an app rewrite.

FileRole
types.tsBackend-neutral interfaces: ProductService, CartService, QuoteService, OrderService, DiscountService, CustomerService, AdminService, AuditService (the eight grouped as CommerceServices)
backend.tsactiveBackend() — resolves demo / firebase / http from the env, without importing any adapter
index.tsBinds commerce to the active adapter and re-exports everything
catalog.tsCatalog queries usable from a server component: category pages, related products, generateStaticParams ids
events.tsTyped, dependency-free event bus (on, once, emit)
demo/The zero-config backend: catalog.json (18 products) + an adapter whose reads are real and whose writes throw DemoModeError
firebase.tsFirebase adapter — delegates to FirebaseDBService, runs client-side
audited-firebase.tsfirebaseCommerce with the ten admin mutations routed through the server so they can be re-authorized and audited
firebase-audit.tsThe client half of the activity trail, reached only when NEXT_PUBLIC_ADMIN_WRITES=client puts the acting admin's own browser in charge of the write
http.tsClient adapter for the SQL backends — forwards each call to the /api/commerce RPC route

Server-only, never bundled for the browser:

FileRole
server/index.tsgetServerCommerce() — resolves the server adapter from COMMERCE_BACKEND
server/prisma/Prisma adapter, driving both Postgres and SQLite (three dialect branches, see SQLite). client.ts holds the lazy client handle, engine switch, and error wrapper; audit.ts the activity trail; index.ts assembles them
server/supabase/Supabase adapter — service-role key, PostgREST. Same three-file shape
server/firebase-admin.tsAdmin-surface adapter for the Firebase backend (Admin SDK); shopper methods throw
server/audit.tsRecords who changed what, with the server's clock and the uid from the verified token
server/cursor.tsKeyset cursors for the SQL adapters — a record of keys, so ordering by a non-unique column can carry its tiebreaker
server/cached-catalog.tsunstable_cache wrappers (5 min, tagged catalog) so dynamic rendering is not a query per visitor

The two SQL adapters were single files until each grew past what one file should hold. They are directories now — client.ts, audit.ts, index.ts — and the split is why client.ts exists at all: the per-service files need the client handle, and importing it from index.ts would be a cycle, because index.ts is what assembles those services into the adapter.

Read the full contract in commerce backends overview and the commerce RPC and events pages.

src/lib/firebase

Firebase SDK wiring. config.ts reads the NEXT_PUBLIC_FIREBASE_* env vars; authService.ts handles Google + passwordless email-link sign-in; dbService.ts (FirebaseDBService) is the Firestore data layer; storageService.ts handles uploads; admin.ts is the server-side firebase-admin instance whose verifyIdToken backs the Firebase half of src/lib/auth; firebaseDataConverters.ts and firebaseUtils.ts hold typed converters and helpers.

src/lib/i18n

Locale dictionaries and resolution. en.ts and es.ts are the shipped keyed dictionaries; index.ts registers them and exposes resolveLocale; server.ts resolves server-side; LocaleProvider.ts is the client provider; navigation.ts builds locale-aware links. Adding a language is a data change: drop src/lib/i18n/<code>.ts and register it. See internationalization.

src/lib/currency

CurrencyProvider.tsx is the client provider; server.ts exposes getActiveCurrency() (read in the root layout) so the first server paint already reflects the shopper's cookie-selected currency. constants.ts holds the cookie name and the resolver, and exists for a reason worth remembering: importing them from the "use client" provider silently gave the server undefined, so server-side currency never worked. Values shared across that boundary belong in a neutral module. Prices are stored in the base currency and converted for display by formatMoney in src/lib/money.ts. See currency.

src/lib/theme

One file, presets.ts, holding the brand color presets and the stylesheet generator over them. Nothing here runs on the server: <ThemeStyle /> (src/components/theme) emits every preset as attribute-scoped CSS — identical bytes for every visitor — plus a tiny pre-paint script that reads the theme cookie and sets data-theme. It used to read that cookie server-side instead, which meant a cookies() call in the root layout, which made every route beneath it render per request. Same result on screen, no per-request work, and the theme survives a statically served page. See branding & theming.

src/config/site.ts — single-file branding

Two identities live here, deliberately separate: productConfig names the boilerplate itself (used by /docs and /platform), and siteConfig names the demo store you are rebranding. The single place to rebrand: store name, tagline, description, url, contact, social links, mainNav / legalNav, currency and currencies, defaultLocale / locales, the theme presets, and the exported productCategories array. Components read from this config instead of hardcoding copy.

src/config/site.ts
export const siteConfig = {
  name: "Kestrel Supply",
  tagline: "Technology worth keeping",
  url: "https://ecommerce-boilerplate.com",
  currency: { code: "USD", locale: "en-US" } satisfies CurrencyConfig,
  defaultLocale: "en",
  locales: [
    { code: "en", label: "English" },
    { code: "es", label: "Español" },
  ],
  // …contact, links, mainNav, theme presets, etc.
} as const;

Full field-by-field reference: site configuration.

src/contexts and src/hooks

src/contexts holds the two client providers mounted in the root layout: cartContext.tsx (CartProvider) and userContext.tsx (UserProvider). src/hooks holds reusable client hooks such as useIsAdmin.ts.

src/types

Shared domain types consumed across the app: product.ts, quote.ts, customer.ts, and personalInfo.ts.

Optional backend directories

These exist only for the SQL backends and are ignored by a build that does not select one (their packages are listed in serverExternalPackages, so the bundler never tries to include them):

PathUsed by
prisma/schema.prismaCOMMERCE_BACKEND=postgres
prisma/schema.sqlite.prismaCOMMERCE_BACKEND=sqlite — identical models, SQLite provider
prisma.config.tsPicks between the two by COMMERCE_BACKEND (Prisma will not read the provider from an env var)
supabase/schema.sqlCOMMERCE_BACKEND=supabase

There is only one set of backend guides: the pages linked above. A second copy used to live in a top-level docs directory, outside the reach of the docs tests, and it rotted — by the time it was deleted it described a schema missing two columns and a whole table. tests/docs-paths.test.ts now fails if any tracked markdown file sits outside the guarded set, so that copy cannot come back unnoticed.

Security rules

Two rule files ship at the repository root and enforce access on the default Firebase backend:

  • firestore.rules — Firestore access control, including the admins role collection that gates the admin dashboard.
  • storage.rules — Firebase Storage access control for product image uploads.

Two collections there are worth reading before you copy the file into a project of your own. newsletter_signups is write: if false: it was create: if true, which is an unauthenticated, unvalidated, unlimited write into a billed collection, and the form now posts to /api/newsletter instead. audit_log allows an admin create only when the entry names that admin's own uid, marks itself source: "client", and carries a real timestamp and bounded strings — so the client-write fallback cannot forge an actor or invent a shape the Activity page then renders.

proxy.ts

src/proxy.ts (Next 16's name for what used to be middleware.ts) makes the locale prefix mandatory. Unprefixed requests (/, /products, …) are 307-redirected to the visitor's active locale, resolved in this order: the locale cookie → the best match from the Accept-Language header → siteConfig.defaultLocale. It runs on the edge and is kept dependency-free.

src/proxy.ts
export const config = {
  matcher: [
    "/((?!api|docs|_next|_vercel|.*\\..*|sitemap\\.xml|robots\\.txt|favicon\\.ico).*)",
  ],
};

The matcher excludes api, the docs subtree, Next/Vercel internals, any path with a file extension, and the SEO/meta files — which is why /docs and /api/* are never locale-prefixed.

tests/ and e2e/

Two directories, because the suites they hold cost different things to run. tests/ is a flat Vitest suite in four groups; e2e/ is Playwright against a built store, with its own environment (e2e/env.ts), provisioning scripts, and specs. Which one a given change needs is the whole subject of Testing — this is only the map.

GroupExamplesWhat it protects
Pure logicmoney, discounts, inventory, order-total, i18n, theme, phone-mask, analytics, cart-line-keyThe framework-agnostic seams in src/lib
Route handlersapi-checkout, api-stripe-webhook, api-commerce-rpc, api-newsletterThe places a mistake costs money or leaks data — server-side re-pricing, webhook idempotency, and the public/user/admin access matrix
Contractscommerce-surface, docs-versions, docs-paths, docs-snippets, prisma-schemas, design-tokensDrift: an adapter missing a method, this site quoting a stale version, a moved file, or code that no longer exists, the two Prisma schemas diverging
Seams and adaptersdemo-commerce, auth-seam, newsletter-seam, prisma-paid-session, supabase-paid-sessionProvider selection, and adapter behaviour against fake clients

tests/sqlite-integration.test.ts is the exception to "no server" and lives in the same directory: SQLite is a file, so it creates a throwaway database, pushes the schema, and drives the actual Prisma adapter. Since Postgres shares every line outside three documented branches, what passes there is evidence for Postgres too.

Where do I go to…?

On this page