Setting up a headless CMS for a multi-tenant SaaS

Multi-tenant Software as a Service (SaaS) platforms require strict data partitioning at every architectural layer. You must isolate tenant boundaries across the Content Management System (CMS) backend, Application Programming Interface (API) delivery, and frontend routing. The core challenge involves maintaining absolute data separation without sacrificing engineering velocity.

Architectural decisions made early dictate long-term scalability. Evaluate enterprise-grade partitioning capabilities during platform selection. Refer to foundational patterns in Headless CMS Architecture & Platform Selection when mapping tenant boundaries to your infrastructure.

Content Modeling & Tenant Scoping Strategies

Tenant isolation begins at the schema level. You can implement tenant tagging via a shared field or enforce physical separation using distinct datasets. Shared schemas reduce initial setup friction but require rigorous query validation. Isolated datasets guarantee hard boundaries at the cost of duplicated content models.

Implement tenant-specific content types to enforce strict reference validation. Configure localized fallbacks to prevent null states when tenant-specific translations are missing. The choice between shared and isolated models directly impacts Developer Experience (DX) and team workflow. Review Developer Experience Tradeoffs in Headless to balance security requirements against content editor velocity.

API Routing & Framework Integration (Next.js App Router + Sanity)

Routing must inject tenant context before any data fetch occurs. Use Next.js middleware to extract the tenant identifier from the subdomain. Attach it as a custom header to all downstream requests. This ensures server-side route handlers receive validated context.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
 const hostname = request.headers.get('host') || '';
 const tenantId = hostname.split('.')[0]; // Extract from subdomain

 if (!tenantId || !process.env.ALLOWED_TENANTS?.includes(tenantId)) {
 return new NextResponse('Unauthorized', { status: 401 });
 }

 const response = NextResponse.next();
 // Critical: Attach tenant context to all downstream requests
 response.headers.set('X-Tenant-ID', tenantId);
 response.headers.set('Vary', 'X-Tenant-ID');
 return response;
}

export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };

Bind the CMS client dynamically using the injected header. Never hardcode dataset identifiers in client modules. Validate the header at the route handler level before initializing the fetch.

// lib/cms-client.ts
import { createClient } from '@sanity/client';

export function getTenantClient(tenantId: string) {
 const dataset = process.env.SANITY_DATASET_PREFIX
 ? `${process.env.SANITY_DATASET_PREFIX}_${tenantId}`
 : tenantId;

 return createClient({
 projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
 dataset,
 apiVersion: '2024-01-01',
 useCdn: false, // Disable CDN for authenticated/tenant-scoped routes
 token: process.env.SANITY_API_READ_TOKEN,
 });
}

Configure your Content Delivery Network (CDN) to respect the Vary header. Default cache keys often ignore custom headers, causing cross-tenant collisions. Explicitly map cache keys to include tenant identifiers.

// vercel.json (or equivalent CDN config)
{
 "headers": [
 {
 "source": "/api/(.*)",
 "headers": [
 { "key": "Cache-Control", "value": "public, max-age=60, s-maxage=3600" },
 { "key": "Vary", "value": "X-Tenant-ID" }
 ]
 }
 ]
}

Edge Case Troubleshooting: Cross-Tenant Content Leakage

Content leakage occurs when a CDN caches a response without accounting for tenant context. Tenant A requests a page. The CDN caches the payload. Tenant B requests the same path. The CDN serves Tenant Aโ€™s data.

Reproducible Scenario (Next.js 14 + Sanity v3)

  1. Deploy a single Sanity project with multiple datasets (tenant_a, tenant_b).
  2. Configure Next.js middleware to read the tenant from the subdomain.
  3. Enable Vercel Edge Cache with default path-only cache keys.
  4. Request /api/content from tenant-a.app.com, then immediately from tenant-b.app.com.
  5. Observe tenant-b.app.com returning cached tenant-a content.

Symptoms

  • Inconsistent rendering across tenant subdomains
  • Audit logs show identical query payloads with different tenant headers
  • Unexpected cache HIT ratio spikes after initial requests

Root Cause Analysis

  • Edge caching ignores custom X-Tenant-ID headers in key generation.
  • Client-side hydration fetches bypass server-side tenant injection.
  • CMS routing defaults to a production dataset when headers are malformed.
  • Shared GraphQL schemas lack mandatory tenant-scoped parameters.

Step-by-Step Resolution

  1. Append Vary: X-Tenant-ID to all API responses in middleware.
  2. Update CDN configuration to include X-Tenant-ID in cache key generation.
  3. Implement route handler validation that rejects requests missing valid context.
  4. Refactor CMS client initialization to dynamically bind the dataset based on the validated header.
  5. Add explicit tenant filters to all queries (e.g., tenant_id: $tenantId).
  6. Purge the edge cache and verify isolation using parallel curl requests.

Prevention, CI/CD Validation & Long-Term Maintenance

Isolation requires automated validation. Manual testing cannot scale across hundreds of tenants. Implement Continuous Integration/Continuous Deployment (CI/CD) pipelines that assert tenant boundaries before deployment.

// tests/tenant-isolation.test.ts
import { describe, it, expect } from 'vitest';
import { getTenantClient } from '../lib/cms-client';

describe('Tenant Data Isolation', () => {
 it('should return zero records when querying Tenant A with Tenant B context', async () => {
 const clientA = getTenantClient('tenant_a');
 const query = '*[_type == "product" && tenant_id == "tenant_b"]';
 const result = await clientA.fetch(query);
 expect(result).toHaveLength(0);
 });
});

Maintenance Checklist

  • Enforce strict Role-Based Access Control (RBAC) per tenant in the CMS console.
  • Segregate environment variables by tenant scope.
  • Scope webhooks to specific datasets to prevent cross-tenant triggers.
  • Deploy automated cache-busting webhooks on publish events.
  • Monitor dashboards for anomalous cross-tenant query patterns.
  • Alert on unauthenticated or header-missing requests exceeding thresholds.

Common Pitfalls

  • Hydration Mismatches: Client-side fetches often strip custom headers. Always fetch tenant-scoped data server-side and pass it as props.
  • Shared Incremental Static Regeneration (ISR) Tags: Using identical revalidate tags across tenants causes global cache invalidation. Prefix tags with tenant identifiers.
  • Fallback Dataset Routing: CMS clients default to a primary dataset when headers are missing. Explicitly throw errors instead of falling back.

FAQ

Q: Can I use a single Sanity project for all tenants? Yes, but only with strict dataset partitioning. Shared schemas require mandatory tenant filters on every query. Physical dataset isolation is safer for regulated industries.

Q: How do I handle tenant-specific domains vs. subdomains? Map domains to tenant IDs in your middleware. Use a configuration file or database lookup to resolve custom domains to internal identifiers. Pass the resolved ID to downstream services.

Q: Does Incremental Static Regeneration (ISR) work safely with multi-tenant setups? ISR works if cache keys include tenant context. Always append X-Tenant-ID to Vary headers. Use tenant-prefixed revalidation tags to prevent cross-cache pollution.

Q: How do I audit content leakage in production? Log the X-Tenant-ID header alongside every CMS request. Compare the logged tenant against the returned dataset ID. Set up alerts for mismatches exceeding zero occurrences.