Ecommerce Boilerplate
Commerce Backends

Commerce Backends Overview

The backend-agnostic commerce layer and how to switch between SQLite, Postgres, Supabase, and Firebase.

The store's data layer lives in src/lib/commerce. It is a backend-agnostic seam: every storefront and admin call site imports one object — commerce — and never touches Prisma, the Supabase client, or Firestore directly. Moving the store from one database to another is a configuration change (two env vars plus credentials), not a code change.

Postgres through Prisma is the recommended backend: your own database, standard SQL, no vendor lock-in, and credentials that never leave the server. SQLite runs the same adapter over a file — the shortest path to real writes, and the right default while you are still building locally. Supabase is hosted Postgres behind the same code. Firebase is the alternative when you want realtime carts and no SQL to operate. Who the visitor is is a separate choice from where the data lives — see authentication.

Any storefront or admin call site
import { commerce } from "@/lib/commerce";

const page = await commerce.products.list(20);
const quote = await commerce.quotes.submit(userId, quoteData, customerInfo, message);

With no credentials configured at all, the store runs on the demo backend — a built-in fixture catalog, so a fresh clone shows a populated storefront immediately. Every real backend is opt-in: set NEXT_PUBLIC_COMMERCE_BACKEND (and COMMERCE_BACKEND for the SQL ones) to name it. The one implicit case is Firebase, which takes over automatically once NEXT_PUBLIC_FIREBASE_PROJECT_ID is set and no backend is named — so add the SQL variables in the same edit as your Firebase Auth ones. This page explains the shared layer; the per-backend pages cover setup.

Which backend is active

Selection happens in src/lib/commerce/backend.ts:

NEXT_PUBLIC_COMMERCE_BACKENDBackendNotes
unset, no Firebase project iddemoBuilt-in fixture catalog. No network, no credentials.
unset, Firebase project id setfirebaseFirestore, straight from the browser.
demodemoForces the fixture catalog even when credentials exist.
firebasefirebase
sqlite / postgres / supabaseserver adapter via /api/commerceCOMMERCE_BACKEND must match.

The demo backend

src/lib/commerce/demo serves catalog.json — the same 18 demo products npm run seed writes to a real backend, so the zero-config storefront and a seeded store show identical data.

Works: catalog listing, category filters, search, product pages, related products, the cart (guest carts live in localStorage), one discount code (DEMO10), and checkout — with no payment provider to redirect to, the cart confirms locally via /checkout/success?demo=1, empties itself, and states that nothing was charged and no order was recorded.

Rejected: every write — quote submission, admin CRUD, order fulfillment, profile edits — with a DemoModeError that names the operation and points at the setup docs. Signed-in surfaces read as empty rather than throwing, and there is no Firebase project to sign in to anyway.

Editing the demo catalog means editing src/lib/commerce/demo/catalog.json; both the adapter and the seed script read it.

The service interfaces

The full backend surface is defined by the CommerceServices interface in src/lib/commerce/types.ts. It groups every commerce operation by domain, and each backend adapter exports one object satisfying it:

src/lib/commerce/types.ts
export interface CommerceServices {
  products: ProductService;
  carts: CartService;
  quotes: QuoteService;
  orders: OrderService;
  discounts: DiscountService;
  customers: CustomerService;
  admin: AdminService;
  audit: AuditService;
}
ServiceAccess asResponsibility
ProductServicecommerce.productsCatalog browsing (including category-filtered paging and counts), search, product CRUD, and the curated "featured on home page" list
CartServicecommerce.cartsCart persistence (keyed by user id), live updates, and guest-cart merging
QuoteServicecommerce.quotesQuote-request submission and the Pending → Quoted → Accepted/Declined pipeline
OrderServicecommerce.ordersPromoting accepted quotes into orders and the Processing → Shipped → Delivered/Cancelled pipeline
DiscountServicecommerce.discountsNative discount codes for the quote flow (case-insensitive, admin-managed)
CustomerServicecommerce.customersCustomer profiles and their saved address book
AdminServicecommerce.adminAdmin role checks (isAdmin(uid))
AuditServicecommerce.auditThe admin activity trail — append-only, keyset-paginated. record is server-only and absent from the RPC whitelist; see Commerce RPC

