Ecommerce Boilerplate
Commerce Backends

Commerce RPC Layer

How the browser reaches SQL backends through one authenticated RPC route.

The boilerplate ships several commerce backends behind one interface (CommerceServices). The default quote-based store runs on Firebase, where the browser talks to Firestore directly. But the SQL backends — SQLite, Postgres/Prisma, and Supabase — hold their credentials server-side (a connection string or file path, a Supabase service-role key), so the browser cannot reach the database. Instead, every commerce call is forwarded as a small RPC to a single Next.js route that runs the matching server adapter.

This page covers the architecture. For the exhaustive per-method reference — every service, method, access level, and payload — see the Commerce RPC API reference.

This layer carries every commerce call for the server-backed values of NEXT_PUBLIC_COMMERCE_BACKEND (sqlite / postgres / supabase). On the default firebase backend the browser reads and writes Firestore directly, and only the ten admin mutations come back through here — so they can be re-authorized and audited server-side. See audited admin writes.

The big picture

Request flow
Component / hook
      │  import { commerce } from "@/lib/commerce"
      ▼
httpCommerce  (src/lib/commerce/http.ts)     ← client adapter
      │  POST /api/commerce/<service>/<method>
      │  Authorization: Bearer <firebase-id-token>   (or an Auth.js cookie)
      │  { "args": [...] }
      ▼
route.ts  (src/app/api/commerce/[service]/[method]/route.ts)   ← runtime: nodejs
      │  1. whitelist check
      │  2. parse { args }
      │  3. resolve server backend
      │  4. access control (identify the caller)
      │  5. dispatch → server adapter method
      ▼
getServerCommerce()  →  Prisma (Postgres/SQLite) or Supabase adapter
      │
      ▼
   { "result": <value> }   or   { "error": string }

Call sites never import httpCommerce directly. They import the backend-agnostic commerce object from @/lib/commerce, and src/lib/commerce/index.ts picks the HTTP adapter when a server backend is selected. The same component code works against Firebase, Postgres, or Supabase.

The client adapter: httpCommerce

src/lib/commerce/http.ts is the client-side implementation of CommerceServices. Every method funnels through one rpc() helper:

src/lib/commerce/http.ts
export async function rpcCall(
  service: string,
  method: string,
  args: unknown[]
): Promise<unknown> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    ...(await authHeaders()),
  };

  const response = await fetch(`/api/commerce/${service}/${method}`, {
    method: "POST",
    headers,
    body: JSON.stringify({ args }),
  });
  // ...unwraps { result } on success, throws body.error on failure
}

authHeaders() (src/lib/auth/client.ts) is where the provider difference lives: a Firebase ID token as Authorization: Bearer, or nothing at all on Auth.js, whose httpOnly session cookie the browser attaches by itself. This transport does not know which is configured. See authentication.

Three more properties fall out of the same shape:

  • One method → one POST. The service and method become the URL path; the method's positional arguments become { args: [...] }. products.getById(id) posts to /api/commerce/products/getById with { "args": ["<id>"] }.
  • The token is attached only when a user is signed in. Public reads and guest quote submissions omit the Authorization header. A token-refresh failure is logged, not thrown — the request is sent unauthenticated and the route decides the outcome (401/403).
  • Success unwraps result; failure throws. A non-2xx response reads { error } from the body and throws it as an Error, so call sites get a normal rejected promise.

The four methods with no HTTP surface

Two of them are CartService members implemented locally in the adapter:

MethodWhy it stays client-side
carts.mergeCartsA pure, synchronous function (reuses the same cartLineKey merge logic as the Firebase adapter). No server state involved.
carts.listenRPC has no realtime push, so it polls: an immediate carts.get, then another every 5s (CART_POLL_INTERVAL_MS). This matches the server adapters' poll contract.

Because these two are handled entirely in the browser, they are deliberately absent from the route's whitelist (see below) — there is nothing for the server to dispatch.

Two more are absent for the opposite reason — they are server-only:

MethodWhy it never gets an HTTP surface
orders.recordPaidSessionThe Stripe webhook calls the server adapter directly after verifying the event signature. Exposing it here would let anyone mint a paid order.
audit.recordThe route writes audit entries itself, in-process, with the actor taken from the verified caller. An HTTP-callable write taking a caller-supplied actorUid would let an admin forge or flood the very trail that exists to hold them accountable.

The route

src/app/api/commerce/[service]/[method]/route.ts exposes a single POST handler. It runs on the Node.js runtime, not Edge — firebase-admin, the pg pool, and the Supabase client all require it, and every request resolves live data:

src/app/api/commerce/[service]/[method]/route.ts
export const runtime = "nodejs";

The handler processes each request in five steps.

