Supabase Backend
Run the commerce layer on Supabase Postgres via the server-side adapter.
The Supabase adapter runs the entire commerce layer — catalog, carts, quotes, orders, customers, discounts, admin checks, and the admin audit trail — on Supabase Postgres instead of the default Firestore. It lives in src/lib/commerce/server/supabase/index.ts and implements the full CommerceServices interface (src/lib/commerce/types.ts), so it is a behavioral drop-in for the Firebase adapter: identical read shapes and defaulting rules, the same Q-YYYYMMDD-NNNNXXXX / O-YYYYMMDD-NNNNXXXX quote and order numbers, the same denormalized per-customer copies, and the same lifecycle events on the commerce event bus.
The bundled demo catalog is the zero-config default, so you do not need a Supabase project, SUPABASE_URL, or a service-role key to run or deploy this boilerplate. Everything below applies only if you pick Supabase as the data backend. Sign-in is a separate choice you still have to make — Firebase Auth is the default, and AUTH_PROVIDER=authjs is the alternative. See authentication.
How it fits together
The Supabase adapter is server-side only. It connects with the Supabase service-role key, which bypasses Row Level Security, so it must be reached exclusively from Next.js API routes / server code — never imported into a client component.
The browser never touches the service-role key. Instead it calls a single RPC route, POST /api/commerce/[service]/[method] (src/app/api/commerce/[service]/[method]/route.ts), which:
- identifies the caller through
getAuthUser()(src/lib/auth), - enforces per-method access control (public / user-scoped / admin), then
- runs
supabaseCommercewith the service-role connection.
Step 1 is where the identity provider is pluggable. Firebase Auth is the default, so the FIREBASE_SERVICE_ACCOUNT* credentials are required by default — the server needs them to verify ID tokens. Under AUTH_PROVIDER=authjs the credential is a session cookie instead and no Firebase project is involved. See authentication and environment variables.
The adapter itself performs no authentication or authorization — the service-role key bypasses RLS entirely. All access control lives in the API layer (caller identification plus admin.isAdmin checks). Never import supabaseCommerce from client code, and never expose SUPABASE_SERVICE_ROLE_KEY to the browser (i.e. never prefix it with NEXT_PUBLIC_).
Setup
Create a Supabase project
Sign in at supabase.com and create a new project. Pick any region and database password — the adapter only needs the project's REST URL and service-role key.
Run the schema
Open the project's SQL Editor, paste the entire contents of supabase/schema.sql (reproduced in full under Schema reference below), and run it. The file is idempotent (create table if not exists ...), so re-running it is safe.
This creates all fourteen tables — products, product_categories, featured_products, carts, quotes, customer_quotes, orders, customer_orders, customers, customer_addresses, discounts, admins, audit_log, newsletter_signups — plus their indexes, and enables Row Level Security with no policies on every table. With RLS on and no policies, the anon/authenticated PostgREST roles are locked out; only the service-role connection used by the adapter gets through. See the schema reference below for details.
Set the Supabase environment variables
Set these where your server code runs (.env.local in development). Both values live in the Supabase dashboard under Project Settings → API (URL and the service_role secret):
SUPABASE_URL=https://<project-ref>.supabase.co
SUPABASE_SERVICE_ROLE_KEY=<service_role key>Do not prefix these with NEXT_PUBLIC_. The service-role key must never reach the browser. The client is lazily initialized: importing the module without these vars does not throw; the first actual database operation does, with a clear error message.
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 (see .env.example):
NEXT_PUBLIC_COMMERCE_BACKEND=supabase # browser: route commerce calls to the RPC layer
COMMERCE_BACKEND=supabase # server: run the Supabase adapterThey must match. A mismatch means the browser and server disagree on where the data lives, and the RPC route answers 500 on the first read.
Grant admin access
admin.isAdmin(uid) checks row existence in the admins table, keyed by the auth uid. Insert one row per admin:
insert into admins (uid) values ('<auth-uid>')
on conflict (uid) do nothing;Required environment variables
| Variable | Where | Purpose |
|---|---|---|
NEXT_PUBLIC_COMMERCE_BACKEND=supabase | browser + server | Routes browser commerce calls to the RPC layer. |
COMMERCE_BACKEND=supabase | server only | Resolves the server adapter to supabaseCommerce. |
SUPABASE_URL | server only | Project REST URL, e.g. https://<project-ref>.supabase.co. |
SUPABASE_SERVICE_ROLE_KEY | server only | Service-role secret used by the adapter (bypasses RLS). Never NEXT_PUBLIC_. |
FIREBASE_SERVICE_ACCOUNT* | server only | Required on the default identity provider — verifies Firebase ID tokens in the RPC route. Not needed under AUTH_PROVIDER=authjs. See environment variables. |
The client is created lazily and cached, with persistSession: false and autoRefreshToken: false. If SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY is missing, the first operation throws:
Supabase commerce adapter: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be
set. This adapter is server-side only — never expose the service-role key to
the client.Schema reference
The DDL in supabase/schema.sql mirrors the Firestore layout the Firebase adapter uses. Schemaless documents (product bodies, cart items, quote/order items and customer info, addresses, applied discounts) live in jsonb columns so unknown fields round-trip exactly like Firestore documents. Queried scalars (numbers, statuses, timestamps, customer ids) are promoted to real columns.
| Firestore | Postgres table |
|---|---|
products/{id} | products |
home-products/{autoId} | featured_products |
carts/{userId} | carts |
quotes/{autoId} | quotes |
customers/{uid}/quotes/{quoteNumber} | customer_quotes |
orders/{autoId} | orders |
customers/{uid}/orders/{orderNumber} | customer_orders |
customers/{uid} | customers |
customers/{uid}/addresses/{autoId} | customer_addresses |
discounts/{UPPERCASE_CODE} | discounts |
admins/{uid} | admins |
audit_log/{autoId} | audit_log |
newsletter_signups/{email} | newsletter_signups |
(derived from products.categories) | product_categories |
Catalog tables
products is keyed by the caller-supplied human id (e.g. AUD-001); the full schemaless product body lives in the data jsonb column, including the adapter-maintained productNameLower search field. featured_products holds home-page entries — each row is a full copy of a product body with its own generated id and a productId back-reference, exactly like the Firestore home-products collection.
create extension if not exists pgcrypto;
create table if not exists products (
id text primary key,
data jsonb not null default '{}'::jsonb
);
-- Prefix search: WHERE data->>'productNameLower' LIKE 'term%'.
-- text_pattern_ops makes the LIKE prefix scan use the index regardless of collation.
create index if not exists products_name_lower_idx
on products ((data ->> 'productNameLower') text_pattern_ops);
create index if not exists products_brand_idx
on products ((data ->> 'brand'));
create index if not exists products_categories_idx
on products using gin ((data -> 'categories'));
create table if not exists featured_products (
id uuid primary key default gen_random_uuid(),
data jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
-- One cart per user, keyed by the auth uid. `items` is the JSON array of
-- cart line snapshots (line identity = id or id::variantId). clear() DELETES
-- the row (a missing row reads as "no cart", distinct from an empty cart).
create table if not exists carts (
user_id text primary key,
items jsonb not null default '[]'::jsonb,
updated_at timestamptz not null default now()
);Quotes and orders
Quotes and orders each keep a main record plus a denormalized per-customer copy keyed by the human-readable number. quotes.order_number is NULL until the quote is converted; createFromQuote claims it atomically to reject double conversion. Orders carry a computed total (line subtotals minus the clamped discount, rounded to cents).
create table if not exists quotes (
id uuid primary key default gen_random_uuid(),
quote_number text not null,
customer_id text,
status text not null default 'Pending',
message text not null default '',
order_number text,
created timestamptz not null default now(),
items jsonb not null default '[]'::jsonb,
customer jsonb not null default '{}'::jsonb,
discount jsonb
);
create index if not exists quotes_created_desc_idx on quotes (created desc);
create index if not exists quotes_customer_idx on quotes (customer_id);
create table if not exists customer_quotes (
customer_id text not null,
quote_number text not null,
main_quote_id uuid,
status text not null default 'Pending',
message text not null default '',
order_number text,
created timestamptz not null default now(),
items jsonb not null default '[]'::jsonb,
customer jsonb not null default '{}'::jsonb,
discount jsonb,
primary key (customer_id, quote_number)
);
create table if not exists orders (
id uuid primary key default gen_random_uuid(),
order_number text not null,
quote_id uuid,
quote_number text,
customer_id text,
customer jsonb not null default '{}'::jsonb,
items jsonb not null default '[]'::jsonb,
total numeric(12, 2) not null default 0,
status text not null default 'Processing',
created timestamptz not null default now(),
discount jsonb,
-- UNIQUE is the idempotency key for the Stripe webhook: a retried delivery
-- hits the constraint instead of creating a second order.
payment_session_id text unique,
payment_status text
);
-- Adds the payment columns to a database created before they existed.
alter table orders add column if not exists payment_session_id text;
alter table orders add column if not exists payment_status text;
create unique index if not exists orders_payment_session_id_key
on orders (payment_session_id);
create index if not exists orders_created_desc_idx on orders (created desc);
create index if not exists orders_customer_idx on orders (customer_id);
create table if not exists customer_orders (
customer_id text not null,
order_number text not null,
quote_id uuid,
quote_number text,
customer jsonb not null default '{}'::jsonb,
items jsonb not null default '[]'::jsonb,
total numeric(12, 2) not null default 0,
status text not null default 'Processing',
created timestamptz not null default now(),
discount jsonb,
payment_session_id text,
payment_status text,
primary key (customer_id, order_number)
);
alter table customer_orders add column if not exists payment_session_id text;
alter table customer_orders add column if not exists payment_status text;If your project predates the payment columns, re-run the file — the
alter table … add column if not exists statements above bring it up to date —
before the first paid Stripe webhook arrives.
Customers, discounts, and admins
customers is a rigid four-field profile keyed by the auth uid — the write path whitelists exactly these fields, matching the Firestore converter. customer_addresses is a schemaless address book with no FK to customers (Firestore subcollections can exist without a parent doc). discounts is keyed by the trimmed UPPERCASE code; expires_at NULL means never expires, max_uses NULL means unlimited. used_count is system-maintained — the adapter calls increment_discount_usage() after persisting a quote that carried the code, because PostgREST cannot express used_count = used_count + 1 in an UPDATE. Execute on that function is revoked from anon and authenticated, so only the service-role connection can move a counter. admins uses row existence as the admin flag.
create table if not exists customers (
id text primary key,
first_name text not null default '',
last_name text not null default '',
phone text not null default '',
email text not null default ''
);
create table if not exists customer_addresses (
id uuid primary key default gen_random_uuid(),
customer_id text not null,
data jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists customer_addresses_customer_idx
on customer_addresses (customer_id);
create table if not exists discounts (
code text primary key,
type text not null default 'percent',
value numeric not null default 0,
active boolean not null default false,
expires_at timestamptz,
-- Total-redemption cap; NULL = unlimited. used_count >= max_uses reads as
-- "not usable" (same UX as expired).
max_uses integer,
-- System-maintained redemption counter: quotes.submit calls
-- increment_discount_usage() after persisting the quote; discounts.save
-- never writes this column.
used_count integer not null default 0,
-- Automatic promotion: auto-applied by the cart (no code entry) when
-- usable and the subtotal meets min_subtotal.
automatic boolean not null default false,
-- Minimum cart subtotal for automatic application; NULL = no threshold.
min_subtotal numeric,
created timestamptz not null default now()
);
-- Migration for pre-existing deployments (create table if not exists does
-- not add columns): safe to re-run, no-ops once the columns exist.
alter table discounts add column if not exists max_uses integer;
alter table discounts add column if not exists used_count integer not null default 0;
alter table discounts add column if not exists automatic boolean not null default false;
alter table discounts add column if not exists min_subtotal numeric;
comment on table discounts is
'Codes stored trimmed + uppercased. expires_at NULL = never expires; a code is usable through (inclusive of) the expiry instant. max_uses NULL = unlimited; used_count is system-maintained (see increment_discount_usage).';
create index if not exists discounts_created_desc_idx on discounts (created desc);
create index if not exists discounts_automatic_idx on discounts (automatic);
-- Atomic usage increment, called by the adapter (service role) right after
-- a quote carrying the discount is persisted. PostgREST cannot express
-- `used_count = used_count + 1` in an UPDATE, hence the function. No-op on
-- an unknown code.
create or replace function increment_discount_usage(p_code text)
returns void
language sql
as $$
update discounts set used_count = used_count + 1 where code = p_code;
$$;
-- Only the service-role connection may bump counters — keep the anon /
-- authenticated PostgREST roles out, consistent with the RLS posture below.
revoke execute on function increment_discount_usage(text) from public;
revoke execute on function increment_discount_usage(text) from anon;
create table if not exists admins (
uid text primary key,
created_at timestamptz not null default now()
);Audit log and newsletter signups
audit_log is the admin activity trail behind commerce.audit — written by the RPC route after each privileged mutation, read by the admin Activity page. Append-only: the adapter exposes record() and listPage() and nothing else, so there is no update or delete path to audit in turn. The composite index is what serves the keyset page.
newsletter_signups is written only by POST /api/newsletter, with the service-role connection, behind a zod schema and an IP rate limit. The normalized email is the primary key, so a repeat signup is one upsert rather than a duplicate row.
create table if not exists audit_log (
id uuid primary key default gen_random_uuid(),
event text not null,
summary text not null default '',
actor_uid text,
actor_email text,
details jsonb not null default '{}'::jsonb,
source text not null default 'server',
created timestamptz not null default now()
);
-- Serves the keyset page: order by created desc, id desc.
create index if not exists audit_log_created_id_desc_idx
on audit_log (created desc, id desc);
create index if not exists audit_log_actor_idx on audit_log (actor_uid);
create table if not exists newsletter_signups (
email text primary key,
source text not null default '',
locale text not null default '',
created timestamptz not null default now()
);
create index if not exists newsletter_signups_created_desc_idx
on newsletter_signups (created desc);No transaction is involved in either, which is worth stating because the usual Supabase caveat is about interactive transactions, which PostgREST cannot offer. record() is a single-row insert and listPage() a single select, so neither wants one and this table carries the same guarantees it would on plain Postgres.
Row Level Security
RLS is enabled with no policies on every table. The adapter uses the service-role key (which bypasses RLS), so enabling RLS with no policies denies all access to the anon/authenticated PostgREST roles — nothing is reachable except through the server-side adapter. This mirrors how firestore.rules gates the Firebase adapter.
alter table products enable row level security;
alter table product_categories enable row level security;
alter table featured_products enable row level security;
alter table carts enable row level security;
alter table quotes enable row level security;
alter table customer_quotes enable row level security;
alter table orders enable row level security;
alter table customer_orders enable row level security;
alter table customers enable row level security;
alter table customer_addresses enable row level security;
alter table discounts enable row level security;
alter table admins enable row level security;
alter table audit_log enable row level security;
alter table newsletter_signups enable row level security;Seeding the catalog
npm run seed writes through this adapter like the app does, so seeding Supabase is one command:
COMMERCE_BACKEND=supabase npm run seedIt is idempotent: products are written by their catalog id, and stale featured copies are cleared before the new ones are added.
Writing rows by hand instead means maintaining the two invariants create normally derives for you:
- The whole product body (including its
id) goes in thedatajsonb column, keyed by the same human id (AUD-001, ...). data.productNameLowermust be present (lower(productName)) or prefix search will silently miss the row, and the matchingproduct_categoriesrows must exist or the product appears on no category page.
Behavior notes and limitations
The adapter is a faithful behavioral drop-in, but a few Firestore-parity quirks are worth knowing:
- Cart updates poll instead of push.
carts.listen()fetches the cart immediately, then re-fetches every 5 seconds; realtime push is Firestore-only for now. The callback contract is otherwise identical (fires immediately with the current state;undefinedfor a missing cart). Expect up to ~5s of latency on cross-session cart changes and slightly more read traffic per open listener. - Pagination cursors are JSON-serializable tokens (base64url-encoded keyset cursors), so they survive HTTP round-trips between the API route and the browser. They remain opaque — hand back exactly what
products.listreturned. Firestore parity quirk preserved: a full final page still returns a non-null cursor; the caller detects exhaustion when the next page comes back empty. - Non-atomic tails preserved.
updateStatusandcreateFromQuoteupdate the main record first, then the per-customer copy — a missing copy throws after the main write committed, matching the Firebase adapter. The only transactional piece is the double-conversion guard increateFromQuote(a conditionalUPDATE ... WHERE order_number IS NULL). - Stock decrement is best-effort, exactly like Firebase: failures are logged and never fail the already-created order; unknown product ids, untracked stock, and missing variants are skipped silently.
- Events fire server-side. Lifecycle events (
quote.submitted,order.placed, ...) are emitted in the server runtime that executes the mutation, so subscribers may safely use secrets — but client-side subscribers will not see them. - Ordering nuances.
products.listorders byidusing the database collation, which can differ from Firestore's code-point ordering for ids mixing cases or non-ASCII characters (typicalAUD-001-style ids sort identically).listForCustomerorders by quote/order number ascending, mirroring the Firestore subcollection's doc-id ordering.