Because the models (Product, Cart, Quote, Order, Customer, Discount, ...) are re-exported from @/lib/commerce, an adapter or a consumer never needs to import from the Firebase layer directly.

Selected method shapes

A few signatures worth knowing, copied from types.ts:

src/lib/commerce/types.ts
// Products — page a catalog at a time with an opaque cursor.
list(pageSize?: number, cursor?: PageCursor | null): Promise<ProductPage>;
// Category pages. The BACKEND filters — a filter applied after pagination
// cannot be correct, because the cursor advances past matches the page had
// no room for.
listByCategory(
  category: string,
  pageSize?: number,
  cursor?: PageCursor | null
): Promise<ProductPage>;
countByCategory(category: string): Promise<number>;
getById(id: string): Promise<Product | null>;
search(term: string, maxResults?: number): Promise<Product[]>;

// Quotes — submit a cart as a quote request; guests pass userId: null.
submit(
  userId: string | null,
  quoteData: { items: Quote["items"]; discount?: AppliedDiscount },
  customerInfo: Quote["customer"],
  message: string
): Promise<{ userQuoteId: string; mainQuoteId: string }>;

// Orders — promote an accepted quote into an order.
createFromQuote(quote: Quote): Promise<{ orderNumber: string }>;

Pagination cursors (PageCursor) are opaque tokens: receive one from a page result and hand it back unchanged to fetch the next page. In the Firebase adapter a cursor is a Firestore snapshot; a SQL adapter can encode whatever it needs. Callers must never inspect or construct one.

Three adapters, one interface

src/lib/commerce/index.ts is the single place the app binds to a backend. It binds the adapter for whichever backend src/lib/commerce/backend.ts resolved (see the table above):

src/lib/commerce/index.ts
function selectCommerce(): CommerceServices {
  switch (activeBackend()) {
    case "demo":
      return demoCommerce;
    case "http":
      return httpCommerce;
    case "firebase":
      // Admin mutations go through the server so they can be authorized and
      // audited there; everything else stays in the browser. Opt out with
      // NEXT_PUBLIC_ADMIN_WRITES=client (see ./audited-firebase.ts).
      return adminWritesGoThroughServer()
  }
}

export const commerce: CommerceServices = selectCommerce();
BackendClient adapterHow data is reached
demo (no credentials)demoCommerceServed from the bundled fixture — no network
firebasefirebaseCommerceTalks to Firestore directly from the browser
http (sqlite / postgres / supabase)httpCommerceForwards every call to the /api/commerce RPC route

An unrecognized NEXT_PUBLIC_COMMERCE_BACKEND value logs an error and falls back to Firebase instead of crashing.

SQL backends (SQLite, Postgres, Supabase): routed through the RPC layer

A SQL database is reached with privileged, server-only credentials (a DATABASE_URL connection string or file path, or the Supabase service-role key) that must never ship to the browser. So when a SQL backend is selected, the browser uses httpCommerce, which forwards each call as:

POST /api/commerce/<service>/<method>
Authorization: Bearer <firebase-id-token>   // when a user is signed in
Content-Type: application/json

{ "args": [ ...method arguments ] }

On the server, getServerCommerce() (in src/lib/commerce/server/index.ts) resolves the matching server adapter from the COMMERCE_BACKEND env var and runs the real database call:

src/lib/commerce/server/index.ts
export async function getServerCommerce(): Promise<CommerceServices> {
  switch (process.env.COMMERCE_BACKEND) {
    // One adapter, two engines — see /docs/backends/sqlite.
    case "postgres":
    case "sqlite": {
      const { prismaCommerce } = await import("./prisma");
      cached = prismaCommerce;
      return cached;
    }
    case "supabase": {
      const { supabaseCommerce } = await import("./supabase");
      cached = supabaseCommerce;
      return cached;
    }
    // "firebase" / unset: the admin-only adapter, so audited admin writes
    // run server-side. Any other value throws.
    case "firebase":
    case undefined:
    case "": {
      const { firebaseAdminCommerce } = await import("./firebase-admin");
      cached = firebaseAdminCommerce;
      return cached;
    }
  }
}

