Acme Store Docs
Commerce Backends

Postgres / Prisma Backend

Run the commerce layer on any PostgreSQL database via the Prisma adapter.

The commerce layer ships with a server-side Prisma/PostgreSQL adapter that is a drop-in replacement for the default Firebase backend: same CommerceServices interface, same lifecycle events, same read shapes. It lets you run the entire store — catalog, carts, quote requests, orders, discounts — on any Postgres database you control (local, Supabase, Neon, RDS, a container).

  • Adapter: src/lib/commerce/server/prisma.tsexport const prismaCommerce
  • Schema: prisma/schema.prisma
  • CLI config: prisma.config.ts

Firebase is the zero-config default. You only need the steps below if you deliberately switch the backend to Postgres. If you are staying on Firebase, you do not need Prisma, a DATABASE_URL, or prisma generate at all.

Prisma Postgres / Accelerate is NOT used

This project does not use Prisma Postgres or Prisma Accelerate — there is no Prisma account, login, or API key involved anywhere. DATABASE_URL must be a plain postgresql:// connection string, and it must be present in the environment before you run npm run prisma:generate.

Prisma 7's CLI has an onboarding flow for its own hosted database (Prisma Postgres + Accelerate). If DATABASE_URL is missing — or points at a prisma:// / prisma+postgres:// URL — the CLI falls back to that onboarding and prompts you to log in and issue an API key. With a real postgresql://... string set, prisma generate and prisma db push run non-interactively and never ask for an account or key.

Prisma 7 specifics

This project ships Prisma 7, which changes two things the adapter relies on:

  1. No datasource.url in schema.prisma. Prisma 7 removed that property. The connection string the CLI needs lives in prisma.config.ts instead (datasource.url = env("DATABASE_URL")), and the runtime client gets it through a driver adapter.
  2. The runtime client needs a driver adapter (not a bare URL). This adapter builds a @prisma/adapter-pg client over DATABASE_URL. The @prisma/client, @prisma/adapter-pg, and pg packages ship in the base dependencies — no extra install is needed; you only generate the client once a DATABASE_URL is set.
prisma.config.ts
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  datasource: {
    url: env("DATABASE_URL"),
  },
});

Setup

Set DATABASE_URL

Use a plain PostgreSQL connection string. Any Postgres works. Export it in your shell or put it in a .env file your process loads — it must be present both for the Prisma CLI (read via prisma.config.ts) and at runtime (the adapter reads process.env.DATABASE_URL on first use).

.env
# Local Postgres
DATABASE_URL="postgresql://user:password@localhost:5432/ecommerce"

# Supabase (Project Settings → Database). The pooled "Transaction"
# string works well for serverless:
DATABASE_URL="postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres"

Set this before the next step. prisma generate only needs the string to parse (it does not connect), but if it is missing the CLI drops into Prisma's hosted-database onboarding and asks for an API key.

Generate the client

Terminal
npm run prisma:generate   # == npx prisma generate

This produces the typed client that @prisma/client re-exports. Re-run it after every schema change (and after a fresh npm install on a Postgres deploy).

Create the tables

For an existing or shared database where you do not want migration history (e.g. prototyping, or applying onto a Supabase project), push the schema directly:

Terminal
npm run prisma:push   # == npx prisma db push

For local development, let Prisma create and track the schema with a migration:

Terminal
npx prisma migrate dev --name init

Both read the connection string from prisma.config.ts.

Grant admin access

admin.isAdmin(uid) checks row existence in the admins table, keyed by the Firebase Auth uid. Insert a row per admin:

insert into admins (uid) values ('<firebase-auth-uid>')
on conflict (uid) do nothing;

Activate the backend

Backend selection is env-driven — you do not edit any source file. Set both variables so the browser and the server agree:

.env
NEXT_PUBLIC_COMMERCE_BACKEND=postgres   # browser: route commerce calls to the RPC layer
COMMERCE_BACKEND=postgres               # server: run the Prisma adapter

The browser never talks to Postgres directly. It calls a single RPC route (POST /api/commerce/<service>/<method>) that verifies the caller's Firebase ID token, enforces per-method access control (public / user-scoped / admin), and then runs prismaCommerce with the privileged DATABASE_URL. Because Firebase Auth stays the identity provider, the FIREBASE_SERVICE_ACCOUNT* credentials are also required to verify tokens — see environment variables.

Bundling: serverComponentsExternalPackages

