Firebase Backend
The client-side backend: Firestore data, Auth, Storage, and the security rules that gate them.
Firebase plays two roles, and it is worth separating them:
- Identity, by default. Firebase Auth issues the ID token the server
verifies on privileged calls. It is the default on every backend, so a
Postgres or SQLite store needs a Firebase project unless you set
AUTH_PROVIDER=authjs— see authentication. - Data, optionally. Set
NEXT_PUBLIC_COMMERCE_BACKEND=firebase(or leave it unset with a project id present) and the app also talks to Cloud Firestore directly from the browser, with Firebase Storage for product images and no server tier to run — reads and writes gated by the shippedfirestore.rulesandstorage.rules.
Firestore-as-data is the one pairing that is not free: the rules gate on
request.auth, so choosing it also chooses Firebase Auth.
This page covers the second role. Choosing it buys realtime carts and no database to operate; SQLite and Postgres are the alternatives.
Without Stripe configured the store is quote-based — browse, build a cart,
submit a quote request; no payment processor needed. Everything on this page
works with just the browser-safe NEXT_PUBLIC_FIREBASE_* credentials.
Server-side credentials (FIREBASE_SERVICE_ACCOUNT*) are needed for audited
admin writes — the default path — plus newsletter signups, the optional
seed script, the optional
Stripe webhook, and ID-token verification on
the RPC route.
How the default backend is selected
src/lib/commerce/index.ts picks the client adapter from
NEXT_PUBLIC_COMMERCE_BACKEND. Firebase is what an unset value resolves to once
NEXT_PUBLIC_FIREBASE_PROJECT_ID is present — with no project id it is the demo
fixture instead — and it is also where an unrecognized value lands, after
logging an error. The full table is on the
backends overview.
All application code goes through import { commerce } from "@/lib/commerce",
so the rest of the app never imports Firestore directly.
COMMERCE_BACKEND=firebase (or leaving it unset) resolves
firebaseAdminCommerce in src/lib/commerce/server/firebase-admin.ts — an
admin-only server adapter, not a full one. It exists so the ten admin
mutations can be re-authorized and audited server-side with the Admin SDK;
every shopper-side method on it throws, because those still run in the browser.
See swapping backends for the SQL options.
Configuration
Firebase web credentials are safe to expose to the browser — you enforce access
control with security rules, not by hiding the keys. Copy .env.example to
.env.local and fill in the values from your Firebase project's Web app
config.
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your-project.appspot.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=
NEXT_PUBLIC_EMAIL_SIGNIN_REDIRECT_URL=http://localhost:3000/complete-login| Variable | Required | Notes |
|---|---|---|
NEXT_PUBLIC_FIREBASE_API_KEY | Yes | Web API key |
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN | Yes | e.g. your-project.firebaseapp.com |
NEXT_PUBLIC_FIREBASE_PROJECT_ID | Yes | Firestore project id |
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET | For image uploads | Image upload/list/delete fail with a clear error when unset |
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID | Yes | From web app config |
NEXT_PUBLIC_FIREBASE_APP_ID | Yes | From web app config |
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID | Optional | Analytics only |
NEXT_PUBLIC_EMAIL_SIGNIN_REDIRECT_URL | For email-link sign-in | The page the sign-in link opens; must be an authorized domain in Firebase Auth |
src/lib/firebase/config.ts provides demo fallbacks for every required value so
next build and local dev work before any environment is configured — Firebase
only validates credentials on network calls, not at initialization.
const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);.env.local.The src/lib/firebase modules
| File | Exports | Responsibility |
|---|---|---|
config.ts | auth, db, default app | Initializes the client SDK (single app, reused across HMR) |
authService.ts | FirebaseAuthService | Google + passwordless email-link sign-in, auth-state subscription, sign-out |
dbService.ts | FirebaseDBService | All Firestore reads/writes: products, carts, quotes, orders, customers, discounts, admin checks |
firebaseDataConverters.ts | typed *Converter objects, interfaces, status enums | Firestore withConverter mappers; safe created date coercion |
firebaseUtils.ts | fetchCollectionDocs, queryCollection, withErrorHandling | Generic collection/query helpers with error logging |
storageService.ts | uploadProductImage, listProductImages, deleteProductImage, validateImageFile | Product image upload/list/delete against Firebase Storage |
admin.ts | getAdminDb, getAdminAuth, verifyIdToken | Server-only firebase-admin singletons and ID-token verification |
types.ts | Product, Customer, Cart, Quote, … | Shared TypeScript interfaces |
Authentication
authService.ts wires Firebase Authentication. Two sign-in methods ship, both
creating a customers document on first login and merging the guest
localStorage cart into the signed-in Firestore cart.
signInWithGoogle() opens a GoogleAuthProvider popup. On success, if no
customers/{uid} document exists yet it creates one, then merges the local cart
into the backend cart and clears localStorage.cart.
subscribeToAuthChanges(handler) wraps onAuthStateChanged and returns the
unsubscribe function; signOutUser() wraps signOut.
Firestore data model
dbService.ts reads and writes these collections directly from the browser.
Each typed collection is accessed through a converter from
firebaseDataConverters.ts. The exception is admin mutations, which are routed
back through the server by default — see
Audited admin writes below.
| Collection | Document id | Purpose |
|---|---|---|
products | {productId} | Catalog items: productName, brand, primaryImageURL, categories: string[], optional price, description, pdfLink |
products/{id}/reviews | auto-id | Per-product reviews |
home-products | auto-id | Copies of products featured on the landing page |
customers | {uid} | Customer profile: firstName, lastName, email, phone |
customers/{uid}/addresses | auto-id | Saved addresses |
customers/{uid}/quotes | {quoteNumber} | The customer's quote history |
customers/{uid}/orders | {orderNumber} | The customer's order history |
carts | {uid} | Signed-in cart: { items: CartItem[] } (guests use localStorage) |
quotes | auto-id | All quote requests (admin pipeline); status starts as "Pending" |
orders | auto-id | Orders created from accepted quotes |
discounts | {CODE} | Discount codes: code (uppercase = doc id), type (percent/fixed), value, active, optional expiresAt |
categories | auto-id | Optional: name, subcategories[] |
brands | auto-id | Optional: brand list for filtering |
admins | {uid} | Membership = admin. Empty body; client-unwritable |
audit_log | auto-id | Append-only admin activity trail: event, summary, actorUid, actorEmail, details, source ("server"/"client"), created |
newsletter_signups | {email} (normalized) | Storefront signups, written only by POST /api/newsletter with the Admin SDK. Keying on the lowercased address makes a repeat signup an idempotent merge |
Quotes and orders are dual-written: the primary copy lives in the top-level
quotes / orders collections (the admin pipeline), and a mirror is written
under customers/{uid}/quotes/{quoteNumber} and
customers/{uid}/orders/{orderNumber} when the submitter was signed in.
Status changes update both copies. Quote and order numbers are generated
client-side as Q-YYYYMMDD-####XXXX / O-YYYYMMDD-####XXXX.
Converters and safe dates
Converters strip the synthetic id on write and stamp it back from the snapshot
id on read. Because the quotes collection accepts guest writes, a document may
carry a null, absent, or malformed created field. toDateSafe coerces any of
these into a Date (falling back to the epoch) so one bad record never crashes
the whole admin listing.
Status values are constrained by exported unions:
- Quotes:
Pending → Quoted → Accepted → Declined - Orders:
Processing → Shipped → Delivered(orCancelled)
Security rules
The shipped firestore.rules are the real access-control layer — the browser
holds Firestore credentials, so the rules (not hidden keys) decide what each
caller can do. Admin identity is a single source of truth: membership in the
admins collection, checked with an exists() lookup.
function isAdmin() {
return signedIn() &&
exists(/databases/$(database)/documents/admins/$(request.auth.uid));
}What is public vs admin vs owner
| Path | Read | Write |
|---|---|---|
products/**, home-products, categories, brands | Public | Admin only |
discounts/{code} | Public (so the cart can validate a typed code and list automatic promotions) | Admin, plus one shape-checked usedCount +1 from anyone (see below) |
admins/{uid} | The owning user only | Nobody (if false) — manage from the Firebase console |
customers/{uid} and /addresses | Owner or admin | Owner or admin |
customers/{uid}/quotes | Owner or admin | Create: owner or admin · Update: admin · Delete: never |
customers/{uid}/orders | Owner or admin | Create/update: admin · Delete: never |
carts/{uid} | Owner only | Owner only |
quotes/{id} (top-level) | Admin only | Create: anyone, if the payload validates · Update: admin · Delete: never |
orders/{id} (top-level) | Admin only | Create/update: admin · Delete: never |
audit_log/{id} | Admin only | Create: admin, and only a shape-checked entry naming themselves (see below) · Update/delete: never |
newsletter_signups/{email} | Admin only | Nobody (if false) — the server writes them with the Admin SDK |
The public catalog reads + admin-gated writes pattern is deliberate: guests
and native apps can browse products, categories, brands, and validate discount
codes without signing in, while every mutation to those collections requires the
caller to be in admins.
Validated guest quote submissions
Anyone — including guests — may create a top-level quote, but the payload is
validated so a hostile client cannot inject arbitrary fields or a malformed
created that would break the admin listing:
function isValidQuote() {
let d = request.resource.data;
return d.created is timestamp
&& d.status == "Pending"
&& (d.customerId == null || d.customerId is string)
&& d.items is list && d.items.size() > 0 && d.items.size() <= 200
&& d.customer is map
&& (!('message' in d) || (d.message is string && d.message.size() <= 5000));
}Only admins can then read the pipeline or change a quote's status.
The audit trail is shape-checked, not merely admin-gated
On a default deployment the server writes audit_log, with the Admin SDK,
which bypasses rules entirely. Client creates stay allowed for the
NEXT_PUBLIC_ADMIN_WRITES=client opt-out, but allow create: if isAdmin()
alone was not enough: it let any admin write an entry naming a different actor,
marked as if the server had written it, dated whenever they liked — exactly what
a trail meant to hold them accountable must not permit.
function isOwnClientAuditEntry() {
let d = request.resource.data;
return d.actorUid == request.auth.uid
&& d.source == "client"
&& d.created is timestamp
&& d.event is string && d.event.size() <= 100
&& d.summary is string && d.summary.size() <= 500;
}The one write a guest may make to a discount
After submitting a quote carrying a discount code, the client increments that
code's usedCount by exactly one. Guests submit quotes, so the update has to be
open to unauthenticated callers — and the rule constrains its shape (only that
field, only +1) rather than its truth:
function isUsageIncrement() {
return request.resource.data.diff(resource.data).affectedKeys()
== ['usedCount'].toSet()
&& request.resource.data.usedCount is int
&& request.resource.data.usedCount
== resource.data.get('usedCount', 0) + 1;
}Rules cannot tie that increment to the quote that was just submitted. A
determined client can skip it (redeeming past maxUses) or spam it (exhausting
a code early). Quotes are human-reviewed before conversion, so the first is
caught at review; the second is a griefing vector on promo codes. If either
matters to you, run a SQL backend — there the counter is incremented
server-side inside quotes.submit. See discounts.
Newsletter signups no longer come from the browser
newsletter_signups used to carry allow create: if true, which made it an
unauthenticated, unvalidated, unlimited write endpoint into a billed
collection. It is now allow write: if false, and the home-page form posts to
POST /api/newsletter instead, which rate-limits by IP, validates with a zod
schema, and writes with the Admin SDK.
That makes signup persistence depend on FIREBASE_SERVICE_ACCOUNT_JSON (or
another FIREBASE_SERVICE_ACCOUNT* credential). Without one the endpoint still
accepts the submission and the visitor still sees a thank-you, but nothing is
stored and the server logs a warning. Set NEWSLETTER_PROVIDER=demo if you
want accept-and-discard on purpose.
Storage rules
storage.rules guards product images. It reuses the same admins
collection as the source of truth via a cross-service Firestore lookup
(supported in Storage rules v2):
function isAdmin() {
return signedIn() &&
firestore.exists(/databases/(default)/documents/admins/$(request.auth.uid));
}
match /products/{productId}/{fileName} {
allow read: if true; // public catalog images
allow write: if isAdmin() && isValidImage();
}isValidImage() enforces contentType matching image/.* and a max size of
5 MB, mirroring validateImageFile in storageService.ts. Everything
outside products/** is off-limits.
Deploying the rules
firebase deploy --only firestore:rulesOr paste the contents of firestore.rules into Firebase console →
Firestore → Rules.
Granting admin access
admins collection whose document id is your user id (shown on the /admin access-denied screen, or in Firebase Auth → Users). The body can be empty.Clients can never write to admins (allow write: if false); manage it
from the Firebase console. FirebaseDBService.isAdmin(uid) and the useIsAdmin
UI hook are convenience checks only — real enforcement lives in the rules.
Audited admin writes
Rules are a real gate, but they cannot record who did what in a way the actor is unable to skip: on a pure client-side backend the same browser that performs an admin write also writes the audit entry, and it can simply not do that.
So the default Firebase deployment routes the ten admin mutations — product
CRUD, feature/unfeature, quote and order status, createFromQuote, discount
save/remove — through /api/commerce/[service]/[method]
(src/lib/commerce/audited-firebase.ts). The route re-verifies the caller,
re-checks admin status server-side, performs the write with the Admin SDK, and
records the audit entry itself with the server's own clock. Reads are
deliberately not routed: they are already gated by rules, add no audit value,
and a server hop would cost latency on every admin table.
This needs FIREBASE_SERVICE_ACCOUNT* credentials. A deployment without them
opts out with NEXT_PUBLIC_ADMIN_WRITES=client, which restores direct
browser-to-Firestore writes gated by rules alone, with client-written audit
entries — the ones the isOwnClientAuditEntry() rule above shape-checks.
Product images (Storage)
The admin product form uploads images to Firebase Storage.
uploadProductImage(file, productId, onProgress?) stores the file at
products/{productId}/{timestamp}-{filename} and resolves to its public
download URL, which the form saves as the product's primaryImageURL.
- Client-side validation (
validateImageFile): JPEG, PNG, WebP, or AVIF, max 5 MB — mirrored server-side bystorage.rules. - When
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKETis unset, uploads/listing/deletes fail fast with an actionable message instead of hitting the demo bucket. listProductImages()powers the media library at/admin/media— it enumeratesproducts/{productId}/folders, resolves URLs + metadata in parallel, sorts newest-first, and caps output at 500 files.deleteProductImage(path)removes a file but does not clearprimaryImageURLon products still pointing at it.
next.config.mjs already allows firebasestorage.googleapis.com in
images.remotePatterns, so uploaded images render through next/image with no
extra config.
Server-side token verification (admin.ts)
src/lib/firebase/admin.ts is a server-only module (never import it from a
client component). It lazily initializes a firebase-admin app under a named
instance and exposes getAdminDb(), getAdminAuth(), and verifyIdToken().
When no admin credentials are configured, each returns null (logging once) so
the rest of the app keeps working.
verifyIdToken() decodes a Firebase ID token from an Authorization: Bearer
header and never throws — a null result means "unauthenticated":
export async function verifyIdToken(
idToken: string | null | undefined
): Promise<DecodedIdToken | null> {
if (!idToken) return null;
const adminAuth = getAdminAuth();
if (!adminAuth) return null;
try {
return await adminAuth.verifyIdToken(idToken);
} catch (error) {
console.error("firebase-admin: ID token verification failed:", error);
return null;
}
}This is reached through getAuthUser() (src/lib/auth), the seam the commerce
RPC route (src/app/api/commerce/[service]/[method]/route.ts) uses to identify
callers on every backend. Firebase is the default answer there; setting
AUTH_PROVIDER=authjs swaps in a session cookie instead and this module goes
unused for identity.
On the Firebase backend the browser talks to Firestore directly for shopper traffic, so the route is not on that path — but admin credentials are still wanted, because audited admin writes and newsletter signups both go through the server.
Credentials are resolved from the first of:
| Variable | Meaning |
|---|---|
FIREBASE_SERVICE_ACCOUNT_JSON | The service-account JSON contents (Vercel-friendly) |
GOOGLE_APPLICATION_CREDENTIALS | Path to a service-account JSON file (application-default) |
FIREBASE_SERVICE_ACCOUNT | Path to a service-account JSON file |