Designing a Multi-Tenant SaaS Frontend at Scale
February 22, 20262 min read7 views
Designing a Multi-Tenant SaaS Frontend at Scale
Tenant isolation, per-tenant configuration, feature flags, data boundaries, caching strategies, and performance isolation. Architecture patterns for serving thousands of tenants from a single codebase.
Multi-Tenancy Models
┌─────────────────────────────────────────────────────────────────┐
│ Multi-Tenancy Spectrum │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Single-Tenant Multi-Tenant │
│ (Dedicated) (Shared) │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │
│ │ Tenant │ │ Tenant │ │ Tenant │ │ All Tenants │ │
│ │ A │ │ A │ │ A │ │ A, B, C, D... │ │
│ ├─────────┤ ├─────────┤ ├─────────┤ │ │ │
│ │ Tenant │ │ Tenant │ │ Shared │ │ Shared App │ │
│ │ A │ │ A │ │ Compute │ │ Shared Compute │ │
│ │ Compute │ │ Compute │ ├─────────┤ │ Shared DB │ │
│ ├─────────┤ ├─────────┤ │ Tenant │ │ │ │
│ │ Tenant │ │ Shared │ │ A │ │ Row-level │ │
│ │ A │ │ DB │ │ DB │ │ isolation │ │
│ │ DB │ │ │ │ │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────────────┘ │
│ │
│ Dedicated Shared Shared Fully Shared │
│ Everything Compute Compute + (Our Focus) │
│ Dedicated DB │
│ │
│ Cost: High ─────────────────────────────────────► Low │
│ Isolation: Strong ──────────────────────────────► Logical │
│ Complexity: Low ────────────────────────────────► High │
│ │
└─────────────────────────────────────────────────────────────────┘
Tenant Context Architecture
Tenant Resolution
// lib/tenant/resolver.ts
interface Tenant {
id: string;
slug: string;
name: string;
domain: string | null;
plan: 'free' | 'pro' | 'enterprise';
config: TenantConfig;
features: string[];
createdAt: Date;
}
interface TenantConfig {
theme: ThemeConfig;
branding: BrandingConfig;
locale: LocaleConfig;
security: SecurityConfig;
}
type TenantResolutionStrategy = 'subdomain' | 'path' | 'domain' | 'header';
class TenantResolver {
private cache: Map<string, Tenant> = new Map();
private strategy: TenantResolutionStrategy;
constructor(strategy: TenantResolutionStrategy = 'subdomain') {
this.strategy = strategy;
}
async resolve(request: Request): Promise<Tenant | null> {
const identifier = this.extractIdentifier(request);
if (!identifier) return null;
// Check cache first
const cached = this.cache.get(identifier);
if (cached) return cached;
// Fetch from database
const tenant = await this.fetchTenant(identifier);
if (tenant) {
this.cache.set(identifier, tenant);
}
return tenant;
}
private extractIdentifier(request: Request): string | null {
const url = new URL(request.url);
switch (this.strategy) {
case 'subdomain': {
// acme.app.com → acme
const parts = url.hostname.split('.');
if (parts.length >= 3) {
return parts[0];
}
return null;
}
case 'path': {
// app.com/t/acme/dashboard → acme
const match = url.pathname.match(/^\/t\/([^/]+)/);
return match?.[1] ?? null;
}
case 'domain': {
// acme.com (custom domain) → lookup by domain
return url.hostname;
}
case 'header': {
// X-Tenant-ID header
return request.headers.get('x-tenant-id');
}
}
}
private async fetchTenant(identifier: string): Promise<Tenant | null> {
// Could be slug, domain, or ID depending on strategy
const tenant = await db.tenants.findFirst({
where: {
OR: [
{ slug: identifier },
{ domain: identifier },
{ id: identifier },
],
},
include: {
config: true,
},
});
return tenant;
}
}
export const tenantResolver = new TenantResolver('subdomain');
Tenant Context Provider
// lib/tenant/context.tsx
import { createContext, useContext } from 'react';
interface TenantContextValue {
tenant: Tenant;
config: TenantConfig;
features: Set<string>;
hasFeature: (feature: string) => boolean;
getTheme: () => ThemeConfig;
}
const TenantContext = createContext<TenantContextValue | null>(null);
export function TenantProvider({
tenant,
children,
}: {
tenant: Tenant;
children: React.ReactNode;
}) {
const features = new Set(tenant.features);
const value: TenantContextValue = {
tenant,
config: tenant.config,
features,
hasFeature: (feature) => features.has(feature),
getTheme: () => tenant.config.theme,
};
return (
<TenantContext.Provider value={value}>
{children}
</TenantContext.Provider>
);
}
export function useTenant(): TenantContextValue {
const context = useContext(TenantContext);
if (!context) {
throw new Error('useTenant must be used within TenantProvider');
}
return context;
}
// Middleware for Next.js
// middleware.ts
import { NextResponse } from 'next/server';
export async function middleware(request: Request) {
const tenant = await tenantResolver.resolve(request);
if (!tenant) {
// Redirect to main site or show 404
return NextResponse.redirect(new URL('/not-found', request.url));
}
// Add tenant to headers for downstream use
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenant.id);
response.headers.set('x-tenant-slug', tenant.slug);
response.headers.set('x-tenant-plan', tenant.plan);
return response;
}
Server Component Tenant Access
// app/layout.tsx
import { headers } from 'next/headers';
import { getTenantById } from '@/lib/tenant/queries';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const headersList = headers();
const tenantId = headersList.get('x-tenant-id');
if (!tenantId) {
notFound();
}
const tenant = await getTenantById(tenantId);
return (
<html lang={tenant.config.locale.language}>
<body>
<TenantProvider tenant={tenant}>
<ThemeProvider theme={tenant.config.theme}>
{children}
</ThemeProvider>
</TenantProvider>
</body>
</html>
);
}
Tenant Isolation
Data Isolation
// lib/db/tenant-isolation.ts
import { Prisma, PrismaClient } from '@prisma/client';
// Row-Level Security at application level
function withTenantIsolation(tenantId: string) {
return Prisma.defineExtension({
name: 'tenantIsolation',
query: {
$allModels: {
async findMany({ model, operation, args, query }) {
// Inject tenant filter
args.where = {
...args.where,
tenantId,
};
return query(args);
},
async findFirst({ model, operation, args, query }) {
args.where = {
...args.where,
tenantId,
};
return query(args);
},
async findUnique({ model, operation, args, query }) {
const result = await query(args);
// Verify tenant ownership
if (result && (result as any).tenantId !== tenantId) {
return null;
}
return result;
},
async create({ model, operation, args, query }) {
// Inject tenant ID
args.data = {
...args.data,
tenantId,
};
return query(args);
},
async update({ model, operation, args, query }) {
// Ensure can only update own data
args.where = {
...args.where,
tenantId,
};
return query(args);
},
async delete({ model, operation, args, query }) {
// Ensure can only delete own data
args.where = {
...args.where,
tenantId,
};
return query(args);
},
},
},
});
}
// Create tenant-scoped client
export function createTenantClient(tenantId: string) {
const prisma = new PrismaClient();
return prisma.$extends(withTenantIsolation(tenantId));
}
// Usage in API routes
export async function GET(request: Request) {
const tenantId = request.headers.get('x-tenant-id')!;
const db = createTenantClient(tenantId);
// All queries automatically scoped to tenant
const users = await db.user.findMany();
return Response.json(users);
}
Component Isolation
// components/tenant-boundary.tsx
import { ErrorBoundary } from 'react-error-boundary';
interface TenantBoundaryProps {
children: React.ReactNode;
fallback?: React.ReactNode;
onError?: (error: Error, tenantId: string) => void;
}
export function TenantBoundary({
children,
fallback,
onError,
}: TenantBoundaryProps) {
const { tenant } = useTenant();
return (
<ErrorBoundary
fallback={fallback ?? <TenantErrorFallback />}
onError={(error) => {
// Log with tenant context
console.error(`[Tenant ${tenant.id}]`, error);
onError?.(error, tenant.id);
// Report to monitoring
errorReporter.capture(error, {
tenantId: tenant.id,
tenantSlug: tenant.slug,
tenantPlan: tenant.plan,
});
}}
>
{children}
</ErrorBoundary>
);
}
function TenantErrorFallback() {
const { tenant } = useTenant();
return (
<div className="error-container">
<h2>Something went wrong</h2>
<p>Please contact support at {tenant.config.support.email}</p>
</div>
);
}
Style Isolation
// lib/styles/tenant-styles.ts
import { createGlobalStyle, ThemeProvider } from 'styled-components';
interface TenantTheme {
colors: {
primary: string;
secondary: string;
background: string;
text: string;
error: string;
success: string;
};
fonts: {
heading: string;
body: string;
};
spacing: Record<string, string>;
borderRadius: string;
}
// Generate CSS custom properties from tenant config
function generateCSSVariables(theme: TenantTheme): string {
return `
:root {
--color-primary: ${theme.colors.primary};
--color-secondary: ${theme.colors.secondary};
--color-background: ${theme.colors.background};
--color-text: ${theme.colors.text};
--color-error: ${theme.colors.error};
--color-success: ${theme.colors.success};
--font-heading: ${theme.fonts.heading};
--font-body: ${theme.fonts.body};
--border-radius: ${theme.borderRadius};
}
`;
}
const TenantGlobalStyles = createGlobalStyle<{ theme: TenantTheme }>`
${({ theme }) => generateCSSVariables(theme)}
body {
font-family: var(--font-body);
background-color: var(--color-background);
color: var(--color-text);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
}
`;
export function TenantThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
const { config } = useTenant();
const theme = buildTheme(config.theme);
return (
<ThemeProvider theme={theme}>
<TenantGlobalStyles theme={theme} />
{children}
</ThemeProvider>
);
}
function buildTheme(themeConfig: ThemeConfig): TenantTheme {
// Merge tenant config with defaults
return {
colors: {
primary: themeConfig.primaryColor ?? '#0066cc',
secondary: themeConfig.secondaryColor ?? '#6b7280',
background: themeConfig.backgroundColor ?? '#ffffff',
text: themeConfig.textColor ?? '#1f2937',
error: '#dc2626',
success: '#16a34a',
},
fonts: {
heading: themeConfig.headingFont ?? 'Inter, sans-serif',
body: themeConfig.bodyFont ?? 'Inter, sans-serif',
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
},
borderRadius: themeConfig.borderRadius ?? '0.375rem',
};
}
Per-Tenant Feature Flags
// lib/features/tenant-flags.ts
interface TenantFeatureFlag {
name: string;
enabled: boolean;
plans: ('free' | 'pro' | 'enterprise')[];
tenantOverrides: Record<string, boolean>; // tenantId -> enabled
config?: Record<string, unknown>;
}
class TenantFeatureService {
private flags: Map<string, TenantFeatureFlag> = new Map();
async loadFlags(): Promise<void> {
const flags = await db.featureFlags.findMany();
for (const flag of flags) {
this.flags.set(flag.name, flag);
}
}
isEnabled(flagName: string, tenant: Tenant): boolean {
const flag = this.flags.get(flagName);
if (!flag) return false;
// Check tenant-specific override first
if (flag.tenantOverrides[tenant.id] !== undefined) {
return flag.tenantOverrides[tenant.id];
}
// Check plan-based access
if (!flag.plans.includes(tenant.plan)) {
return false;
}
// Check global enabled status
return flag.enabled;
}
getConfig<T>(flagName: string, tenant: Tenant): T | null {
if (!this.isEnabled(flagName, tenant)) return null;
const flag = this.flags.get(flagName);
return (flag?.config as T) ?? null;
}
// Get all enabled features for a tenant
getEnabledFeatures(tenant: Tenant): string[] {
const enabled: string[] = [];
for (const [name, flag] of this.flags) {
if (this.isEnabled(name, tenant)) {
enabled.push(name);
}
}
return enabled;
}
}
export const tenantFeatures = new TenantFeatureService();
// React hook
export function useTenantFeature(flagName: string): boolean {
const { tenant } = useTenant();
return tenantFeatures.isEnabled(flagName, tenant);
}
// Component wrapper
export function TenantFeature({
flag,
children,
fallback,
}: {
flag: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const enabled = useTenantFeature(flag);
if (!enabled) {
return fallback ?? null;
}
return <>{children}</>;
}
// Usage
function Dashboard() {
return (
<div>
<DashboardHeader />
<TenantFeature flag="advanced-analytics" fallback={<BasicAnalytics />}>
<AdvancedAnalytics />
</TenantFeature>
<TenantFeature flag="ai-insights">
<AIInsightsWidget />
</TenantFeature>
</div>
);
}
Routing Strategies
Subdomain-Based Routing
// middleware.ts
import { NextResponse } from 'next/server';
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host') ?? '';
const url = request.nextUrl.clone();
// Extract subdomain
// acme.app.com → acme
// app.com → null (main app)
const subdomain = getSubdomain(hostname);
if (!subdomain) {
// Main marketing site
return NextResponse.next();
}
// Resolve tenant
const tenant = await tenantResolver.resolve(subdomain);
if (!tenant) {
// Invalid tenant - show error or redirect
return NextResponse.redirect(new URL('/tenant-not-found', request.url));
}
// Rewrite to tenant-specific route
url.pathname = `/tenant/${tenant.id}${url.pathname}`;
const response = NextResponse.rewrite(url);
response.headers.set('x-tenant-id', tenant.id);
return response;
}
function getSubdomain(hostname: string): string | null {
const parts = hostname.split('.');
// localhost:3000 or app.com
if (parts.length < 3) return null;
// acme.app.com → acme
// www.app.com → null (www is not a tenant)
const subdomain = parts[0];
if (subdomain === 'www' || subdomain === 'app') return null;
return subdomain;
}
Path-Based Routing
// app/[tenant]/layout.tsx
import { notFound } from 'next/navigation';
import { getTenantBySlug } from '@/lib/tenant/queries';
export default async function TenantLayout({
children,
params,
}: {
children: React.ReactNode;
params: { tenant: string };
}) {
const tenant = await getTenantBySlug(params.tenant);
if (!tenant) {
notFound();
}
return (
<TenantProvider tenant={tenant}>
<TenantThemeProvider>
<TenantBoundary>
{children}
</TenantBoundary>
</TenantThemeProvider>
</TenantProvider>
);
}
// Generate static params for known tenants
export async function generateStaticParams() {
const tenants = await db.tenants.findMany({
where: { status: 'active' },
select: { slug: true },
});
return tenants.map((t) => ({ tenant: t.slug }));
}
Custom Domain Routing
// lib/tenant/domain-routing.ts
interface DomainMapping {
domain: string;
tenantId: string;
verified: boolean;
}
class CustomDomainRouter {
private domainCache: Map<string, DomainMapping> = new Map();
async resolve(hostname: string): Promise<Tenant | null> {
// Check if it's a custom domain
if (this.isCustomDomain(hostname)) {
return this.resolveDomain(hostname);
}
// Fall back to subdomain resolution
return tenantResolver.resolve(hostname);
}
private isCustomDomain(hostname: string): boolean {
const appDomains = ['app.com', 'localhost'];
return !appDomains.some((d) => hostname.endsWith(d));
}
private async resolveDomain(domain: string): Promise<Tenant | null> {
// Check cache
const cached = this.domainCache.get(domain);
if (cached) {
return this.getTenant(cached.tenantId);
}
// Lookup in database
const mapping = await db.domainMappings.findUnique({
where: { domain },
});
if (!mapping || !mapping.verified) {
return null;
}
this.domainCache.set(domain, mapping);
return this.getTenant(mapping.tenantId);
}
// Verify domain ownership via DNS
async verifyDomain(tenantId: string, domain: string): Promise<boolean> {
const expectedTxt = `saas-verify=${tenantId}`;
try {
const records = await dns.resolveTxt(domain);
const verified = records.flat().includes(expectedTxt);
if (verified) {
await db.domainMappings.update({
where: { domain },
data: { verified: true },
});
}
return verified;
} catch {
return false;
}
}
}
Caching Strategy
Tenant-Aware Cache Keys
// lib/cache/tenant-cache.ts
class TenantCache {
private redis: RedisClient;
constructor() {
this.redis = createRedisClient();
}
// Generate tenant-scoped cache key
private key(tenantId: string, ...parts: string[]): string {
return `tenant:${tenantId}:${parts.join(':')}`;
}
async get<T>(tenantId: string, key: string): Promise<T | null> {
const data = await this.redis.get(this.key(tenantId, key));
return data ? JSON.parse(data) : null;
}
async set<T>(
tenantId: string,
key: string,
value: T,
ttl: number = 3600
): Promise<void> {
await this.redis.setex(
this.key(tenantId, key),
ttl,
JSON.stringify(value)
);
}
async invalidate(tenantId: string, pattern: string): Promise<void> {
const keys = await this.redis.keys(this.key(tenantId, pattern));
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
async invalidateAll(tenantId: string): Promise<void> {
await this.invalidate(tenantId, '*');
}
}
export const tenantCache = new TenantCache();
// CDN cache with tenant keys
export function getTenantCacheHeaders(
tenant: Tenant,
options: { maxAge: number; tags?: string[] }
): HeadersInit {
return {
'Cache-Control': `public, s-maxage=${options.maxAge}, stale-while-revalidate=86400`,
'Surrogate-Key': [
`tenant-${tenant.id}`,
...(options.tags ?? []).map((t) => `tenant-${tenant.id}-${t}`),
].join(' '),
'Vary': 'X-Tenant-ID',
};
}
// Invalidate tenant's CDN cache
async function invalidateTenantCDN(tenantId: string, tags?: string[]): Promise<void> {
const keysToInvalidate = tags
? tags.map((t) => `tenant-${tenantId}-${t}`)
: [`tenant-${tenantId}`];
await cdnInvalidator.invalidate({ tags: keysToInvalidate });
}
Cache Isolation
// lib/cache/isolation.ts
interface CacheIsolationConfig {
maxMemoryPerTenant: number; // MB
defaultTTL: number;
evictionPolicy: 'lru' | 'lfu' | 'ttl';
}
class IsolatedTenantCache {
private tenantCaches: Map<string, LRUCache<string, unknown>> = new Map();
private config: CacheIsolationConfig;
constructor(config: CacheIsolationConfig) {
this.config = config;
}
private getOrCreateCache(tenantId: string): LRUCache<string, unknown> {
if (!this.tenantCaches.has(tenantId)) {
this.tenantCaches.set(
tenantId,
new LRUCache({
maxSize: this.config.maxMemoryPerTenant * 1024 * 1024,
sizeCalculation: (value) =>
JSON.stringify(value).length,
ttl: this.config.defaultTTL * 1000,
})
);
}
return this.tenantCaches.get(tenantId)!;
}
get<T>(tenantId: string, key: string): T | undefined {
const cache = this.getOrCreateCache(tenantId);
return cache.get(key) as T | undefined;
}
set<T>(tenantId: string, key: string, value: T, ttl?: number): void {
const cache = this.getOrCreateCache(tenantId);
cache.set(key, value, { ttl: ttl ? ttl * 1000 : undefined });
}
clear(tenantId: string): void {
const cache = this.tenantCaches.get(tenantId);
if (cache) {
cache.clear();
}
}
getStats(tenantId: string): CacheStats {
const cache = this.tenantCaches.get(tenantId);
if (!cache) {
return { size: 0, itemCount: 0, hitRate: 0 };
}
return {
size: cache.calculatedSize ?? 0,
itemCount: cache.size,
hitRate: cache.size > 0 ? (cache as any).hits / ((cache as any).hits + (cache as any).misses) : 0,
};
}
}
export const isolatedCache = new IsolatedTenantCache({
maxMemoryPerTenant: 50, // 50MB per tenant
defaultTTL: 300, // 5 minutes
evictionPolicy: 'lru',
});
Performance Isolation
Rate Limiting Per Tenant
// lib/rate-limit/tenant-limiter.ts
interface TenantRateLimits {
free: { requests: number; window: number };
pro: { requests: number; window: number };
enterprise: { requests: number; window: number };
}
const RATE_LIMITS: TenantRateLimits = {
free: { requests: 100, window: 60 }, // 100 req/min
pro: { requests: 1000, window: 60 }, // 1000 req/min
enterprise: { requests: 10000, window: 60 }, // 10000 req/min
};
class TenantRateLimiter {
private redis: RedisClient;
constructor() {
this.redis = createRedisClient();
}
async checkLimit(tenant: Tenant): Promise<{
allowed: boolean;
remaining: number;
resetAt: number;
}> {
const limits = RATE_LIMITS[tenant.plan];
const key = `ratelimit:${tenant.id}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = Math.floor(now / limits.window) * limits.window;
const windowKey = `${key}:${windowStart}`;
// Increment and get count
const count = await this.redis.incr(windowKey);
// Set expiry on first request
if (count === 1) {
await this.redis.expire(windowKey, limits.window);
}
const allowed = count <= limits.requests;
const remaining = Math.max(0, limits.requests - count);
const resetAt = windowStart + limits.window;
// Track metrics
metrics.gauge('rate_limit.usage', count / limits.requests, {
tenantId: tenant.id,
plan: tenant.plan,
});
return { allowed, remaining, resetAt };
}
}
export const tenantRateLimiter = new TenantRateLimiter();
// Middleware
export async function rateLimitMiddleware(
request: Request,
tenant: Tenant
): Promise<Response | null> {
const { allowed, remaining, resetAt } = await tenantRateLimiter.checkLimit(tenant);
if (!allowed) {
return new Response('Too Many Requests', {
status: 429,
headers: {
'X-RateLimit-Limit': String(RATE_LIMITS[tenant.plan].requests),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(resetAt),
'Retry-After': String(resetAt - Math.floor(Date.now() / 1000)),
},
});
}
// Add rate limit headers to response
return null; // Continue to handler
}
Resource Quotas
// lib/quotas/tenant-quotas.ts
interface TenantQuotas {
storage: number; // MB
apiCalls: number; // per month
users: number;
projects: number;
customDomains: number;
}
const PLAN_QUOTAS: Record<string, TenantQuotas> = {
free: {
storage: 100,
apiCalls: 10000,
users: 3,
projects: 2,
customDomains: 0,
},
pro: {
storage: 5000,
apiCalls: 100000,
users: 20,
projects: 20,
customDomains: 3,
},
enterprise: {
storage: -1, // unlimited
apiCalls: -1,
users: -1,
projects: -1,
customDomains: -1,
},
};
class QuotaService {
async checkQuota(
tenant: Tenant,
resource: keyof TenantQuotas,
increment: number = 1
): Promise<{ allowed: boolean; current: number; limit: number }> {
const quotas = PLAN_QUOTAS[tenant.plan];
const limit = quotas[resource];
// Unlimited
if (limit === -1) {
return { allowed: true, current: 0, limit: -1 };
}
const current = await this.getCurrentUsage(tenant.id, resource);
const allowed = current + increment <= limit;
return { allowed, current, limit };
}
async getCurrentUsage(
tenantId: string,
resource: keyof TenantQuotas
): Promise<number> {
switch (resource) {
case 'storage':
return this.getStorageUsage(tenantId);
case 'apiCalls':
return this.getApiCallsThisMonth(tenantId);
case 'users':
return db.users.count({ where: { tenantId } });
case 'projects':
return db.projects.count({ where: { tenantId } });
case 'customDomains':
return db.domainMappings.count({ where: { tenantId } });
default:
return 0;
}
}
private async getStorageUsage(tenantId: string): Promise<number> {
const result = await db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size), 0) as total
FROM files
WHERE tenant_id = ${tenantId}
`;
return Number(result[0].total) / (1024 * 1024); // Convert to MB
}
private async getApiCallsThisMonth(tenantId: string): Promise<number> {
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
return db.apiLogs.count({
where: {
tenantId,
createdAt: { gte: startOfMonth },
},
});
}
}
export const quotaService = new QuotaService();
// Usage in API route
export async function POST(request: Request) {
const tenant = await getTenant(request);
const { allowed, current, limit } = await quotaService.checkQuota(
tenant,
'projects'
);
if (!allowed) {
return Response.json(
{
error: 'Quota exceeded',
message: `You've reached your limit of ${limit} projects. Upgrade to create more.`,
current,
limit,
},
{ status: 403 }
);
}
// Create project...
}
Tenant Configuration UI
// components/tenant-settings/theme-editor.tsx
interface ThemeEditorProps {
tenant: Tenant;
onSave: (config: ThemeConfig) => Promise<void>;
}
function ThemeEditor({ tenant, onSave }: ThemeEditorProps) {
const [config, setConfig] = useState(tenant.config.theme);
const [preview, setPreview] = useState(false);
const updateConfig = (key: keyof ThemeConfig, value: string) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
return (
<div className="theme-editor">
<div className="editor-panel">
<h2>Theme Settings</h2>
<div className="setting-group">
<label>Primary Color</label>
<ColorPicker
value={config.primaryColor}
onChange={(v) => updateConfig('primaryColor', v)}
/>
</div>
<div className="setting-group">
<label>Secondary Color</label>
<ColorPicker
value={config.secondaryColor}
onChange={(v) => updateConfig('secondaryColor', v)}
/>
</div>
<div className="setting-group">
<label>Logo</label>
<ImageUpload
value={config.logoUrl}
onChange={(v) => updateConfig('logoUrl', v)}
maxSize={500 * 1024} // 500KB
/>
</div>
<div className="setting-group">
<label>Favicon</label>
<ImageUpload
value={config.faviconUrl}
onChange={(v) => updateConfig('faviconUrl', v)}
maxSize={100 * 1024} // 100KB
/>
</div>
<div className="setting-group">
<label>Heading Font</label>
<FontPicker
value={config.headingFont}
onChange={(v) => updateConfig('headingFont', v)}
/>
</div>
<div className="setting-group">
<label>Body Font</label>
<FontPicker
value={config.bodyFont}
onChange={(v) => updateConfig('bodyFont', v)}
/>
</div>
<div className="actions">
<Button variant="secondary" onClick={() => setPreview(!preview)}>
{preview ? 'Hide Preview' : 'Show Preview'}
</Button>
<Button onClick={() => onSave(config)}>Save Changes</Button>
</div>
</div>
{preview && (
<div className="preview-panel">
<ThemePreview config={config} />
</div>
)}
</div>
);
}
function ThemePreview({ config }: { config: ThemeConfig }) {
// Render preview in iframe for complete isolation
const previewHtml = generatePreviewHtml(config);
return (
<iframe
srcDoc={previewHtml}
sandbox="allow-scripts"
className="preview-iframe"
/>
);
}
Summary
Multi-tenant SaaS frontend architecture requires:
| Concern | Strategy | Implementation |
|---|---|---|
| Tenant Resolution | Subdomain/Path/Domain | Middleware + DNS |
| Data Isolation | Row-level security | Prisma extension |
| Style Isolation | CSS custom properties | Scoped theme provider |
| Feature Flags | Plan + tenant overrides | Feature service |
| Caching | Tenant-scoped keys | Redis + CDN surrogate keys |
| Rate Limiting | Per-plan limits | Redis sliding window |
| Resource Quotas | Usage tracking | Database aggregations |
Key principles:
- Resolve tenant early — Middleware sets context for entire request
- Isolate by default — All queries scoped to tenant
- Cache with tenant keys — Prevent data leakage
- Rate limit per plan — Protect shared resources
- Monitor per tenant — Track usage and errors separately
- Fail isolated — One tenant's error doesn't affect others
The goal: thousands of tenants on shared infrastructure, each experiencing a custom product, with isolation guarantees and predictable performance.
What did you think?