Acme Store Docs
Commerce Backends

Firebase (Default Backend)

The default client-side backend: Firestore data, Auth, Storage, and security rules.

Firebase is the zero-config default backend for this boilerplate. Leave NEXT_PUBLIC_COMMERCE_BACKEND and COMMERCE_BACKEND unset (or set the former to firebase) and the app talks to Cloud Firestore directly from the browser, using Firebase Authentication as the identity provider and Firebase Storage for product images. There is no server tier to run: reads and writes are gated entirely by the shipped firestore.rules and storage.rules.

The store is quote-based by default — browse, build a cart, submit a quote request; no payment processor is needed. Everything on this page works with just the browser-safe NEXT_PUBLIC_FIREBASE_* credentials. Server-side credentials (FIREBASE_SERVICE_ACCOUNT*) are only needed for the optional seed script, the optional Stripe webhook, and ID-token verification on the RPC route.

How the default backend is selected

The client-visible adapter is chosen in src/lib/commerce/index.ts from NEXT_PUBLIC_COMMERCE_BACKEND:

NEXT_PUBLIC_COMMERCE_BACKENDAdapterBehavior
unset / "" / "firebase"firebaseCommerceTalks to Firestore directly from the browser
"supabase" / "postgres"httpCommerceForwards every call to the /api/commerce RPC route

Any unknown value logs an error and falls back to firebaseCommerce. All application code goes through import { commerce } from "@/lib/commerce" — the Firebase implementation is just one adapter, so the rest of the app never imports Firestore directly.

The Firebase backend has no server adapter. Because the browser holds Firestore reads/writes directly (guarded by security rules), setting COMMERCE_BACKEND=firebase throws — that variable exists only to select a SQL server adapter. 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.

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
newsletter_signupsauto-idNewsletter emails (write-only for visitors)

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)Admin only
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 · Update/delete: never
newsletter_signups/{id}Admin onlyCreate: anyone · Update/delete: never

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.

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.

Terminal
firebase deploy --only storage

Or paste storage.rules into Firebase console → Storage → 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.

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 consumed by the commerce RPC route (src/app/api/commerce/[service]/[method]/route.ts), which the SQL backends use to authenticate callers — Firebase Auth stays the identity provider even when Firestore is not the datastore. The default Firebase backend does not route through this API (the browser talks to Firestore directly), so admin credentials are optional for it.

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