In today’s SaaS world, multi-tenancy is a powerful pattern that lets you serve multiple customers (tenants) from a single codebase and infrastructure. This approach drives cost efficiency, faster feature roll-outs, and simplified maintenance. However, building a robust multi-tenant app also introduces challenges around data isolation, tenant-aware routing, and security.
In this tutorial, you’ll learn how to build a Next.js v14.x multi-tenant application backed by Firebase. We’ll cover:
- Multi-tenancy models and routing strategies
- Middleware-based tenant detection
- Tenant context management in Next.js
- Firestore data partitioning and security rules
- Implementing tenant dashboards with SSR/ISR and real-time updates
- Deployment to Firebase Hosting and Vercel
- Common pitfalls and interview Q&A
By the end, you’ll have a blueprint to launch your own multi-tenant SaaS platform. Ready to elevate your Next.js game and deliver tailored experiences for every customer? Let’s get started and build something impressive!
What Is Multi-Tenancy?
Multi-tenant applications serve multiple customers (tenants) from a shared codebase and infrastructure, with logical data and configuration isolation.
- Benefits: Lower infrastructure costs, unified feature releases, easier maintenance.
- Challenges: Secure data partitioning, tenant-aware routing/theming, per-tenant scaling.
Choosing a Tenancy Model & Routing
- Subdomain Routing
alice.myapp.com→ Tenant “alice”bob.myapp.com→ Tenant “bob”
- URL-Path Routing
myapp.com/alicemyapp.com/bob
- Custom Domains
app.alice.com
Next.js middleware on the Edge can parse hostnames or paths to determine the current tenant and forward that info downstream.
Next.js Architecture for Tenants
User Request ↓ Edge Middleware → Determine tenantId (subdomain/path) ↓ Next.js App Router • TenantProvider reads tenantId • Server Components fetch tenant-scoped data • Client Components subscribe to real-time updates ↓ Firebase Services • Auth with tenant custom claims • Firestore collections per tenant • Hosting & Functions for SSR
Implementing Tenant Detection
Edge Middleware (Subdomain/Path)
Create middleware.ts at the project root:
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const host = req.headers.get('host') || '';
const subdomain = host.split('.')[0];
const pathTenant = req.nextUrl.pathname.split('/')[1];
const tenantId = subdomain !== 'www' ? subdomain : pathTenant || 'public';
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenantId);
return response;
}
export const config = { matcher: '/:path*' };
Tenant Context Provider
// app/providers/TenantProvider.tsx
'use client';
import { createContext, useContext } from 'react';
const TenantCtx = createContext({ tenantId: 'public' });
export function TenantProvider({ children }) {
const tenantId =
typeof window !== 'undefined'
? window.location.hostname.split('.')[0] || 'public'
: 'public'; // For SSR, read from headers if needed
return (
<TenantCtx.Provider value={{ tenantId }}>
{children}
</TenantCtx.Provider>
);
}
export const useTenant = () => useContext(TenantCtx);
Wrap your root layout:
// app/layout.tsx
import { TenantProvider } from './providers/TenantProvider';
export default function RootLayout({ children }) {
return (
<html>
<body>
<TenantProvider>{children}</TenantProvider>
</body>
</html>
);
}
Partitioning Data in Firebase
Firestore Structure
Option A: Per-Tenant Subcollections
/tenants/{tenantId}/projects/{projectId}/tasks/{taskId}
Option B: Shared Collections with tenantId Field
/projects
• { id, tenantId, name, ... }
Best Practice: Use subcollections (Option A) for stricter isolation.
Auth with Tenant-Scoped Users
Use Firebase Admin to set a tenantId custom claim on each user:
import { getAuth } from 'firebase-admin/auth';
export const onUserCreate = functions.auth.user().onCreate(async (user) => {
const tenantId = extractTenantFromEmail(user.email);
await getAuth().setCustomUserClaims(user.uid, { tenantId });
});
Security Rules
rules_version = '2';
service cloud.firestore {
match /databases/{db}/documents {
match /tenants/{tenantId}/{coll}/{docId} {
allow read, write: if request.auth != null
&& request.auth.token.tenantId == tenantId;
}
}
}
Building the Tenant Dashboard
Server Component (SSR/ISR)
// app/dashboard/page.tsx
import { db } from '@/lib/firebase';
import { collection, getDocs, query, where } from 'firebase/firestore';
import { headers } from 'next/headers';
export const revalidate = 30; // ISR every 30s
export default async function Dashboard() {
const tenantId = headers().get('x-tenant-id')!;
const q = query(
collection(db, tenants/${tenantId}/projects),
where('status', '==', 'active')
);
const snap = await getDocs(q);
const projects = snap.docs.map(d => ({ id: d.id, ...d.data() }));
return (
<main>
<h1>{tenantId} Dashboard</h1>
<ul>
{projects.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</main>
);
}
Client Component (Real-Time)
// components/LiveNotifications.tsx
'use client';
import { useEffect, useState } from 'react';
import { db } from '@/lib/firebase';
import { collection, onSnapshot } from 'firebase/firestore';
import { useTenant } from '@/app/providers/TenantProvider';
export default function LiveNotifications() {
const { tenantId } = useTenant();
const [notes, setNotes] = useState([]);
useEffect(() => {
const unsub = onSnapshot(
collection(db, tenants/${tenantId}/notifications),
snap => setNotes(snap.docs.map(d => d.data()))
);
return () => unsub();
}, [tenantId]);
return (
<section>
<h2>Notifications</h2>
{notes.map((n, i) => (
<p key={i}>{n.message}</p>
))}
</section>
);
}
Key Gotchas & Best Practices
- Cold Starts: Use warm-up functions or keep-alives for SSR.
- Cache Isolation: Include
tenantIdin your cache keys for ISR/Edge. - CORS & Domains: Properly configure Firebase Hosting rewrites for custom domains.
- Environment Secrets: Store per-tenant secrets in Secret Manager or Firestore, not
.env.
Deployment Strategies
Firebase Hosting: Enable Next.js framework support
firebase experiments:enable webframeworks firebase init hosting # Select “Use existing project” → Next.js
npm run build firebase deploy
Vercel
- Connect your Git repo to Vercel.
- Add Firebase environment variables in Vercel dashboard.
- Configure wildcard/custom domains for subdomains.
- Vercel auto-handles Edge Functions for middleware.
Interview Questions & Answers
- How do you prevent data leakage between tenants?
Use separate Firestore subcollections per tenant and security rules that verifyrequest.auth.token.tenantIdmatches the document’s tenant. - How would you convert a single-tenant app to multi-tenant?
Add atenantIdfield, update routing to detect tenants, migrate existing data into tenant namespaces, then enforce security rules. - Can you cache tenant-specific pages at the edge safely?
Yes, always include the tenant ID in your cache key (e.g.,${tenantId}:${request.url}) to keep caches isolated. - How do you handle a traffic spike for one tenant?
Rely on Firebase’s auto-scaling and, if needed, add per-tenant rate limits or quotas in middleware to protect other tenants. - What’s your approach for per-tenant theming and feature flags?
Store each tenant’s settings in Firestore (e.g.,/tenants/{tenantId}/config) and load them into a React context at runtime. - Subdomain vs. path-based tenancy, what’s the trade-off?
Subdomains offer better branding but need DNS/SSL per tenant; path-based is simpler to set up but less white-labeled.






