Commerce Backends Overview
The backend-agnostic commerce layer and how to switch between Firebase, Supabase, and Postgres.
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 Firestore, Prisma, or the Supabase client directly. Swapping the store from Firebase to a SQL database is a configuration change (two env vars plus credentials), not a code change.
import { commerce } from "@/lib/commerce";
const page = await commerce.products.list(20);
const quote = await commerce.quotes.submit(userId, quoteData, customerInfo, message);Firebase is the default and requires no extra setup — the store runs entirely client-side against Firestore. Supabase and Postgres are opt-in. This page explains the shared layer; the per-backend pages cover setup.
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;
}| Service | Access as | Responsibility |
|---|---|---|
ProductService | commerce.products | Catalog browsing, 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)) |
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>;
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.
Two adapters, one interface
src/lib/commerce/index.ts is the single place the app binds to a backend. It picks the client-visible adapter from the NEXT_PUBLIC_COMMERCE_BACKEND env var:
function selectCommerce(): CommerceServices {
switch (process.env.NEXT_PUBLIC_COMMERCE_BACKEND) {
case "supabase":
case "postgres":
return httpCommerce;
case "firebase":
case undefined:
case "":
return firebaseCommerce;
default:
// Unknown value: fall back to firebase rather than crash on a typo.
return firebaseCommerce;
}
}
export const commerce: CommerceServices = selectCommerce();NEXT_PUBLIC_COMMERCE_BACKEND | Client adapter | How data is reached |
|---|---|---|
unset / firebase (default) | firebaseCommerce | Talks to Firestore directly from the browser |
supabase / postgres | httpCommerce | Forwards every call to the /api/commerce RPC route |
An unrecognized value logs an error and falls back to Firebase instead of crashing.
Firebase (default): client-side
With Firebase selected, firebaseCommerce runs in the browser and reads/writes Firestore directly. Access control lives in Firestore security rules. There is no server RPC layer and no SQL env vars to set.
SQL backends: routed through the RPC layer
A Supabase or Postgres database is reached with privileged, server-only credentials (the Supabase service-role key, or a direct DATABASE_URL connection string) 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> {
const backend = process.env.COMMERCE_BACKEND;
switch (backend) {
case "postgres": return (await import("./prisma")).prismaCommerce;
case "supabase": return (await import("./supabase")).supabaseCommerce;
// "firebase" / unset / other → throws (misconfiguration)
}
}Server adapters are loaded lazily via dynamic import, so a Supabase-only deployment never pulls in Prisma's dependencies (and vice versa). The resolved adapter is cached after first use.
carts.listen and carts.mergeCarts have no HTTP surface. mergeCarts is a pure function run locally, and listen polls the RPC route (an immediate get, then every 5s) since RPC has no realtime push. Everything else in CommerceServices maps 1:1 to an RPC method.
Firebase Auth is the identity provider for every backend
Regardless of which backend stores the commerce data, Firebase Auth stays the identity provider. With a SQL backend, the signed-in user's Firebase ID token rides along on each RPC as Authorization: Bearer <token>; the route verifies it with firebase-admin and enforces per-method access control (public / user / admin). Guest calls — public catalog reads and a guest quotes.submit (userId null) — simply omit the header.
This is why the FIREBASE_SERVICE_ACCOUNT* credentials are required even for the SQL backends: the server needs them to verify ID tokens.
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=
# supabase | postgres: 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) | The server adapter: prismaCommerce or supabaseCommerce |
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 getServerCommerce() throws (COMMERCE_BACKEND is not set) and the route returns HTTP 500. COMMERCE_BACKEND=firebase also throws — Firebase has no server adapter. 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.