The optional SQL-backend packages are kept out of the bundle and require()d at runtime inside the adapter. next.config.mjs lists them as external so a default (Firebase) build succeeds even without a generated client, and so the Postgres adapter can load the generated client at runtime:

next.config.mjs
experimental: {
  serverComponentsExternalPackages: [
    "@prisma/client",
    "@prisma/adapter-pg",
    "pg",
  ],
},

This pairs with the adapter's lazy client: @prisma/client and @prisma/adapter-pg are loaded via require inside getPrisma() (not a top-level import) so the file type-checks and builds with no generated client. The instance is cached on globalThis so hot reload does not open a new pool per edit, and the first actual operation — not import — throws if DATABASE_URL is unset.

Deploying on Postgres

A fresh CI environment has no generated client, so the Prisma client must be generated during the build. Set the build command (or add a postinstall) to run generation first:

Vercel Build Command
npm run prisma:generate && npm run build

The deploy environment must include:

VariablePurpose
DATABASE_URLPlain postgresql:// string the adapter and CLI connect with.
NEXT_PUBLIC_COMMERCE_BACKEND=postgresBrowser routes commerce calls to the RPC layer.
COMMERCE_BACKEND=postgresServer runs the Prisma adapter.
FIREBASE_SERVICE_ACCOUNT_JSONVerifies caller ID tokens (Firebase Auth stays the identity provider).

Run npm run prisma:push (or your migration step) against the production database once, out of band, to create the tables.

Deploying on Firebase but the build still asks for a Prisma API key? Some platforms (notably Vercel) auto-detect prisma/schema.prisma + @prisma/client and inject prisma generate into the install step. With no DATABASE_URL present, that generate drops into Prisma's hosted-database onboarding and prompts for a key — even though your app never uses Postgres at runtime. Two fixes, either works:

  1. Give the build a placeholder DATABASE_URL, e.g. postgresql://user:pass@localhost:5432/db. prisma generate only needs the string to parse, so generation runs non-interactively; the value is never used at runtime while COMMERCE_BACKEND is unset/firebase.
  2. Or disable auto-generate on your platform (on Vercel, set the build command explicitly to npm run build) so Prisma is never invoked.

Data model

The schema (prisma/schema.prisma) mirrors the Firestore layout the Firebase adapter uses, with loose/schemaless documents stored in Json columns so unknown fields round-trip exactly like Firestore documents.

ModelTableKeyNotes
Productproductscaller id (e.g. AUD-001)Body in data Json; productNameLower and brand are promoted, indexed columns the adapter keeps in sync. Variants/options live inside data.
FeaturedProductfeatured_productscuid()Full copy of a product body; productId promoted back-reference.
CartcartsuserId (auth uid)items is a JSON CartItem[]; clear deletes the row (missing ≠ empty).
Quotequotescuid()items/customer/discount are Json; orderNumber claimed atomically on conversion.
Orderorderscuid()Created by promoting quotes; total is a Float.
Customercustomersid (auth uid)Rigid: firstName, lastName, phone, email only.
Addressaddressescuid()Schemaless data Json + indexed customerId (no FK).
Discountdiscountstrimmed UPPERCASE codeSaves are single-row upserts; lookups case-insensitive by construction.
AdminadminsuidRow existence grants admin.

Behavioral notes worth knowing:

  • Prefix search uses COLLATE "C". products.search pins its range bounds and ordering to byte-order collation so results match Firestore's code-point ordering regardless of the database's default collation.
  • Per-customer quote/order copies are flattened. listForCustomer is a WHERE customer_id = $uid query; the copy identity is synthesized on read (id === quoteNumber/orderNumber, mainQuoteId === <main record id> on quotes).
  • carts.listen polls (immediate fetch + 5s interval) — realtime push is Firestore-only for now. The callback contract is otherwise identical.
  • PageCursor is a base64url-encoded keyset token (JSON-serializable so it survives HTTP round-trips), not a Firestore snapshot.

Using a different database

The schema uses no Postgres-only features (no scalar lists, enums, or @db.* native types), so the same adapter code runs on MySQL or SQLite. Change datasource.provider in prisma/schema.prisma, swap the driver-adapter package passed to new PrismaClient({ adapter }) in src/lib/commerce/server/prisma.ts (e.g. @prisma/adapter-mariadb for MySQL, @prisma/adapter-better-sqlite3 for SQLite), give products.search an engine-appropriate binary collation (MySQL utf8mb4_bin, SQLite COLLATE BINARY), then regenerate and migrate.

On this page