Acme Store Docs
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 entirely on Firebase, where the browser talks to Firestore directly. But the SQL backends — Postgres/Prisma and Supabase — hold their credentials server-side (a connection string, 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 explains the architecture: how a client call becomes an HTTP request, how the route authenticates and authorizes it, and how data crosses the wire. For the exhaustive per-method reference (every service, method, access level, and payload), see the Commerce RPC API reference.

This layer only exists for the server-backed values of NEXT_PUBLIC_COMMERCE_BACKEND (supabase / postgres). With the default firebase backend the browser reads and writes Firestore directly and this route is never called.

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>
      │  { "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 (verify Firebase ID token)
      │  5. dispatch → server adapter method

getServerCommerce()  →  Postgres/Prisma 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
async function rpc(
  service: string,
  method: string,
  args: unknown[]
): Promise<unknown> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
  };
  const user = auth.currentUser;
  if (user) {
    try {
      headers.Authorization = `Bearer ${await user.getIdToken()}`;
    } catch (error) {
      console.error("[commerce.http] could not get ID token:", error);
    }
  }

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

A few architectural properties fall out of this:

  • 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 simply 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.

Two methods that never hit the network

CartService has two members with no HTTP surface, 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.

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";
export const dynamic = "force-dynamic";

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 configured Postgres or Supabase adapter. If it throws (misconfiguration), the route logs and returns 500 ("Commerce backend is not configured.").

Access control. For any non-public method the caller's Firebase ID token is verified with verifyIdToken (from src/lib/firebase/admin.ts) 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.").

Access levels

Every whitelisted method carries one of three access levels. Firebase Auth is the identity provider for all backends — the SQL adapters still trust a verified Firebase ID token to identify the caller.

LevelRule enforced by the route
publicNo auth. Catalog reads (products.list, getById, search, listFeatured) and discount lookup (discounts.findUsable).
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
if (service === "quotes" && method === "submit") {
  // Guests (userId null) may submit; a non-null userId must be the caller.
  if (uidArg != null) {
    if (!decoded) return error("Authentication required.", 401);
    if (uidArg !== decoded.uid) {
      return error("You may only submit quotes as yourself.", 403);
    }
  }
} else if (access === "user") {
  if (!decoded) return error("Authentication required.", 401);
  if (typeof uidArg !== "string" || uidArg !== decoded.uid) {
    return error("You may only act on your own account.", 403);
  }
} else {
  // admin
  if (!decoded) return error("Authentication required.", 401);
  const isAdmin = await backend.admin.isAdmin(decoded.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>

{ "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