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.
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_BACKEND | Backend | Notes |
|---|---|---|
| unset, no Firebase project id | demo | Built-in fixture catalog. No network, no credentials. |
| unset, Firebase project id set | firebase | Firestore, straight from the browser. |
demo | demo | Forces the fixture catalog even when credentials exist. |
firebase | firebase | |
sqlite / postgres / supabase | server adapter via /api/commerce | COMMERCE_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:
export interface CommerceServices {
products: ProductService;
carts: CartService;
quotes: QuoteService;
orders: OrderService;
discounts: DiscountService;
customers: CustomerService;
admin: AdminService;
audit: AuditService;
}| Service | Access as | Responsibility |
|---|---|---|
ProductService | commerce.products | Catalog browsing (including category-filtered paging and counts), search, product CRUD, and the curated "featured on home page" list |
CartService | commerce.carts | Cart persistence (keyed by user id), live updates, and guest-cart merging |
QuoteService | commerce.quotes | Quote-request submission and the Pending → Quoted → Accepted/Declined pipeline |
OrderService | commerce.orders | Promoting accepted quotes into orders and the Processing → Shipped → Delivered/Cancelled pipeline |
DiscountService | commerce.discounts | Native discount codes for the quote flow (case-insensitive, admin-managed) |
CustomerService | commerce.customers | Customer profiles and their saved address book |
AdminService | commerce.admin | Admin role checks (isAdmin(uid)) |
AuditService | commerce.audit | The 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:
// 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):
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();| Backend | Client adapter | How data is reached |
|---|---|---|
demo (no credentials) | demoCommerce | Served from the bundled fixture — no network |
firebase | firebaseCommerce | Talks to Firestore directly from the browser |
http (sqlite / postgres / supabase) | httpCommerce | Forwards 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:
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:
# 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.| Variable | Read by | Selects |
|---|---|---|
NEXT_PUBLIC_COMMERCE_BACKEND | The browser (index.ts) | The client adapter: firebaseCommerce or httpCommerce |
COMMERCE_BACKEND | The server (getServerCommerce) and the Prisma CLI | The 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:
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.