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.

Why naive merging collidesFields that commonly exist in several CMS sources, how naive schema merging treats them, and how namespaced subgraphs with explicit ownership resolve them.FieldNaive mergeFederated resolutionsluglast source winsowned by the routable type's subgraphauthortwo incompatible typesone Author entity, one owner, keyed by idstatusenum values differnormalized in each subgraph before exposuremetadata / SEOduplicated objectsContentMetadata entity keyed by canonicalUrl
Collisions are resolved by ownership and keys, not by renaming fields until they stop clashing.
GraphQL
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.

TypeScript
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:

Context normalization before upstream dispatchThe frontend sends a query with an authorization header; the gateway's context transformer strips downstream headers and validates scope into one normalized context; the gateway then resolves key fields in the CMS subgraph with a correlation id and returns the merged response.FrontendGatewayContext transformerCMS subgraphquery + Authorizationstrip headers, validate scopenormalized contextresolve @key fields (correlation id)partial entitymerged response + Cache-Control
Every subgraph receives the same normalized identity, regardless of how each upstream CMS authenticates.

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.

Three CMS sources behind one graphA marketing Contentful space, a documentation Sanity dataset and a product Strapi instance each sit behind their own subgraph; the router composes them, and a shared ContentMetadata entity is keyed by canonical URL across all three.Routermarketing subgraphContentfuldocs subgraphSanityproduct subgraphStrapiContentMetadata@key(canonicalUrl)
Each CMS stays independent; only the shared entity and its key cross boundaries.

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=60 on every response, which would cache authenticated or preview responses. Derive the header from the subgraphs’ cache hints and use private, no-store whenever a request carries identity or preview context.
  • Throwing on missing auth for public content. Rejecting every request without an Authorization header 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.