Advanced GraphQL Federation Patterns

GraphQL federation distributes schema ownership across independent CMS subgraphs behind one gateway, so each content service deploys on its own cycle while clients query a single graph. The tradeoff you take on: strict boundary enforcement, explicit entity keys, and composition validation in CI. This is the pattern that scales headless content delivery without a monolithic content graph , and it belongs to the Headless CMS Architecture & Platform Selection section because choosing it shapes every other integration decision.

Integration Contract

A federated content graph has three kinds of participants. Subgraphs own types: an editorial subgraph wrapping Contentful or Sanity, a commerce subgraph wrapping the product catalogue, perhaps an identity subgraph. The router composes their schemas into a supergraph and plans queries across them. Clients, the frontends, see one schema and one endpoint. The contract between them is the set of entity keys, the ownership of each field, the headers the router forwards, and the caching hints each subgraph returns.

Headless CMS platforms rarely speak federation natively. Contentful and Hygraph expose GraphQL APIs that are not federation subgraphs, so in practice the editorial subgraph is a thin service you own that wraps the CMS API, adds @key directives and __resolveReference, and translates CMS types into the domain types the supergraph exposes. Hygraph’s content federation and similar vendor features can pull remote sources into the CMS’s own graph instead, which is a different trade-off covered in federating multiple CMS sources.

Bash
# .env: router and subgraph contract
APOLLO_ROUTER_CONFIG_PATH=./router.yaml
SUPERGRAPH_PATH=./supergraph.graphql        # composed in CI, never at runtime in production
EDITORIAL_SUBGRAPH_URL=https://editorial.internal/graphql
COMMERCE_SUBGRAPH_URL=https://commerce.internal/graphql
CMS_DELIVERY_TOKEN=held_by_editorial_subgraph_only
ROUTER_QUERY_DEPTH_LIMIT=8

Gateway composition

The composition router is the single entry point for clients. Apollo Router v1.30+ (or an open-source equivalent) handles composition validation, query planning, and execution routing. Define the supergraph explicitly so type collisions surface at compose time instead of as runtime schema drift.

At a glance, the router fronts independently owned subgraphs and resolves cross-service joins in one execution plan:

One router, independently owned subgraphsThe frontend queries the router, which plans the query across the commerce and editorial subgraphs; each subgraph resolves its entities by key with batched loaders, and the router merges results and applies cache hints.Frontend clientRoutersupergraph + query plancommerce subgraph@key(id)editorial subgraph@key(productId)Catalogue DBHeadless CMS APIone queryfetch_entitiesbatched
The router never talks to the CMS directly; each subgraph owns its data source and its types.
YAML
# supergraph.yaml
federation_version: 2
subgraphs:
  commerce:
    routing_url: https://commerce-cms.internal/graphql
    schema:
      file: ./subgraphs/commerce.graphql
  editorial:
    routing_url: https://editorial-cms.internal/graphql
    schema:
      file: ./subgraphs/editorial.graphql

rover supergraph compose --config supergraph.yaml generates the executable schema; validate type collisions here, before deploy, to avoid breaking client contracts. The GraphQL vs REST API Tradeoffs explain why federation curbs over- and under-fetching across multi-source content when the frontend needs precise payload control.

An editorial subgraph over a headless CMS

The editorial subgraph is where the CMS meets the supergraph, and it is usually small. It defines the domain types the supergraph should expose, implements __resolveReference with a batched loader, and translates CMS responses into those types. Keeping CMS-specific shapes, such as Contentful’s sys objects or Sanity’s _type fields, inside this service means the rest of the graph never depends on the CMS vendor.

TypeScript
// editorial-subgraph/index.ts
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { buildSubgraphSchema } from "@apollo/subgraph";
import DataLoader from "dataloader";
import gql from "graphql-tag";

const typeDefs = gql`
  extend schema @link(url: "https://specs.apollo.dev/federation/v2.5", import: ["@key", "@shareable"])

  type Product @key(fields: "id") {
    id: ID!
    description: String
    pageTitle: String
    heroImageUrl: String
  }
`;

interface EditorialEntry {
  productId: string;
  description?: string;
  pageTitle?: string;
  heroImage?: { url: string };
}

