Multi-Brand Content Governance in Headless Ecosystems
Running multiple brands from one headless platform means enforcing content isolation, role-scoped publishing, and deterministic preview routing on a shared delivery layer. Get any layer wrong and you get cross-brand schema collisions, leaked content, broken preview links, and cache contamination. This page enforces brand boundaries at the schema, API, and delivery layers — the controls that keep Multi-Tenant Architecture Patterns from becoming compliance failures.
Why multi-brand governance fails
Three misconfigurations cause most cross-brand failures:
- Flat models without namespace isolation. Shared field definitions apply validation globally. When
heroImageneeds different aspect ratios or alt-text rules per brand, global validation either blocks publishing or silently accepts invalid payloads. - Gateways without tenant-scoped resolution. Endpoints that resolve by slug or ID alone return the first match for
/blog/launch-updateregardless of brand ownership — straight data leakage. - Shared preview routing with aggressive caching. When staging shares a routing namespace, middleware injects the wrong brand identifier and edge caches ignore tenant headers. Paired with platform-level RBAC instead of workspace-level, editors publish to brands they shouldn’t.
Treat content as globally addressable rather than tenant-scoped and these compound into cache poisoning, broken ISR/SSG regeneration, and audit trails that can’t attribute changes to a workspace.
Resolution
- Namespace models by brand. Replace shared schemas with brand-prefixed types or interface contracts whose validation references brand-specific taxonomies, media libraries, and locales.
- Scope RBAC to the workspace. Assign editor/approver/publisher roles per brand identifier, evaluated before content enters the delivery queue.
- Inject brand context into queries. Route preview and delivery through middleware that extracts the brand from URL, subdomain, or header and appends it as a mandatory query filter.
- Partition cache keys. Vary edge caching by brand, locale, and content type; disable shared keys on preview routes and propagate strict
Varyheaders. - Validate schemas in CI. Lint content models before merge and block cross-brand field references, missing constraints, or unscoped relationships.
Code patterns
These enforce isolation at the schema, routing, and validation layers, ready for Jamstack or edge-rendered pipelines. The brand identifier resolves at the edge and must travel through every downstream boundary:
flowchart LR
A["Request: subdomain / path"] --> B{"Brand registry lookup"}
B -->|unknown| C["403: unauthorized brand"]
B -->|matched| D["Inject X-Brand-ID + locale"]
D --> E["Partition cache key by brand<br/>Vary: X-Brand-ID"]
E --> F["GraphQL: mandatory brandId filter"]
F --> G["Brand-scoped content"]
Brand-scoped GraphQL schema
Interfaces plus a required brand field enforce tenant resolution at the type level.
interface BrandScopedContent {
id: ID!
brandId: String!
publishedAt: DateTime
status: ContentStatus!
}
type Article implements BrandScopedContent {
id: ID!
brandId: String!
title: String!
slug: String!
body: RichText!
seo: SeoMetadata
status: ContentStatus!
publishedAt: DateTime
}
type Query {
# Mandatory brandId filter prevents global resolution
articleBySlug(brandId: String!, slug: String!): Article
articles(brandId: String!, limit: Int = 10, cursor: String): ArticleConnection!
}
A non-nullable brandId argument on every root query removes ambiguous resolution paths. See GraphQL Interfaces for the interface model behind multi-tenant data graphs.
Edge middleware for brand-context injection
Routing must resolve the tenant before any fetch. This middleware (Next.js, Remix, or custom Node edge) parses the subdomain or path prefix, validates against a registry, and attaches context to outgoing requests.
import { NextRequest, NextResponse } from 'next/server';
const BRAND_REGISTRY = new Map([
['acme', { brandId: 'acme_corp', locale: 'en-US' }],
['globex', { brandId: 'globex_inc', locale: 'en-GB' }],
]);
export async function middleware(req: NextRequest) {
const host = req.headers.get('host') || '';
const subdomain = host.split('.')[0];
const brandConfig = BRAND_REGISTRY.get(subdomain);
if (!brandConfig) {
return NextResponse.json({ error: 'Unauthorized brand context' }, { status: 403 });
}
// Attach brand context to request headers for downstream API consumption
const requestHeaders = new Headers(req.headers);
requestHeaders.set('X-Brand-ID', brandConfig.brandId);
requestHeaders.set('X-Brand-Locale', brandConfig.locale);
const response = NextResponse.next({
request: { headers: requestHeaders },
});
// Ensure CDN respects tenant boundaries
response.headers.set('Vary', 'X-Brand-ID, Accept-Language');
response.headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
return response;
}
export const config = {
matcher: ['/((?!api|_next/static|favicon.ico).*)'],
};
The Vary header carries the load here: without it, edge networks serve one tenant’s cached payload to another. See the HTTP Vary Header reference for cache partitioning behavior.
CDN cache-key partitioning
On Vercel, Cloudflare, or Fastly, the cache key must include the brand identifier — a missing one serves acme_corp content to globex_inc visitors.
# Cloudflare/Varnish-style cache key generation
set $cache_key "$scheme://$host$uri?brand=$http_x_brand_id&locale=$http_x_brand_locale";
proxy_cache_key $cache_key;
proxy_cache_valid 200 301 302 1h;
For JavaScript-based edge functions, implement cache key normalization before fetching:
async function fetchBrandContent(brandId: string, query: string) {
const cacheKey = `content:${brandId}:${Buffer.from(query).toString('base64')}`;
// Edge runtime cache implementation
const cached = await caches.default.match(cacheKey);
if (cached) return cached.json();
const res = await fetch('/api/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Brand-ID': brandId },
body: JSON.stringify({ query }),
});
const data = await res.json();
await caches.default.put(cacheKey, new Response(JSON.stringify(data), {
headers: { 'Cache-Control': 'public, max-age=3600' }
}));
return data;
}
CI schema validation
Catch cross-brand field leakage at the PR stage. This script parses GraphQL SDL and blocks merges that introduce unscoped relationships or a nullable brandId.
import { parse, visit } from 'graphql';
import fs from 'fs';
import path from 'path';
const SCHEMA_DIR = path.join(__dirname, '../schemas');
const BRAND_PREFIXES = ['acme', 'globex', 'nexus'];
function validateBrandIsolation() {
const files = fs.readdirSync(SCHEMA_DIR).filter(f => f.endsWith('.graphql'));
const errors: string[] = [];
for (const file of files) {
const content = fs.readFileSync(path.join(SCHEMA_DIR, file), 'utf-8');
const ast = parse(content);
visit(ast, {
ObjectTypeDefinition(node) {
const name = node.name.value;
const isBrandScoped = BRAND_PREFIXES.some(prefix => name.toLowerCase().startsWith(prefix));
const hasBrandIdField = node.fields?.some(f => f.name.value === 'brandId');
if (isBrandScoped && !hasBrandIdField) {
errors.push(`[SCHEMA VIOLATION] ${name} is brand-scoped but missing required 'brandId' field.`);
}
},
FieldDefinition(node) {
if (node.name.value === 'brandId' && node.type.kind !== 'NonNullType') {
errors.push(`[SCHEMA VIOLATION] 'brandId' must be non-nullable. Found optional type in ${node.name.value}.`);
}
}
});
}
if (errors.length > 0) {
console.error('❌ Brand governance validation failed:');
errors.forEach(e => console.error(` - ${e}`));
process.exit(1);
}
console.log('✅ Schema isolation validated. No cross-brand violations detected.');
}
validateBrandIsolation();
Wire this into CI (package.json script or GitHub Actions) to block deploys that breach tenant boundaries — the automated gate that keeps Enterprise CMS Governance & Compliance holding at scale.