Server adapters are loaded lazily via dynamic import, so a Supabase-only deployment never pulls in Prisma's dependencies (and vice versa); inside the Prisma adapter the two driver packages are required on their own branches, so a Postgres deployment never loads better-sqlite3's native binding. The resolved adapter is cached after first use.

Firebase: client-side

With Firebase selected, firebaseCommerce runs in the browser and reads/writes Firestore directly. Access control lives in Firestore security rules rather than in a server route, so there is no RPC layer and no SQL env vars to set — and admin mutations are routed back through the server anyway, so they can be re-authorized and written to the audit log with the server's own clock.

Four methods are missing from the RPC whitelist, for two different reasons. carts.mergeCarts is a pure function run locally and carts.listen polls the RPC route (an immediate get, then every 5s) since RPC has no realtime push — neither needs a server. audit.record and orders.recordPaidSession are the opposite: server-only, because an HTTP-callable audit write or paid-order write taking caller-supplied arguments would let anyone forge one. Everything else maps 1:1 to an RPC method.

Identity is a second, independent seam

Whichever backend stores the data, the RPC route asks one question — who is calling? — through getAuthUser() in src/lib/auth, and AUTH_PROVIDER decides who answers it: Firebase (an ID token riding on Authorization: Bearer, verified with firebase-admin) or Auth.js (an httpOnly session cookie, sessions stored as rows in your SQL database). The route enforces the method's access level — public / user / admin — against whatever uid comes back, and never learns which provider produced it. Guest calls, meaning public catalog reads and a guest quotes.submit (userId null), carry no credential at all.

Firebase is the default, which is why the FIREBASE_SERVICE_ACCOUNT* credentials are listed as required even for the SQL backends: unless you set AUTH_PROVIDER=authjs, the server needs them to verify ID tokens. Auth.js drops the Firebase project entirely, but only on a SQL backend — firestore.rules gate on request.auth, so Firestore-as-data stays paired with Firebase Auth. See authentication.

The two variables that must agree

Selecting a SQL backend means setting both halves — the client var and the server var — to the same value, plus that backend's credentials:

.env
# firebase (or unset): pure client-side, no RPC route, no SQL vars.
NEXT_PUBLIC_COMMERCE_BACKEND=firebase
COMMERCE_BACKEND=

# sqlite | postgres | supabase: set BOTH to the same value + matching creds.
VariableRead bySelects
NEXT_PUBLIC_COMMERCE_BACKENDThe browser (index.ts)The client adapter: firebaseCommerce or httpCommerce
COMMERCE_BACKENDThe server (getServerCommerce) and the Prisma CLIThe server adapter (prismaCommerce, supabaseCommerce, or the admin-only firebaseAdminCommerce), its SQL dialect, and which schema file prisma.config.ts loads

If the two disagree, the store breaks. Setting NEXT_PUBLIC_COMMERCE_BACKEND=supabase while leaving COMMERCE_BACKEND empty makes the browser route through RPC, but the server resolves the Firebase admin adapter instead, whose shopper-side methods throw — so the route returns HTTP 500 on the first catalog read. COMMERCE_BACKEND=firebase is not a misconfiguration on its own: it is what a default Firebase deployment uses for audited admin writes. An unrecognized value does throw. See the full list on environment variables.

Lifecycle events

The layer also exposes a small, typed, dependency-free event bus (src/lib/commerce/events.ts). Every successful mutation emits an event, so integrations — analytics, webhooks, sync jobs — subscribe instead of forking core files:

Subscribe to commerce events
import { commerceEvents } from "@/lib/commerce";

const off = commerceEvents.on("order.placed", ({ orderNumber, quote }) => {
  console.log(`Order ${orderNumber} placed from quote ${quote.quoteNumber}`);
});
// later: off();

Emissions are fire-and-forget and post-success only. See the events page for the full event map.

Where to go next

On this page