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_BACKEND | Adapter | Behavior |
|---|---|---|
unset / "" / "firebase" | firebaseCommerce | Talks to Firestore directly from the browser |
"supabase" / "postgres" | httpCommerce | Forwards 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.
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.
Passwordless sign-in is a two-step flow:
loginWithEmailLink(email)callssendSignInLinkToEmailwithhandleCodeInApp: trueandurlset toNEXT_PUBLIC_EMAIL_SIGNIN_REDIRECT_URL, then stores the address underlocalStorage["emailForSignIn"].- When the emailed link opens the redirect page,
completeSignInWithEmailLink(email, url)verifies the link withisSignInWithEmailLink, completes it viasignInWithEmailLink, creates the customer record if needed, and merges carts.
The default redirect target is /complete-login. The link's domain must be
listed under Firebase Auth authorized domains.
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.
| 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 |
newsletter_signups | auto-id | Newsletter 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(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) | Admin only |
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 · Update/delete: never |
newsletter_signups/{id} | Admin only | Create: 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:
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):
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.
firebase deploy --only storageOr paste storage.rules into Firebase console → Storage → 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.
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 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:
| 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 |