interface Context {
  locale: string;
  loader: DataLoader<string, EditorialEntry | null>;
}

async function fetchEditorial(ids: readonly string[], locale: string): Promise<(EditorialEntry | null)[]> {
  const url = new URL(`https://cdn.contentful.com/spaces/${process.env.CONTENTFUL_SPACE}/environments/master/entries`);
  url.searchParams.set("content_type", "productEditorial");
  url.searchParams.set("fields.productId[in]", ids.join(","));
  url.searchParams.set("locale", locale);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` } });
  if (!res.ok) throw new Error(`CMS ${res.status}`);
  const body = (await res.json()) as { items: { fields: EditorialEntry }[] };
  const byId = new Map(body.items.map((i) => [i.fields.productId, i.fields]));
  return ids.map((id) => byId.get(id) ?? null);
}

const resolvers = {
  Product: {
    __resolveReference: async (ref: { id: string }, ctx: Context) => {
      const entry = await ctx.loader.load(ref.id);
      return {
        id: ref.id,
        description: entry?.description ?? null,
        pageTitle: entry?.pageTitle ?? null,
        heroImageUrl: entry?.heroImage?.url ?? null,
      };
    },
  },
};

const server = new ApolloServer({ schema: buildSubgraphSchema({ typeDefs, resolvers }) });
await startStandaloneServer(server, {
  context: async ({ req }) => {
    const locale = (req.headers["x-locale"] as string) ?? "en-US";
    return { locale, loader: new DataLoader((ids) => fetchEditorial(ids, locale)) };
  },
  listen: { port: 4002 },
});

A new loader per request keeps batching within one operation and prevents data from leaking between requests. Every editorial field is nullable, so a missing entry or a CMS outage degrades to empty fields rather than an error that erases the whole product.

Entity resolution and cross-service joins

Cross-service joins use @key directives and __resolveReference implementations. Each subgraph declares primary identifiers for shared entities. Avoid implicit joins — explicit keys prevent N+1 cascades and keep resolver execution predictable.

GraphQL
# commerce.graphql
type Product @key(fields: "id") {
  id: ID!
  sku: String!
  price: Money
  editorialContent: ProductEditorial @external
}

type ProductEditorial @key(fields: "productId") @extends {
  productId: ID! @external
  pageTitle: String
  richText: JSON
}

The editorial subgraph resolves localized metadata on demand. Aligning field boundaries with Content Modeling Best Practices prevents circular dependencies and keeps domain ownership clear. For batch resolution, use DataLoader to aggregate requests across products within one execution cycle.

TypeScript
// editorial-resolver.ts
import DataLoader from 'dataloader';

// A single DataLoader instance is created per request and shared via context,
// so calls across multiple products batch into one upstream fetch.
export function createEditorialLoader(cms) {
  return new DataLoader(async (ids: string[]) => {
    const results = await cms.fetchByProductIds(ids);
    return ids.map(id => results.find(r => r.productId === id) || null);
  });
}

export const resolvers = {
  ProductEditorial: {
    __resolveReference: (reference, context) => {
      return context.editorialLoader.load(reference.productId);
    }
  }
};

Caching federated content

Gateway caching needs precise @cacheControl directives and HTTP header propagation. Set max-age per subgraph to match content volatility — editorial content takes shorter TTLs than commerce catalogs or static assets.

GraphQL
extend type Query {
  productFeed(locale: String!): [Product!]! @cacheControl(maxAge: 3600, scope: PUBLIC)
}

Configure the router to respect upstream Cache-Control headers and propagate ETag/Last-Modified to edge CDNs. Apply cache tags at the entity level for granular invalidation without full purges — detail in Federating multiple headless CMS sources with GraphQL.

Router configuration

The router’s configuration file controls what each request carries to subgraphs and how long it may wait. A minimal production configuration forwards an allow-list of headers, sets per-subgraph timeouts and enforces query limits:

YAML
# router.yaml
headers:
  all:
    request:
      - propagate:
          named: x-locale
      - propagate:
          named: x-preview        # set only after the router validated the preview session
traffic_shaping:
  subgraphs:
    editorial:
      timeout: 800ms
    commerce:
      timeout: 1500ms
limits:
  max_depth: 8
  max_aliases: 30

Timeouts per subgraph reflect how much each source matters for the page: commerce data such as price is essential, editorial copy can fall back. The depth and alias limits block the most common abusive query shapes before the router spends time planning them; rate limiting and query complexity covers cost-based limits that go further.

Schema & Content Modeling Considerations

Federation turns content modeling into a cross-team contract. The rule that keeps it manageable is single ownership: every field has exactly one subgraph that resolves it, and shared entities are linked by stable keys, never duplicated. For CMS content, that usually means the commerce subgraph owns products, prices and inventory, while the editorial subgraph owns descriptions, rich text, SEO fields and editorial imagery, keyed by the product id or SKU. The CMS content model should store that key explicitly, as a validated field on the editorial entry, so the link does not depend on matching titles or slugs.

Field ownership in a commerce and editorial supergraphFields of the Product entity, the subgraph that owns each field and the source system behind it.Product fieldOwning subgraphSourceid (key)commercecatalogue databaseprice, stockcommercecatalogue databasedescription, richTexteditorialheadless CMSpageTitle, seoDescriptioneditorialheadless CMSheroImageeditorialCMS asset CDN
One owner per field; the key field is the only field both subgraphs know about.

Localization adds a dimension that federation does not handle for you. Pass the locale as an argument on the root field and forward it to every subgraph through the router’s header propagation, so editorial fields resolve in the same locale as commerce fields such as localized prices. Keep locale out of entity keys; the key identifies the product, and the locale is request context.

Preview & Draft Workflow

Drafts cross subgraph boundaries awkwardly. When an editor previews a product page, editorial fields must come from the CMS preview API while commerce fields stay live. Model preview as request context: the router forwards a preview header, only after validating the preview session, and each subgraph decides what it means. The editorial subgraph switches to the preview token and bypasses its caches; the commerce subgraph ignores it. Never let the router cache responses for requests carrying the preview header. The draft/publish state patterns apply unchanged; federation only adds the need to forward the state through one more hop.

Error Handling & Resilience

A federated query can partially succeed: if the editorial subgraph times out, the router still returns commerce fields with errors for the missing ones. Design the frontend for that, rendering products with prices and a fallback description rather than failing the page. Configure per-subgraph timeouts in the router so one slow CMS call cannot hold the whole response, and use circuit breaking in the editorial subgraph around the CMS API. Make nullable any field that comes from a less reliable source, so a partial result is still valid GraphQL rather than a null that bubbles up and erases its parent.

Partial success when a subgraph times outThe router fetches products from the commerce subgraph and editorial fields from the editorial subgraph; the editorial call exceeds its timeout, so the router returns commerce data with an error for the editorial fields, and the frontend renders a fallback description.FrontendRoutercommerceeditorialquery productPageproductsid, price, stock_entities(productIds)editorial timeout 800 msdata + errors[path: description]render fallback description
Nullable editorial fields let the page render with prices even when the CMS is slow.

Testing & Observability

Composition is the first test: run rover supergraph compose in CI for every subgraph change and fail on errors, and use schema checks against recorded client operations to catch changes that would break existing queries. Contract tests between the editorial subgraph and the CMS catch content model changes before they reach the supergraph; the automated testing topic covers them. In production, trace queries across subgraphs with OpenTelemetry, and watch per-subgraph latency, error rates and the number of entity fetches per operation, which reveals N+1 patterns immediately.

Federation vs. stitching

Federation is the default for distributed schemas. Some legacy integrations still use schema stitching, but as Schema stitching for multi-vendor headless architectures shows, stitching lacks native type ownership and query-planning optimization, so it strains at enterprise scale. Federation’s standardized directives and tooling are documented in the Apollo Federation specification.

For Cross-service data aggregation with Apollo Federation, enforce CI schema checks, trace with OpenTelemetry, and set SLAs for subgraph latency. Following the GraphQL specification keeps type resolution and error handling consistent across federated boundaries.

When Not to Federate

Federation solves an organizational problem: several teams owning parts of one graph that many clients consume. Without that problem, it is overhead. A single team with one frontend and a CMS plus one commerce API will usually be faster and more reliable with server-side data fetching in the frontend, or a small backend-for-frontend that calls both APIs in parallel and shapes the response for its pages. The warning signs that federation is premature are a router maintained by the same people who write every subgraph, a supergraph with two subgraphs and one client, and schema checks that nobody but the author ever reads.

Federation becomes worth it when the costs of coordination exceed the costs of the infrastructure: several product teams shipping independently, mobile and web clients sharing the same domain types, and a platform team that can own the router, composition in CI and observability across subgraphs. The GraphQL versus REST topic covers the protocol decision that comes before this one.

Adopting Federation Incrementally

Teams that already run a single GraphQL server, often a backend-for-frontend that grew over time, can move to federation without a rewrite. Start by turning the existing server into the first subgraph, unchanged, behind a router. Clients switch their endpoint to the router, which initially routes everything to one place. Then extract domains one at a time into their own subgraphs, beginning with the most independent one, typically editorial content from the CMS, and use @override to migrate ownership of each field without breaking clients. Schema checks against real client operations make each step safe, and the router’s tracing shows immediately whether a newly extracted subgraph adds latency.

Security at the Router

The router is the public face of every subgraph, so it carries the security controls. Terminate authentication at the router and forward a verified identity to subgraphs, rather than the raw client token where possible. Disable introspection in production, or restrict it to internal networks. Prefer persisted queries for public clients, which turns the graph into an allow-list of known operations; persisted queries for secure endpoints covers the setup. And keep CMS tokens inside the subgraphs that need them, so a router compromise does not expose every backend credential.

Choosing a Runtime

Three runtimes cover most federated setups. The Apollo Router is a compiled binary with first-class support for header propagation, per-subgraph timeouts, query limits, persisted query safelisting and telemetry, and it is the default choice for production. The Node.js @apollo/gateway remains useful for local development and for teams that need custom JavaScript hooks, at the cost of performance and operational features. Open-source alternatives, such as routers that implement the federation specification outside Apollo’s ecosystem, fit teams that want to avoid vendor tooling or run on specific platforms. Whatever the runtime, compose the supergraph in CI, deploy the composed artifact, and keep the router’s configuration in version control next to the subgraphs’ schemas, so every change to the graph is reviewable in one place.

The guides below go deeper into each concern: aggregation and header propagation, the runtime choice between router-based federation and stitching, persisted queries, cost limits and type safety across subgraphs.

Frequently Asked Questions

Do I need federation to combine a CMS with other APIs?

No. For one frontend and two or three sources, a backend-for-frontend or server components that fetch from each API in parallel are simpler. Federation pays off when several teams own separate domains, several clients consume them, and a shared graph with independent deployments is worth the operational cost.

Can the CMS itself be a federation subgraph?

Rarely directly, because vendor GraphQL APIs do not implement the federation spec. Wrap the CMS in a small subgraph service that adds keys and reference resolvers, or use a vendor feature that federates remote sources into the CMS graph instead.

How does caching work across subgraphs?

Each subgraph returns cache hints for its fields, and the router computes the most restrictive policy for the whole response. Tag responses with entity ids so CMS webhooks can purge exactly the affected entries at the router or CDN.

What is the most common production failure?

N+1 entity fetches from resolvers that load one reference at a time. DataLoader in every __resolveReference, plus monitoring of entity fetches per operation, prevents most of them.

How many subgraphs is too many?

There is no fixed number, but each subgraph needs an owner, a deploy pipeline and monitoring. When subgraphs outnumber the teams that can maintain them, merge the ones that always change together.

Does federation work with REST-based CMS APIs?

Yes, through a subgraph that wraps the REST API. The router only sees GraphQL, so REST sources are as easy to federate as GraphQL ones.

Who should own the router?

A platform team, not any single domain team. The router’s configuration affects every subgraph, so changes to headers, limits and timeouts deserve a neutral owner and a review process of their own.

Can federation help with multi-brand or multi-region content?

Yes, when brands or regions are owned by different teams with their own CMS spaces. Each becomes a subgraph, and shared entities such as products or authors link them. When one team owns everything, a single CMS with localization and brand fields is usually simpler.