Building a Multi-Tenant App with Next.js & Firebase

Building a Multi-Tenant App with Next.js & Firebase | Step-by-Step Guide

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

  1. Subdomain Routing
    • alice.myapp.com → Tenant “alice”
    • bob.myapp.com → Tenant “bob”
  2. URL-Path Routing
    • myapp.com/alice
    • myapp.com/bob
  3. 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 tenantId in 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

  1. Connect your Git repo to Vercel.
  2. Add Firebase environment variables in Vercel dashboard.
  3. Configure wildcard/custom domains for subdomains.
  4. Vercel auto-handles Edge Functions for middleware.

Interview Questions & Answers

  1. How do you prevent data leakage between tenants?
    Use separate Firestore subcollections per tenant and security rules that verify request.auth.token.tenantId matches the document’s tenant.
  2. How would you convert a single-tenant app to multi-tenant?
    Add a tenantId field, update routing to detect tenants, migrate existing data into tenant namespaces, then enforce security rules.
  3. 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.
  4. 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.
  5. 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.
  6. 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.