Federating Multiple Headless CMS Sources with GraphQL
This guide applies Advanced GraphQL Federation Patterns to the multi-CMS case. Federating multiple headless CMS sources with GraphQL fails when teams treat the gateway as an API proxy instead of a type-resolution layer. The result is schema collisions on shared fields, auth context drift across upstreams, and unpredictable build times. What works: strict namespace isolation per subgraph, a centralized context transformer, cache tags mapped to content IDs, and schema validation enforced in CI.
Schema isolation and type unification
Merging content endpoints triggers type conflicts on ubiquitous fields — slug, author, status. Run each CMS as an independent subgraph and extend shared entities through federation directives rather than duplicating them. The @key directive sets the primary identifier the router uses to stitch partial objects across services.
extend type Article @key(fields: "id") {
id: ID! @external
title: String! @external
metadata: ContentMetadata @external
}
type ContentMetadata @key(fields: "canonicalUrl") {
canonicalUrl: String!
ogTitle: String
keywords: [String!]
}
Defining types at their source avoids cascading validation errors during incremental builds. The gateway resolves references by querying the owning subgraph for the @key fields, then merges the payload — which scales across multi-tenant environments where content models diverge. The Apollo Federation documentation covers how query planning and entity resolution interact.
Resolver routing and context propagation
Authentication drift is a frequent failure: when JWT expiry windows or token scopes differ across upstream CMS providers, the gateway dispatches stale or mismatched credentials. A centralized context transformer standardizes identity before routing — it strips downstream-specific headers, validates token scope, and injects one normalized execution context.
import { ApolloServerPlugin } from '@apollo/server';
import { verifyAndScopeToken } from './auth-utils';
export const authContextPlugin: ApolloServerPlugin = {
async requestDidStart({ request }) {
const authHeader = request.http?.headers.get('authorization');
if (!authHeader) throw new Error('Missing upstream auth context');
const [scheme, token] = authHeader.split(' ');
if (scheme.toLowerCase() !== 'bearer') {
throw new Error('Invalid authorization scheme');
}
const validatedToken = await verifyAndScopeToken(token, request.operationName);
return {
willSendResponse({ response }) {
// Enforce predictable cache headers at the gateway boundary
response.http?.headers.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
}
};
}
};
Log resolver latency per subgraph at the gateway so you can isolate an upstream bottleneck before it surfaces as a frontend timeout. Attach a correlation ID to every dispatched request for distributed tracing across CMS boundaries.
The context transformer sits between the client and the CMS subgraphs, normalizing identity before any upstream dispatch:
Caching across fragmented origins
CDN caching assumes a single origin with consistent ETag headers. Federation fragments each response across upstreams, breaking that assumption and causing stale content or needless Jamstack rebuilds. Propagate cache tags with @cacheControl: tag per content type and version, then purge targeted fragments via webhook. The MDN HTTP Caching reference explains why explicit max-age and stale-while-revalidate beat opaque CDN defaults here. Map cache tags directly to CMS content IDs so a webhook payload purges only affected fragments instead of whole route caches. Paired with incremental static regeneration (ISR), this keeps content fresh without sacrificing build performance.
N+1 mitigation
Sequential resolver execution across federated boundaries is the classic N+1 trap: fetch relational data from the primary CMS, then query an identity provider or secondary store per record, exhausting connection pools and inflating TTFB. Deploy DataLoader at the subgraph level to batch identical key requests into one upstream call, and use CMS-specific include/populate parameters to pre-fetch relations before they reach the federation layer.
Set per-subgraph timeouts and circuit breakers that fail fast on a degraded upstream, returning cached fallbacks or partial responses rather than blocking the whole graph. Add query complexity analysis at the router to reject deeply nested client requests.
Governance and CI enforcement
Schema contracts need teeth. Agency engineers and content teams routinely add ad-hoc fields or alter type definitions without coordination, which breaks downstream TypeScript generation and client type safety. Enforce schema registry validation in CI: require pull requests to pass graphql-codegen validation and contract testing before merge, and block any change that breaks a shared @key entity. Track resolver execution time, cache hit ratio, and type-generation success as DX metrics. For router-level optimizations and cross-service query planning, see Advanced GraphQL Federation Patterns.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| One subgraph per CMS source | yes | Independent deploys and credentials. |
| Shared entity keys | stable ids or canonical URLs | Joins survive content renames. |
| Context transformer | at the gateway, allow-listed headers | Consistent identity across different CMS auth models. |
| Cache tags | content id per fragment | Webhooks purge exactly the affected fragments. |
| CI | composition + codegen + contract tests on every change | Breaking changes never reach the router. |
Gotchas & Edge Cases
- Response-wide Cache-Control at the gateway. The plugin above sets
public, max-age=60on every response, which would cache authenticated or preview responses. Derive the header from the subgraphs’ cache hints and useprivate, no-storewhenever a request carries identity or preview context. - Throwing on missing auth for public content. Rejecting every request without an
Authorizationheader breaks anonymous reading of public content. Distinguish public operations from authenticated ones. - Two CMSs, one content type. When marketing and docs both model “Author”, decide which owns it and have the other reference it by key, or authors diverge between the two sites.
- Different rate limits per vendor. A burst that is fine for one CMS can exceed another’s limit. Set timeouts and concurrency per subgraph, not globally.
Worked Example
A software company ran marketing pages in Contentful, documentation in Sanity and product release notes in Strapi, each with its own author profiles and SEO fields. Their first unified API merged the three schemas and resolved collisions by prefixing type names, which left three incompatible Author types. Moving to federation with one Author entity owned by the marketing subgraph, keyed by an employee id stored in all three CMSs, gave every page the same author card and one place to update it. A shared ContentMetadata entity keyed by canonical URL let the SEO team audit metadata across all three sources with a single query.
Rollout Checklist
- Put each CMS source behind its own subgraph with its own credentials and timeouts.
- Decide one owner per shared entity and store the join key explicitly in every CMS that references it.
- Normalize identity at the gateway with an allow-list of forwarded headers.
- Derive response caching from subgraph hints, never a gateway-wide public header.
- Map every CMS webhook to content-id cache tags in one shared format.
- Gate every subgraph change on composition, codegen and contract tests in CI.
Roll out one source at a time. Start with the CMS whose content is referenced most, usually the one that owns authors or products, because every other subgraph will key into it. Add the remaining sources once that entity’s key is stable and stored in each CMS, and resist merging two sources’ versions of the same concept until ownership is agreed.
Frequently Asked Questions
Is federation better than a CMS vendor’s own content federation feature?
They solve different problems. Vendor features pull remote data into one CMS’s graph, which suits a CMS-centric team. Apollo-style federation keeps each source independent under a router you own, which suits several teams and several CMSs.
How do webhooks from several CMSs purge the right cache?
Each CMS webhook maps its entry ids to cache tags in the same format, for example content:<source>:<id>, and the purge service handles all sources identically.
Can one query span all three CMS sources?
Yes, that is the point of the shared graph. The router plans the query across subgraphs; keep such cross-source queries on the server, where their latency and cost are easier to control.
How do preview and drafts work across several CMS sources?
Forward a validated preview header through the router, and let each CMS subgraph switch to its own preview API when the header is present. Sources without drafts simply ignore it, so editors see drafts from their CMS combined with live data from the rest.
What about search across all sources?
Index content from every source into one search service, keyed by the same entity ids, and expose search as its own subgraph. Querying three CMS search APIs at runtime and merging results is slow and ranks inconsistently.
How do we version the shared entities?
Evolve them additively: add fields, deprecate old ones, and remove them only after schema checks show no client still requests them. Changing a shared key is a migration that needs a transition period where both old and new keys resolve.
Does each CMS need its own webhook handler?
One handler per CMS is simplest, because payloads and signatures differ, but all of them should emit the same internal event format so purging logic is shared.