GraphQL Schema Stitching — One API to Rule Them All

Explore the modern and futuristic architecture of Valencia's City of Arts and Sciences in Spain. Hero image credit: Photo by Rafael Minguet Delgado on Pexels

Third in a series about migrating from legacy architectures to a modern Nuxt 4 stack. The series covers architecture, code generation, performance, infrastructure, and the automation philosophy behind every decision.


Table of Contents

The Problem: Frontend as Integration Middleware

Every non-trivial web application pulls data from more than one source. A common pattern is a headless CMS for marketing content and page structure, paired with a backend API for business logic such as pricing, order processing, and user data.

In legacy architectures, the frontend becomes the integration layer. Each page assembles its view by calling multiple APIs, each with its own authentication, error handling, response format, and rate limiting. The frontend developer is no longer writing UI code — they are writing middleware.

flowchart TB subgraph Browser["Frontend (Browser)"] direction TB Page["Page Component"] end subgraph CMS["CMS REST API"] CmsPage["/api/cms/page/homepage"] CmsNav["/api/cms/navigation"] end subgraph Backend["Backend REST API"] Subscriptions["/api/backend/subscriptions?zip=10115"] Offers["/api/backend/offers"] end ThirdParty["/api/ratings<br/>(Third-party API)"] Page -->|"fetch('/api/cms/page/homepage')"| CmsPage Page -->|"fetch('/api/cms/navigation')"| CmsNav Page -->|"fetch('/api/backend/subscriptions?zip=10115')"| Subscriptions Page -->|"fetch('/api/backend/offers')"| Offers Page -->|"fetch('/api/ratings')"| ThirdParty

Every new feature means working out which endpoints to call, in what order, and how to merge the responses. The frontend is doing work the backend should own.


What Schema Stitching Changes

Schema stitching merges multiple GraphQL schemas into a single unified schema. The frontend sees one endpoint, one type system, and one query language. It never needs to know which backend produced which field.

The idea is simple: an Apollo Server gateway running inside the Nuxt application fetches the schemas of all data sources at startup, merges them using @graphql-tools/stitch, and serves the result at a single /api/graphql endpoint.

flowchart LR Browser["Browser<br/>Single GraphQL Query"] -->|HTTP /api/graphql| Nuxt["Nuxt 4 (SSR + Gateway)<br/>Apollo Server at /api/graphql"] subgraph Gateway["Apollo Gateway"] Schema["Stitched Schema<br/>(merged at startup)"] end Nuxt --> Schema subgraph CMS["Headless CMS Subgraph"] CmsTypes["content, pages, navigation"] end subgraph BE["Backend Subgraph"] BeTypes["pricing, orders, user data"] end Schema -->|"resolve CMS fields"| CMS Schema -->|"resolve backend fields"| BE

From the client’s perspective, this architecture is invisible. A single query can fetch data from both sources. The gateway resolves each field from the correct subgraph transparently:

query ProductPage($path: String!) {
  page(path: $path) {           # ← resolved from CMS
    title
    content { ... }
  }
  offers(searchTerm: "xyz") {   # ← resolved from backend
    name
    price
    features
  }
}

One request, one response, one set of types. No manual data merging.


Cross-Subgraph Field Enrichment with @delegate

Merging schemas is only the beginning. The real value appears when data from one subgraph needs to be enriched with data from another — for example, taking a product ID from the backend and using it to fetch a CMS description, all transparently within a single client query.

This is where a custom @delegate directive comes in. Implemented on top of delegateToSchema from @graphql-tools/delegate, it lets you declare cross-subgraph relationships directly in the schema extension layer:

extend type AddonDetailsModel {
  description: String
    @delegate(
      to: "cms",
      field: "content",
      args: { id: "{addonId:slug}" }
    )
}

The gateway intercepts requests for description, extracts addonId from the already-resolved parent object, transforms it (here: slugified), and delegates to the CMS subgraph — all without the client knowing two systems were involved.

The @delegate directive has significant depth: placeholder types, formatters, N+1 mitigation, and testing strategies. These are covered in detail in Article 4: The @delegate Directive Deep Dive.


Caching: Not Everything Is Equal

Different data sources in a stitched schema have different caching needs:

Source Freshness Requirement Cache Strategy
CMS content Minutes to hours Aggressive (LRU, per-operation)
Product pricing Real-time No cache
Ratings Hours Moderate (TTL-based)
Navigation Until content changes Aggressive + webhook invalidation

The gateway implements per-operation caching: a whitelist of GraphQL operation names determines which queries are cached and for how long. CMS queries are cached aggressively; pricing queries bypass the cache entirely. See the Schema Stitching Handbook for a range of practical gateway patterns.

flowchart LR Client["Client"] -->|"GraphQL operation"| Gateway["Gateway"] subgraph CacheLayer["In-memory cache (per replica)"] Cache["Per-operation entries<br/>(keyed by operation name)"] end Gateway -->|"check/put"| Cache Cache -->|"hit"| Gateway Gateway -->|"miss → resolve via subgraph"| Subgraphs["CMS / Backend / Ratings"] Subgraphs --> Gateway Gateway -->|"response"| Client

Cache Invalidation via Pub/Sub

In a multi-replica deployment, cache invalidation must be coordinated. When the CMS publishes new content, it fires a webhook. The first application replica to receive the webhook publishes an invalidation message on a Redis Pub/Sub channel. All replicas subscribe to that channel and clear their local caches simultaneously.

flowchart TB CMS["CMS<br/>Content Change"] --> Webhook["Webhook"] Webhook --> R1["Replica #1"] R1 -->|"Clear local cache"| R1Cache["Replica #1 cache cleared"] R1 -->|"Publish 'invalidate'<br/>on Redis channel"| Redis["Redis Pub/Sub<br/>'invalidate' channel"] Redis --> R2["Replica #2"] Redis --> R3["Replica #3"] Redis --> R4["Replica #4"] R2 -->|"Clear local cache"| R2Cache["Replica #2 cache cleared"] R3 -->|"Clear local cache"| R3Cache["Replica #3 cache cleared"] R4 -->|"Clear local cache"| R4Cache["Replica #4 cache cleared"]

No stale data on any replica, no manual cache management. The CMS editor publishes content and all replicas serve the new version within seconds.


What This Architecture Eliminates

Concern Before (REST Multi-Source) After (Stitched GraphQL)
API calls per page 3–5 separate HTTP requests 1 GraphQL query
Data joining Manual in frontend code Automatic via @delegate
Type definitions Per-endpoint, per-source Generated from unified schema
Error handling Per-endpoint custom logic One Apollo error link
Retry logic Duplicated per client One retry link with backoff
Cache invalidation Manual or nonexistent Automatic via Pub/Sub
New data source New REST client + types New subgraph + stitch config

Further Reading


What’s Next

  • Article 4: The @delegate Directive Deep Dive — Cross-subgraph field resolution with typed placeholders and formatters.
  • Article 5: GraphQL-Based Code Generation — Eliminating All Boilerplate — How writing a .graphql file generates a fully typed Vue composable with zero manual work.
  • Article 6: Architecting Enterprise Nuxt with Custom Modules — How modules enforce real architectural boundaries across the codebase.

Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.

Hero image credit: Photo by Rafael Minguet Delgado on Pexels