Ecommerce Boilerplate
Commerce Backends

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 shipped firestore.rules and storage.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.

.env.local
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
VariableRequiredNotes
NEXT_PUBLIC_FIREBASE_API_KEYYesWeb API key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAINYese.g. your-project.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_IDYesFirestore project id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKETFor image uploadsImage upload/list/delete fail with a clear error when unset
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_IDYesFrom web app config
NEXT_PUBLIC_FIREBASE_APP_IDYesFrom web app config
NEXT_PUBLIC_FIREBASE_MEASUREMENT_IDOptionalAnalytics only
NEXT_PUBLIC_EMAIL_SIGNIN_REDIRECT_URLFor email-link sign-inThe 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.

src/lib/firebase/config.ts
const app = getApps().length ? getApp() : initializeApp(firebaseConfig);

export const auth = getAuth(app);
export const db = getFirestore(app);
Create a project at console.firebase.google.com and add a Web app; copy its config values into .env.local.
Enable Authentication → sign-in methods: Google and Email link (passwordless).
Enable Cloud Firestore.
(Optional) Enable Storage for product image uploads.
Deploy the security rules (see below) before going to production.

The src/lib/firebase modules

FileExportsResponsibility
config.tsauth, db, default appInitializes the client SDK (single app, reused across HMR)
authService.tsFirebaseAuthServiceGoogle + passwordless email-link sign-in, auth-state subscription, sign-out
dbService.tsFirebaseDBServiceAll Firestore reads/writes: products, carts, quotes, orders, customers, discounts, admin checks
firebaseDataConverters.tstyped *Converter objects, interfaces, status enumsFirestore withConverter mappers; safe created date coercion
firebaseUtils.tsfetchCollectionDocs, queryCollection, withErrorHandlingGeneric collection/query helpers with error logging
storageService.tsuploadProductImage, listProductImages, deleteProductImage, validateImageFileProduct image upload/list/delete against Firebase Storage
admin.tsgetAdminDb, getAdminAuth, verifyIdTokenServer-only firebase-admin singletons and ID-token verification
types.tsProduct, 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.

CollectionDocument idPurpose
products{productId}Catalog items: productName, brand, primaryImageURL, categories: string[], optional price, description, pdfLink
products/{id}/reviewsauto-idPer-product reviews
home-productsauto-idCopies of products featured on the landing page
customers{uid}Customer profile: firstName, lastName, email, phone
customers/{uid}/addressesauto-idSaved 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)
quotesauto-idAll quote requests (admin pipeline); status starts as "Pending"
ordersauto-idOrders created from accepted quotes
discounts{CODE}Discount codes: code (uppercase = doc id), type (percent/fixed), value, active, optional expiresAt
categoriesauto-idOptional: name, subcategories[]
brandsauto-idOptional: brand list for filtering
admins{uid}Membership = admin. Empty body; client-unwritable
audit_logauto-idAppend-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 (or Cancelled)

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.

firestore.rules
function isAdmin() {
  return signedIn() &&
    exists(/databases/$(database)/documents/admins/$(request.auth.uid));
}

What is public vs admin vs owner

PathReadWrite
products/**, home-products, categories, brandsPublicAdmin 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 onlyNobody (if false) — manage from the Firebase console
customers/{uid} and /addressesOwner or adminOwner or admin
customers/{uid}/quotesOwner or adminCreate: owner or admin · Update: admin · Delete: never
customers/{uid}/ordersOwner or adminCreate/update: admin · Delete: never
carts/{uid}Owner onlyOwner only
quotes/{id} (top-level)Admin onlyCreate: anyone, if the payload validates · Update: admin · Delete: never
orders/{id} (top-level)Admin onlyCreate/update: admin · Delete: never
audit_log/{id}Admin onlyCreate: admin, and only a shape-checked entry naming themselves (see below) · Update/delete: never
newsletter_signups/{email}Admin onlyNobody (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:

firestore.rules
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.

firestore.rules
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:

firestore.rules
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):

storage.rules
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

Terminal
firebase deploy --only firestore:rules

Or paste the contents of firestore.rules into Firebase console → Firestore → Rules.

Granting admin access

Sign in to the store once, so your user exists in Firebase Auth.
In Firestore, create a document in the 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.
Reload — the "Admin" entry appears in the account menu.

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 by storage.rules.
  • When NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET is 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 enumerates products/{productId}/ folders, resolves URLs + metadata in parallel, sorts newest-first, and caps output at 500 files.
  • deleteProductImage(path) removes a file but does not clear primaryImageURL on 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":

src/lib/firebase/admin.ts
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:

VariableMeaning
FIREBASE_SERVICE_ACCOUNT_JSONThe service-account JSON contents (Vercel-friendly)
GOOGLE_APPLICATION_CREDENTIALSPath to a service-account JSON file (application-default)
FIREBASE_SERVICE_ACCOUNTPath to a service-account JSON file

On this page