Whitelist check. The service.method pair is looked up in a static WHITELIST. Anything not listed returns 404 with { "error": "Unknown commerce method: <service>.<method>" }. An unknown method is indistinguishable from a non-existent route — the database is never touched for calls that aren't explicitly allowed.

Parse the body. The body must be exactly { args: unknown[] }. A non-array args yields 400 ("Request body must be { args: unknown[] }."); invalid JSON yields 400 ("Request body must be valid JSON.").

Resolve the backend. getServerCommerce() returns the adapter COMMERCE_BACKEND names: Prisma (Postgres or SQLite), Supabase, or — on a default Firebase deployment — the admin-only firebaseAdminCommerce. If it throws (an unrecognized value), the route logs and returns 500 ("Commerce backend is not configured.").

Access control. For any non-public method the caller is resolved with getAuthUser(request) (from src/lib/auth) and checked against the method's access level. See Access levels.

Dispatch. Date arguments are revived (see Dates on the wire), then the adapter method is invoked with the spread args. The return value is wrapped as { result: result ?? null }. A thrown adapter error is logged and returned as 500 ("<service>.<method> failed."). On success, a catalog mutation drops the cached catalog reads server components use, and an admin mutation is written to the audit trail before the response goes out — awaited, so the entry is durable before the admin sees success, but never able to fail the request.

Access levels

Every whitelisted method carries one of three access levels, enforced against whatever uid getAuthUser() returns. Which provider supplies that uid — a verified Firebase ID token, or an Auth.js session cookie — is a separate configuration the route deliberately cannot see.

LevelRule enforced by the route
publicNo auth. Catalog reads (products.list, listByCategory, countByCategory, getById, search, listFeatured) and discount lookup (discounts.findUsable, plus discounts.listAutomatic — automatic promotions need no secret code, since the cart applies them for everyone).
userA valid token is required, and the method's uid argument — always args[0] — must equal the token's uid. A signed-in user can only act on their own cart, quotes, orders, and profile.
adminA valid token is required and backend.admin.isAdmin(uid) must return true for the backend in use.

The core of the check:

route.ts — access control
const caller = await getAuthUser(request);

if (service === "quotes" && method === "submit") {
  // Guests (userId null) may submit; a non-null userId must be the caller.
  if (uidArg != null) {
    if (!caller) return error("Authentication required.", 401);
    if (uidArg !== caller.uid) {
      return error("You may only submit quotes as yourself.", 403);
    }
  }
} else if (access === "user") {
  if (!caller) return error("Authentication required.", 401);
  if (typeof uidArg !== "string" || uidArg !== caller.uid) {
    return error("You may only act on your own account.", 403);
  }
} else {
  // admin
  if (!caller) return error("Authentication required.", 401);
  const isAdmin = await backend.admin.isAdmin(caller.uid);
  if (!isAdmin) return error("Admin privileges required.", 403);
}

Two deliberate exceptions

  • quotes.submit allows a null userId. A guest checkout submits a quote with no account, so args[0] may be null and no token is needed. But a non-null userId must still match the token — you cannot submit a quote in someone else's name.
  • admin.isAdmin is a user method, not admin. It is exposed at the user level so the client can check its own admin status (its uid argument must match the token). The route also calls backend.admin.isAdmin internally to gate admin-level methods.

The user check requires args[0] to be a non-empty string equal to the token uid. Every user-level client method therefore passes the caller's uid as its first argument — that ordering is not incidental, it is the security boundary.

Dates on the wire

JSON has no Date type. The route and client cooperate so that call sites see real Date objects regardless of backend:

  • Outbound (client → route). Some methods take arguments containing dates (a discount's created / expiresAt; a quote or order created). These cross the wire as ISO strings, so reviveArgDates in the route converts them back to Dates before handing the args to the adapter. It is targeted per method — not a blind deep-walk — so schemaless string fields on products or customers are never mistaken for timestamps.
  • Inbound (route → client). Dates in the result serialize to ISO strings automatically via JSON.stringify / Date.prototype.toJSON. The httpCommerce adapter then revives the known timestamp fields — Quote.created, Order.created, Discount.created, and Discount.expiresAt — so the shapes match exactly what the Firebase adapter returns.

This symmetry is what lets the same UI code consume any backend without special-casing date handling.

Request and response by example

Fetching a page of products (public, no token):

Public read
POST /api/commerce/products/list HTTP/1.1
Content-Type: application/json

{ "args": [24, null] }
200 OK
{ "result": { "products": [/* … */], "cursor": "…" } }

A signed-in user reading their own cart (user level — args[0] is their uid):

Authenticated read
POST /api/commerce/carts/get HTTP/1.1
Content-Type: application/json
Authorization: Bearer <firebase-id-token>   # or a session cookie on Auth.js

{ "args": ["<caller-uid>"] }

A mismatched uid is rejected before the database is touched:

403 Forbidden
{ "error": "You may only act on your own account." }

Where to go next

On this page