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:
activeBackend()(lib/commerce/backend.ts) is read in the browser too, which is why the choice lives inNEXT_PUBLIC_COMMERCE_BACKENDand its server twinCOMMERCE_BACKEND— the two must name the same backend.- Server components never take the HTTP hop.
lib/commerce/catalog.tsresolves the server adapter whentypeof window === "undefined", so a catalog page on Postgres queries in-process instead of fetching a relative URL Node cannot resolve. - Privileged writes always cross the server, even on Firebase. That is what
makes
server/audit.tsable to record who changed what with a clock the acting admin does not control.
What a page actually does
Take /{locale}/products?category=audio:
| Step | Where |
|---|---|
| Resolve locale and currency from the URL and cookies | [locale]/layout.tsx + lib/{i18n,currency}/server.ts |
| Fetch the page of products | lib/commerce/server/cached-catalog.ts → catalog.ts → the active adapter's listByCategory |
| Render the grid server-side, hydrate the filters | products/page.tsx → components/product/ProductCatalog.tsx |
| Add to cart | contexts/cartContext.tsx (guest carts in localStorage) |
| Check out | api/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
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.mdA 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/
├─ 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:
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]:
| Segment | Route | Purpose |
|---|---|---|
page.tsx | /{locale} | Landing page (hero, categories, featured, contact) |
products/ | /{locale}/products | Catalog + product detail pages |
cart/ | /{locale}/cart | Cart review |
checkout/ | /{locale}/checkout | Quote-request (and optional Stripe) checkout |
login/ | /{locale}/login | Google + email-link sign-in |
complete-login/ | /{locale}/complete-login | Passwordless email-link landing |
account/ | /{locale}/account | Profile, addresses, quote/order history |
admin/ | /{locale}/admin | Admin dashboard (role-gated) |
platform/ | /{locale}/platform | Marketing page for the boilerplate itself |
about/ | /{locale}/about | About 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.
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 probeThe /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:
const withMDX = createMDX();
export default withMDX(nextConfig);src/components
Presentational and interactive components, grouped by the surface they serve:
| Folder | What 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:
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 loaderauth/ 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.
| File | Role |
|---|---|
types.ts | Backend-neutral interfaces: ProductService, CartService, QuoteService, OrderService, DiscountService, CustomerService, AdminService, AuditService (the eight grouped as CommerceServices) |
backend.ts | activeBackend() — resolves demo / firebase / http from the env, without importing any adapter |
index.ts | Binds commerce to the active adapter and re-exports everything |
catalog.ts | Catalog queries usable from a server component: category pages, related products, generateStaticParams ids |
events.ts | Typed, 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.ts | Firebase adapter — delegates to FirebaseDBService, runs client-side |
audited-firebase.ts | firebaseCommerce with the ten admin mutations routed through the server so they can be re-authorized and audited |
firebase-audit.ts | The 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.ts | Client adapter for the SQL backends — forwards each call to the /api/commerce RPC route |
Server-only, never bundled for the browser:
| File | Role |
|---|---|
server/index.ts | getServerCommerce() — 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.ts | Admin-surface adapter for the Firebase backend (Admin SDK); shopper methods throw |
server/audit.ts | Records who changed what, with the server's clock and the uid from the verified token |
server/cursor.ts | Keyset cursors for the SQL adapters — a record of keys, so ordering by a non-unique column can carry its tiebreaker |
server/cached-catalog.ts | unstable_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.
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):
| Path | Used by |
|---|---|
prisma/schema.prisma | COMMERCE_BACKEND=postgres |
prisma/schema.sqlite.prisma | COMMERCE_BACKEND=sqlite — identical models, SQLite provider |
prisma.config.ts | Picks between the two by COMMERCE_BACKEND (Prisma will not read the provider from an env var) |
supabase/schema.sql | COMMERCE_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 theadminsrole 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.
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.
| Group | Examples | What it protects |
|---|---|---|
| Pure logic | money, discounts, inventory, order-total, i18n, theme, phone-mask, analytics, cart-line-key | The framework-agnostic seams in src/lib |
| Route handlers | api-checkout, api-stripe-webhook, api-commerce-rpc, api-newsletter | The places a mistake costs money or leaks data — server-side re-pricing, webhook idempotency, and the public/user/admin access matrix |
| Contracts | commerce-surface, docs-versions, docs-paths, docs-snippets, prisma-schemas, design-tokens | Drift: 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 adapters | demo-commerce, auth-seam, newsletter-seam, prisma-paid-session, supabase-paid-session | Provider 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.