GraphQL Schema Stitching — One API to Rule Them All

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.


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\n(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, and serves the result at a single /api/graphql endpoint.

flowchart LR
  Browser["Browser\nSingle GraphQL Query"] -->|HTTP /api/graphql| Nuxt["Nuxt 4 (SSR + Gateway)\nApollo Server at /api/graphql"]

  subgraph Gateway["Apollo Gateway"]
    Schema["Stitched Schema\n(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

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.


The @delegate Directive — Cross-Subgraph Field Resolution

Merging schemas is only the beginning. The real value appears when data from one subgraph needs to be enriched with data from another.

Example: the backend returns product add-ons, each with a technical addonId. The CMS stores marketing descriptions for those add-ons, keyed by a slug derived from that ID. Traditionally, the frontend makes two calls and joins the data by hand.

With the @delegate directive, that enrichment is declared directly in the schema:

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

This says: when someone requests description on an AddonDetailsModel, take the addonId from the parent object, apply the slug formatter, and use it to query the content field on the CMS subgraph.

sequenceDiagram
  participant C as Client
  participant G as GraphQL Gateway
  participant B as Backend Subgraph
  participant M as CMS Subgraph

  C->>G: query { addonDetails { addonId, name, description } }
  G->>B: addonDetails
  B-->>G: { addonId: "Premium Support", name: "..." }

  Note over G: Step 2: @delegate for description<br/>Extract addonId, apply :slug → "premium-support"

  G->>M: content(id: "premium-support")
  M-->>G: { description: "Our premium support package..." }

  Note over G: Step 3: Merge CMS result into backend response

  G-->>C: { addonId, name, description }

The resolution pipeline looks like this:

Client Query: addonDetails { addonId, name, description }
                                                │
                    ┌───────────────────────────┘
                    ▼
Step 1: Resolve addonDetails from backend subgraph
        → { addonId: "Premium Support", name: "...", ... }
                    │
                    ▼
Step 2: @delegate fires for 'description' field
        → Extract addonId: "Premium Support"
        → Apply :slug formatter → "premium-support"
        → Query CMS: content(id: "premium-support") { ... }
                    │
                    ▼
Step 3: Merge CMS result into backend response
        → { addonId: "Premium Support", name: "...",
            description: "Our premium support package..." }
                    │
                    ▼
Client receives unified response
(has no idea two subgraphs were involved)

Placeholder Formatters

The {value:format} syntax handles naming convention mismatches between systems:

FormatterInputOutputUse Case
:slug"Premium Support""premium-support"CMS lookups by slug
:lower"CATEGORY""category"Case-insensitive matching
:upper"category""CATEGORY"Enum matching
(none)"abc-123""abc-123"Direct pass-through

Typed Placeholders

String placeholders always produce strings. But sometimes you need to pass a Boolean or a number across the subgraph boundary. Typed placeholders solve this:

  • p_preview — reads the preview parameter from the field’s arguments, preserving its Boolean type
  • f_categoryId — reads categoryId from the parent field’s resolved object

The p_ and f_ prefixes are a deliberate naming convention that makes the data-flow direction explicit in every delegation declaration.

flowchart LR
  subgraph Parent["Parent field/object"]
    Fcat["f_categoryId\n(from resolved object)"]
  end

  subgraph Args["Field arguments"]
    Pprev["p_preview\n(from args, Boolean)"]
  end

  Gateway["Gateway @delegate\nschema directive"] -->|reads| Fcat
  Gateway -->|reads| Pprev

  Fcat -->|"typed value"| Target["Target subgraph\n(where: { categoryId: ... })"]
  Pprev -->|"typed value"| Target

Caching: Not Everything Is Equal

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

SourceFreshness RequirementCache Strategy
CMS contentMinutes to hoursAggressive (LRU, per-operation)
Product pricingReal-timeNo cache
RatingsHoursModerate (TTL-based)
NavigationUntil content changesAggressive + 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.

flowchart LR
  Client["Client"] -->|"GraphQL operation"| Gateway["Gateway"]

  subgraph CacheLayer["In-memory cache (per replica)"]
    Cache["Per-operation entries\n(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\nContent Change"] --> Webhook["Webhook"]

  Webhook --> R1["Replica #1"]
  R1 -->|"Clear local cache"| R1Cache["Replica #1 cache cleared"]
  R1 -->|"Publish 'invalidate'\non Redis channel"| Redis["Redis Pub/Sub\n'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

ConcernBefore (REST Multi-Source)After (Stitched GraphQL)
API calls per page3–5 separate HTTP requests1 GraphQL query
Data joiningManual in frontend codeAutomatic via @delegate
Type definitionsPer-endpoint, per-sourceGenerated from unified schema
Error handlingPer-endpoint custom logicOne Apollo error link
Retry logicDuplicated per clientOne retry link with backoff
Cache invalidationManual or nonexistentAutomatic via Pub/Sub
New data sourceNew REST client + typesNew subgraph + stitch config

Lessons Learned

Schema stitching is not federation

GraphQL Federation (Apollo’s managed approach) assumes you control all subgraphs and can add federation directives to each one. Schema stitching works with any GraphQL endpoint, including third-party APIs you cannot modify. If you are integrating a headless CMS or an external service, stitching is often the only option that fits.

flowchart LR
  subgraph Federation["GraphQL Federation"]
    FedGw["Federation Gateway"]
    FedSub["Subgraphs\n(with federation directives)"]
  end

  subgraph Stitching["Schema Stitching"]
    StGw["Stitching Gateway"]
    StAny["Any GraphQL endpoints\n(CMS, 3rd party, internal)"]
  end

  FedGw --> FedSub
  StGw --> StAny
  StAny -.- NOTE["Works with unmodified external GraphQL APIs"]

The gateway belongs in the application, not next to it

Running the Apollo Server gateway inside the Nuxt application, as a Nitro server handler, means SSR queries never leave the process — zero network overhead for server-side data fetching. During SSR, the “gateway” is just a function call. On the client, it becomes a normal HTTP request to /api/graphql.

flowchart TB
  subgraph SSR["Server-Side Rendering (SSR)"]
    NuxtSSR["Nuxt app process"]
    GwSSR["Apollo Gateway\n(in-process)"]
  end

  subgraph ClientSide["Client-side"]
    Browser["Browser"]
    HttpGw["HTTP /api/graphql"]
  end

  NuxtSSR -->|"function call"| GwSSR
  Browser -->|"HTTP"| HttpGw
  HttpGw -->|"Nuxt handler"| NuxtSSR

Delegation is powerful but easy to misuse

The @delegate directive makes cross-subgraph enrichment easy to declare, which is both useful and risky. Without care, delegation chains create N+1 problems: if a list query returns 50 items and each has a delegated field, that is 50 additional subgraph queries. For list scenarios, design the target subgraph to accept batch arguments (where: { id_in: [...] }) or use custom resolver functions that fan out efficiently.

flowchart LR
  Client["Client"] --> Query["List query\nitems { id, delegatedField }"]

  Query --> Gateway["Gateway"]

  subgraph AntiPattern["Naive delegation (N+1)"]
    Items["50 items"] -->|"@delegate per item"| Subgraph1["Target subgraph\n50 separate calls"]
  end

  subgraph Optimized["Batched delegation"]
    Items2["50 items"] -->|"collect IDs"| Batch["id_in: ..."]
    Batch --> Subgraph2["Target subgraph\n1 batched call"]
  end

Placeholder formatters save more effort than they look like

Naming conventions differ between systems — camelCase, snake_case, slugified strings. The formatter system handles this directly in the schema declaration. Without it, every delegation would require a custom resolver to transform the argument. With formatters, the transformation is a single annotation.


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 — Why modules are the architecture, not folders.

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

Categories: