Acme Store Docs
Commerce Backends

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.

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);

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:

src/lib/commerce/types.ts
export interface CommerceServices {
  products: ProductService;
  carts: CartService;
  quotes: QuoteService;
  orders: OrderService;
  discounts: DiscountService;
  customers: CustomerService;
  admin: AdminService;
}
ServiceAccess asResponsibility
ProductServicecommerce.productsCatalog browsing, 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))

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>;
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:

src/lib/commerce/index.ts
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_BACKENDClient adapterHow data is reached
unset / firebase (default)firebaseCommerceTalks to Firestore directly from the browser
supabase / postgreshttpCommerceForwards 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:

src/lib/commerce/server/index.ts
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:

.env
# 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.
VariableRead bySelects
NEXT_PUBLIC_COMMERCE_BACKENDThe browser (index.ts)The client adapter: firebaseCommerce or httpCommerce
COMMERCE_BACKENDThe 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:

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