Building a SaaS used to require weeks of backend setup before you could even validate your idea. Firebase changes that. With Auth, a real-time database, serverless functions, hosting, analytics, and a rich extension marketplace (including Stripe subscriptions), you can ship a production-grade MVP in days—not months.
This comprehensive guide shows you how to plan, build, and scale a multi-tenant SaaS on Firebase. You’ll get architecture advice, security best practices, code snippets, a step-by-step tutorial, and a final resources chapter to keep going.
Key outcomes: You’ll understand how to plan for multi-tenancy, implement authentication and authorization, wire subscriptions with Stripe, protect your data with Security Rules, run locally with the Emulator Suite, and deploy with confidence.
Table of Contents
- Introduction: Why Firebase for SaaS
- Architecture Overview for a Firebase-First SaaS
- Planning Your SaaS on Firebase (before you code)
- Step-by-Step Tutorial: Minimal Multi‑Tenant SaaS
- Project setup
- Web app setup
- Auth flows
- Organizations and memberships
- Security Rules
- Billing with Stripe
- Invites and roles
- Local dev and deployment
- Most Useful Firebase Features for SaaS
- Scaling, Performance, and Costs
- Avoiding Lock‑In and Migration Strategy
- Conclusion
- Resources
Introduction: Why Firebase for SaaS
Firebase is a product suite from Google that helps you build, ship, and scale apps quickly:
- Authentication: Email/password, social logins, SSO via SAML/OIDC (with Identity Platform).
- Firestore: Serverless, globally available NoSQL database with real-time listeners and offline caching.
- Cloud Functions (Gen 2): Event-driven backend running on Cloud Run with autoscaling.
- Hosting: Fast, global CDN with preview channels for PRs.
- Cloud Storage: Secure file storage.
- Analytics, Performance Monitoring, Remote Config, A/B testing.
- Extensions: Prebuilt integrations (e.g., “Run subscription payments with Stripe”).
For a SaaS founder, Firebase offers a “batteries-included” way to deliver a secure MVP with auth, data, billing, and observability—while keeping your ops overhead near zero.
Architecture Overview for a Firebase-First SaaS
A pragmatic SaaS architecture on Firebase looks like this:
- Frontend: React, Next.js, Vue, Svelte—served on Firebase Hosting. If you need SSR/SSG, use Firebase Hosting + Cloud Functions/Cloud Run integration (Next.js on Firebase is supported).
- Auth: Firebase Authentication. For enterprise SSO and multi-tenancy features, enable Google Cloud Identity Platform on your project.
- Data: Firestore for multi-tenant data; Cloud Storage for uploads; BigQuery for analytics export.
- Backend: Cloud Functions (Gen 2) for APIs, webhooks (Stripe), scheduled jobs, and background triggers.
- Billing: Stripe via Firebase Extension (“Run Subscription Payments with Stripe”) or custom Cloud Functions.
- Security: Firestore Security Rules + App Check + IAM for admin ops.
- Local Dev: Emulator Suite for Auth, Firestore, Functions, Hosting, Pub/Sub.
- CI/CD: GitHub Actions + Firebase Hosting preview channels; Functions deploy on merge.
Planning Your SaaS on Firebase (before you code)
A few hours of planning saves weeks of rework.
1) Define your tenants and data model
Common multi-tenant patterns with Firestore:
Single collection per resource, tenant field on each document:
- Pros: Simple queries; one schema.
- Cons: Must filter by tenant in every query and enforce in Security Rules.
Hierarchical orgs collection with subcollections:
- Path example: orgs/{orgId}/projects/{projectId}
- Pros: Rules can easily constrain to the current orgId; great for per-org queries.
- Cons: Cross-org aggregation requires collection group queries.
If you expect heavy cross-tenant queries, prefer top-level collections with a tenantId field. If you mostly read per-org, the hierarchical model is simpler.
2) Roles and authorization
Decide your roles early (e.g., owner, admin, member, viewer). Two approaches:
- Document-based authorization in Rules:
- orgs/{orgId}/members/{uid} → { role: “admin” }
- Rules read membership to allow/deny.
- Custom Claims:
- Cloud Functions set request.auth.token.orgs[orgId].role
- Faster checks in Rules, but require token refresh when claims change.
A hybrid works well: document-based for flexibility + cache in claims for speed where necessary.
3) Billing and lifecycle
- Free trial vs. freemium?
- Disable premium features or entire app when subscription lapses?
- Use the Stripe extension to avoid writing your own webhook plumbing.
- Plan states: active, trialing, past_due, canceled; reflect them in your UI and Rules.
4) Environments
- Separate dev and prod Firebase projects.
- Use Hosting preview channels for PRs.
- Keep secrets (Stripe keys, webhook secrets) in environment configs or Secret Manager.
5) Cost awareness and limits
- Start on Blaze (pay-as-you-go) if using Functions/Extensions/Stripe webhooks.
- Use Firestore aggregation count() to avoid reading entire collections.
- Denormalize where it reduces reads; avoid hot document contention.
- Set budgets and alerts in Google Cloud Billing.
Step-by-Step Tutorial: Minimal Multi‑Tenant SaaS
We’ll build a simple SaaS with:
- Email/password auth
- Organization creation and membership
- Role-based access
- Stripe subscriptions
- Secure data model
- Local emulators and deployment
0) Prereqs
- Node.js 18+ (Functions Gen 2 supports Node 20 at time of writing)
- A Stripe account
- Firebase CLI:
npm i -g firebase-tools
1) Create and initialize your Firebase project
# Login and create
firebase login
firebase projects:create my-saas-prod --display-name "My SaaS (Prod)"
firebase projects:create my-saas-dev --display-name "My SaaS (Dev)"
# Use dev for now
firebase use my-saas-dev
# Initialize features
firebase init
# Choose: Firestore, Functions, Hosting, Emulators
# Functions: TypeScript, ESLint, Gen 2
# Firestore: yes to rules and indexes
# Hosting: single-page app? yes (if SPA)
# Emulators: Auth, Firestore, Functions, Hosting; import/export? yes
Your repo will now have firebase.json, firestore.rules, firestore.indexes.json, functions/, etc.
Example firebase.json:
{
"functions": {
"source": "functions"
},
"hosting": {
"public": "dist",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [{ "source": "**", "destination": "/index.html" }]
},
"emulators": {
"auth": { "port": 9099 },
"functions": { "port": 5001 },
"firestore": { "port": 8080 },
"hosting": { "port": 5000 },
"ui": { "enabled": true }
}
}
2) Install the Stripe subscriptions extension (recommended)
In the Firebase Console → Extensions → “Run Subscription Payments with Stripe”.
- Set Stripe Secret/Public keys.
- Choose Firestore collection paths (defaults are fine).
- The extension creates:
- Firestore paths like
/customers/{uid}/subscriptions - A way to start checkout by adding a document in
/customers/{uid}/checkout_sessions - Webhooks to keep Firestore in sync with Stripe
- Firestore paths like
Note: You can also implement billing manually with Cloud Functions, but the extension saves time and reduces errors.
3) Frontend setup (React example)
Create your app (Vite):
npm create vite@latest web -- --template react-ts
cd web && npm i firebase stripe @stripe/stripe-js
Initialize Firebase in src/firebase.ts:
// src/firebase.ts
import { initializeApp } from "firebase/app";
import { getAuth, connectAuthEmulator } from "firebase/auth";
import { getFirestore, connectFirestoreEmulator } from "firebase/firestore";
const firebaseConfig = {
apiKey: "YOUR_DEV_API_KEY",
authDomain: "my-saas-dev.firebaseapp.com",
projectId: "my-saas-dev",
storageBucket: "my-saas-dev.appspot.com",
appId: "YOUR_APP_ID"
};
export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
if (import.meta.env.DEV) {
connectAuthEmulator(auth, "http://localhost:9099", { disableWarnings: true });
connectFirestoreEmulator(db, "localhost", 8080);
}
Simple auth UI:
// src/auth.ts
import { auth } from "./firebase";
import { createUserWithEmailAndPassword, signInWithEmailAndPassword, signOut } from "firebase/auth";
export async function signUp(email: string, password: string) {
const cred = await createUserWithEmailAndPassword(auth, email, password);
return cred.user;
}
export async function signIn(email: string, password: string) {
const cred = await signInWithEmailAndPassword(auth, email, password);
return cred.user;
}
export async function logout() {
await signOut(auth);
}
4) Organizations and memberships
Data model:
- orgs/{orgId}
- orgs/{orgId}/members/{uid} → { role: “owner” | “admin” | “member” }
- users/{uid} → { displayName, email }
- Optionally: users/{uid}/orgs/{orgId} for quick org listings
Create an org:
// src/orgs.ts
import { db } from "./firebase";
import {
doc, setDoc, serverTimestamp, collection, addDoc
} from "firebase/firestore";
import { auth } from "./firebase";
export async function createOrg(name: string) {
const user = auth.currentUser;
if (!user) throw new Error("Not signed in");
const orgRef = await addDoc(collection(db, "orgs"), {
name,
createdAt: serverTimestamp(),
ownerUid: user.uid,
plan: "free"
});
// Add membership
await setDoc(doc(db, "orgs", orgRef.id, "members", user.uid), {
role: "owner",
addedAt: serverTimestamp()
});
// Optional backlink
await setDoc(doc(db, "users", user.uid, "orgs", orgRef.id), {
role: "owner",
orgId: orgRef.id,
joinedAt: serverTimestamp()
});
return orgRef.id;
}
List current user’s orgs with a collection group query:
import { collectionGroup, query, where, getDocs } from "firebase/firestore";
export async function listMyOrgs(uid: string) {
const q = query(
collectionGroup(db, "members"),
where("__name__", ">=", uid), // placeholder: we need the member doc path
);
// Instead, use backlink in users/{uid}/orgs:
const snap = await getDocs(collection(db, "users", uid, "orgs"));
return snap.docs.map(d => ({ id: d.id, ...d.data() }));
}
Note: The backlink users/{uid}/orgs simplifies this common query and avoids collection group filtering complexity.
5) Firestore Security Rules (role-based, per-org)
Rules that enforce membership and roles:
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isSignedIn() {
return request.auth != null;
}
function isMember(orgId) {
return exists(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid));
}
function hasRole(orgId, role) {
return get(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid)).data.role == role;
}
// Users can read/write their profile
match /users/{uid} {
allow read: if isSignedIn() && request.auth.uid == uid;
allow write: if isSignedIn() && request.auth.uid == uid;
// subcollection orgs is derived; restrict writes to server
match /orgs/{orgId} {
allow read: if isSignedIn() && request.auth.uid == uid;
allow write: if false; // only server via functions should write backlinks
}
}
// Orgs
match /orgs/{orgId} {
allow read: if isSignedIn() && isMember(orgId);
allow create: if isSignedIn(); // anyone can create an org
allow update, delete: if isSignedIn() && (hasRole(orgId, "owner") || hasRole(orgId, "admin"));
// Members subcollection
match /members/{memberUid} {
allow read: if isSignedIn() && isMember(orgId);
// Only owner/admin can add/remove members
allow create, update, delete: if isSignedIn() && (hasRole(orgId, "owner") || hasRole(orgId, "admin"));
}
// Example org resource subcollection
match /projects/{projectId} {
allow read: if isSignedIn() && isMember(orgId);
allow create, update, delete: if isSignedIn() && (hasRole(orgId, "owner") || hasRole(orgId, "admin"));
}
}
}
}
Important: Rules can call get()/exists() up to 10 times per request. Design your reads accordingly.
6) Billing with Stripe
With the Stripe extension installed, starting a checkout is as simple as writing a Firestore doc.
Create a checkout session (in your web app):
// src/billing.ts
import { db } from "./firebase";
import { collection, addDoc, onSnapshot } from "firebase/firestore";
import { auth } from "./firebase";
import { loadStripe } from "@stripe/stripe-js";
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!);
export async function startCheckout(priceId: string) {
const user = auth.currentUser;
if (!user) throw new Error("Not signed in");
const checkoutSessionRef = await addDoc(
collection(db, "customers", user.uid, "checkout_sessions"),
{
price: priceId,
mode: "subscription",
allow_promotion_codes: true,
success_url: window.location.origin + "/billing?success=true",
cancel_url: window.location.origin + "/billing?canceled=true"
}
);
return new Promise<void>((resolve, reject) => {
const unsub = onSnapshot(checkoutSessionRef, async (snap) => {
const data = snap.data();
if (!data) return;
const { sessionId, error } = data as any;
if (error) {
unsub();
reject(new Error(error.message));
}
if (sessionId) {
const stripe = await stripePromise;
if (!stripe) return reject(new Error("Stripe not loaded"));
unsub();
await stripe.redirectToCheckout({ sessionId });
resolve();
}
});
});
}
Reflect subscription state in org docs with a Cloud Function (optional):
- Listen to
/customers/{uid}/subscriptions/{subId}and update the user’s default org plan or feature flags. - Alternatively, read subscription state live where needed.
7) Cloud Functions (Gen 2) for administrative tasks
Initialize in functions/:
cd functions
npm i
npm i stripe firebase-admin firebase-functions
Example: Set custom claims on membership change (optional optimization):
// functions/src/index.ts
import * as admin from "firebase-admin";
import { onDocumentWritten } from "firebase-functions/v2/firestore";
import { defineSecret } from "firebase-functions/params";
admin.initializeApp();
export const syncMemberClaims = onDocumentWritten(
"orgs/{orgId}/members/{uid}",
async (event) => {
const { orgId, uid } = event.params;
const after = event.data?.after?.data() as { role?: string } | undefined;
// Remove claim if membership removed
const role = after?.role ?? null;
const user = await admin.auth().getUser(uid);
const claims = (user.customClaims || {}) as any;
// namespacing in claims
claims.orgs = claims.orgs || {};
if (role) {
claims.orgs[orgId] = { role };
} else {
if (claims.orgs[orgId]) delete claims.orgs[orgId];
}
await admin.auth().setCustomUserClaims(uid, claims);
}
);
Note: Clients must refresh ID tokens to see updated claims (e.g., sign out/in or call getIdToken(true)).
Example: Stripe webhook (manual approach if not using the extension):
// functions/src/stripeWebhook.ts
import Stripe from "stripe";
import * as functions from "firebase-functions/v2/https";
import * as admin from "firebase-admin";
import { defineSecret } from "firebase-functions/params";
const STRIPE_SECRET = defineSecret("STRIPE_SECRET");
const STRIPE_WEBHOOK_SECRET = defineSecret("STRIPE_WEBHOOK_SECRET");
export const stripeWebhook = functions.onRequest(
{ secrets: [STRIPE_SECRET, STRIPE_WEBHOOK_SECRET], region: "us-central1" },
async (req, res) => {
const stripe = new Stripe(STRIPE_SECRET.value(), { apiVersion: "2024-06-20" });
const sig = req.headers["stripe-signature"] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.rawBody, sig, STRIPE_WEBHOOK_SECRET.value());
} catch (err: any) {
console.error("Webhook signature verification failed", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle subscription events
if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.created") {
const sub = event.data.object as Stripe.Subscription;
// Map customer -> uid via your own mapping (store customer.id in Firestore under customers/{uid})
// Update org's plan/state accordingly
}
return res.json({ received: true });
}
);
If you’re using the Stripe extension, you usually don’t need this custom webhook; the extension manages it and writes subscription data to Firestore for you.
8) Invites and roles
Implement invites via a Firestore collection:
- orgs/{orgId}/invites/{inviteId} → { email, role, status, createdAt }
- Email delivery via SendGrid extension or custom SMTP function
- When invitee signs up and accepts, create members/{uid} with role
You can enforce that only admin/owner can create invites in Rules (write to invites only if hasRole).
9) Emulator Suite and tests
Run everything locally:
firebase emulators:start
Security Rules test (Jest + @firebase/rules-unit-testing):
// test/rules.test.ts
import {
initializeTestEnvironment,
assertSucceeds,
assertFails
} from "@firebase/rules-unit-testing";
import { doc, setDoc, getDoc } from "firebase/firestore";
let testEnv: any;
beforeAll(async () => {
testEnv = await initializeTestEnvironment({
projectId: "my-saas-dev",
firestore: { rules: require("fs").readFileSync("firestore.rules", "utf8") }
});
});
afterAll(async () => {
await testEnv.cleanup();
});
test("member can read org", async () => {
const aliceCtx = testEnv.authenticatedContext("alice");
const adminCtx = testEnv.unauthenticatedContext();
const adminDb = testEnv.unauthenticatedContext().firestore();
const orgRef = doc(adminDb, "orgs", "o1");
await setDoc(orgRef, { name: "Org 1" });
await setDoc(doc(adminDb, "orgs/o1/members", "alice"), { role: "member" });
const aliceDb = aliceCtx.firestore();
await assertSucceeds(getDoc(doc(aliceDb, "orgs", "o1")));
});
10) Deploy
# Build frontend and deploy hosting
npm run build
firebase deploy --only hosting
# Deploy rules, indexes, functions
firebase deploy --only firestore:rules,firestore:indexes,functions
Use preview channels for PRs:
firebase hosting:channel:deploy feature-branch-123
You’ll get a unique preview URL to share with testers.
Most Useful Firebase Features for SaaS
- Authentication and Identity Platform:
- Passwordless email link, SAML/OIDC for enterprise SSO (via Identity Platform upgrade).
- Multi-tenancy in Auth (Identity Platform) if you need distinct auth contexts per tenant.
- Firestore:
- Real-time listeners for dashboards and collaboration.
- Collection group queries for cross-subcollection search.
- Aggregation queries (count()) to avoid expensive scans.
- TTL policies to auto-expire temp docs (e.g., invites).
- Cloud Functions (Gen 2):
- Scale-to-zero backend, scheduled tasks, webhooks, heavy compute on Cloud Run.
- Region control and concurrency for cost/perf tuning.
- Firebase Extensions:
- Stripe subscriptions, Send email with SendGrid, Image resizing, Algolia Search, Trigger Email.
- App Check:
- Protect backend resources from abuse using reCAPTCHA/DeviceCheck/Play Integrity.
- Emulator Suite:
- Develop and test auth, rules, functions, and hosting locally with fast feedback.
- Hosting:
- Global CDN, zero-downtime deploys, preview channels per PR, custom domains + HTTP/2.
- Analytics + BigQuery Export:
- Product analytics and event pipelines to BI tools; combine with Firestore exports for insights.
- Remote Config + A/B Testing:
- Gate features, run pricing experiments, roll out safely.
Scaling, Performance, and Costs
- Data modeling and indexes:
- Design queries first; add composite indexes in firestore.indexes.json.
- Prefer many small docs over one hot doc; avoid counters on a single doc (use distributed counters).
- Reads and writes:
- Denormalize for read-optimized UI; real-time listeners should be scoped and unsubscribed when not visible.
- Use serverTimestamp() to avoid client clock skew.
- Aggregations:
- Use Firestore count() aggregation or maintain precomputed summary docs with Cloud Functions to reduce reads.
- Cold starts:
- Functions Gen 2 reduces cold starts vs Gen 1; choose memory/CPU appropriately and prefer a single region near users.
- Scheduling and background work:
- Cloud Scheduler + HTTPS function or Pub/Sub scheduled functions (Gen 2) for nightly jobs.
- Security:
- Keep Rule logic simple; test with the Emulator Suite.
- Use App Check to reduce abuse.
- Cost management:
- Set budgets and alerts in Google Cloud.
- Monitor read patterns; avoid chatty listeners.
- Export data to GCS/BigQuery for snapshots, analytics, and potential migrations.
Example composite index file:
// firestore.indexes.json (snippet)
{
"indexes": [
{
"collectionGroup": "projects",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "orgId", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
],
"fieldOverrides": []
}
Avoiding Lock‑In and Migration Strategy
Vendor lock-in is a concern for any early-stage product. Plan escape hatches:
- Data export:
- Scheduled Firestore exports to Cloud Storage; load into BigQuery or external warehouses.
- Keep a periodic JSON/Parquet snapshot for disaster recovery.
- Abstraction:
- Wrap Firestore reads/writes behind repository interfaces in your codebase.
- Encapsulate Stripe logic behind a billing service layer.
- Portability:
- Prefer web-standard auth flows and normalize user profile data.
- Avoid deep coupling to proprietary features unless they deliver clear differentiation.
- Search and analytics:
- Use Extensions (Algolia, BigQuery) so you can swap providers later with minimal core changes.
Conclusion
Firebase is a force multiplier for SaaS founders. You get authentication, a real-time database, serverless compute, secure hosting, analytics, and a thriving extension ecosystem that offloads complex features like subscriptions—so you can focus on your product.
With a thoughtful multi-tenant data model, clear role design, Stripe integration, and solid Security Rules, you can launch a production-ready MVP in days. As you grow, Firebase scales with you: Functions Gen 2 for heavy workloads, Remote Config for safe rollouts, App Check for abuse protection, and BigQuery for analytics. Combine this with cost-aware patterns and a light abstraction layer, and you’ll move fast without painting yourself into a corner.
Ship something useful this week—and iterate with real users next week. Firebase makes that cadence possible.
Resources
- Firebase Docs: https://firebase.google.com/docs
- Firestore