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.
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
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, and serves the result at a single /api/graphql endpoint.
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
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
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.
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
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:
| Formatter | Input | Output | Use 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 thepreviewparameter from the field’s arguments, preserving its Boolean typef_categoryId— readscategoryIdfrom 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.
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
flowchart LR
subgraph Parent["Parent field/object"]
Fcat["f_categoryId<br/>(from resolved object)"]
end
subgraph Args["Field arguments"]
Pprev["p_preview<br/>(from args, Boolean)"]
end
Gateway["Gateway @delegate<br/>schema directive"] -->|reads| Fcat
Gateway -->|reads| Pprev
Fcat -->|"typed value"| Target["Target subgraph<br/>(where: { categoryId: ... })"]
Pprev -->|"typed value"| Target
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.
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
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.
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
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 |
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
.graphqlfile 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.





