GraphQL schema stitching: merging Sanity and Shopify into one API
How I unified two separate GraphQL APIs — Sanity as CMS and Shopify as storefront — into a single coherent endpoint, with type namespacing, a custom executor, and cross-schema resolvers.
A CMS-driven e-commerce typically has two sources of truth: the CMS for editorial content (descriptions, pages, buying guides) and the commerce platform for transactional data (prices, variants, inventory). Keeping them separate on the client means two distinct calls, two different formats, and duplicated data access logic.
The approach I took is schema stitching: merging two GraphQL subschemas — Sanity and Shopify — into a single endpoint managed by a BFF (Backend for Frontend) running on Lambda. The client makes one query and gets data from both sources, normalized into a single response.
The namespace problem
First obstacle: Sanity and Shopify both have a Product type. Import both schemas directly and they overwrite each other.
The fix is to prefix all Shopify types before merging:
import { RenameTypes, RenameRootFields, RenameRootTypes } from '@graphql-tools/wrap';
const shopifyTransforms = [
new RenameTypes((name) => `Spf${name}`), // Product → SpfProduct
new RenameRootTypes((name) => `Spf${name}`), // Query → SpfQuery
new RenameRootFields((_op, name) => `spf_${name}`), // products → spf_products
];
Spf stands for “Shopify Storefront”. All Shopify types become SpfProduct, SpfCart, SpfVariant — no conflict with Sanity types, and the client can always tell which source a field comes from.
Building the unified schema
With both subschemas configured, stitchSchemas merges them and lets you add custom typedefs and resolvers on top:
import { stitchSchemas } from '@graphql-tools/stitch';
const schema = stitchSchemas({
subschemas: [
{ schema: sanitySchema, executor: sanityExecutor },
{ schema: shopifySchema, executor: shopifyExecutor, transforms: shopifyTransforms },
],
typeDefs: /* GraphQL */ `
extend type Product {
sanity: SanityProduct
}
type Routing { ... }
type Query {
allRouting(where: RoutingWhereInput, limit: Int, offset: Int): [Routing]
}
`,
resolvers: {
Product: {
sanity: {
selectionSet: '{ id }',
resolve(parent, _args, context, info) {
const sanityId = createSanityProductId(extractIdFromGid(parent.id));
return delegateToSchema({
schema: sanitySubschema,
operation: 'query',
fieldName: 'Product',
args: { id: sanityId },
context,
info,
});
},
},
},
Query: {
allRouting: allRoutingResolver,
},
},
});
The Product.sanity field is the interesting part: when the client queries spf_product { sanity { description } }, the resolver takes the Shopify ID, converts it to Sanity’s ID format, and delegates the query to the Sanity subschema. The client doesn’t know two systems are involved.
The Shopify executor: injecting locale context
Shopify Storefront API supports localization via the @inContext(country: $country, language: $language) directive. The problem: this directive belongs in the query, but the Spf prefix we apply to type transforms doesn’t apply to variables.
I wrote a custom executor that intercepts each request before sending it to Shopify:
export const shopifyExecutor: Executor = async (request) => {
// Strip the Spf prefix from type variables
// (SpfCountryCode → CountryCode, SpfLanguageCode → LanguageCode)
const cleanedVariables = stripTypePrefix(request.variables, 'Spf');
// Extract country and language from operation directives
const country = extractDirectiveVariable(request.document, 'country');
const language = extractDirectiveVariable(request.document, 'language');
return fetch(shopifyStorefrontUrl, {
method: 'POST',
headers: { 'X-Shopify-Storefront-Access-Token': token },
body: JSON.stringify({
query: print(request.document),
variables: {
...cleanedVariables,
country,
language,
},
}),
}).then(res => res.json());
};
This means the client can send the @inContext directive in the query without worrying about the prefix — the executor handles it before the request reaches Shopify.
Custom resolvers: routing, inventory, reviews
Beyond the data from both subschemas, the BFF adds custom resolvers for functionality that belongs to neither Sanity nor Shopify:
Routing — Site routes are pre-computed and stored in DynamoDB. The allRouting resolver queries the table with filters on slug, documentId, and cursor-based pagination:
const allRoutingResolver = async (_parent, args, context) => {
const { where, limit, offset, first, after } = args;
if (where?.slug?.eq) {
return dynamoDB.query({ KeyConditionExpression: 'slug = :slug', ... });
}
if (where?.documentId?.in) {
return dynamoDB.batchGet({ Keys: where.documentId.in.map(id => ({ documentId: id })) });
}
// ...
};
Inventory — Takes SKUs and locale, queries the Shopify Admin API, filters by country market. Returns quantity with backorder policy (CONTINUE or DENY).
Verified reviews — Delegates to an external reviews API, separate from both main sources. The client sees it as part of the same schema.
Stellate: caching the entire BFF
With schema stitching, every query goes through the BFF Lambda — which in turn calls Sanity, Shopify, and DynamoDB. Without caching, latency is the sum of all hops.
I added Stellate as a GraphQL reverse proxy in front of the BFF:
// stellate config
export default defineConfig({
serviceName: 'my-storefront',
rules: [
{ types: ['Query'], maxAge: 1800, swr: 172800 },
{ types: ['SpfCart', 'SpfCustomer'], maxAge: 0 }, // never cache user data
],
keyFields: {
Routing: ['slug'],
SpfProduct: ['id'],
},
scopes: [
{ scope: 'AUTHENTICATED', representation: 'header:authorization' },
],
});
30 minutes maxAge + 2 days stale-while-revalidate for most queries. Cart and Customer have maxAge: 0 — never cached because they’re user-specific. When Sanity publishes an update, a webhook triggers a purge on Stellate via its API.
What I’d do differently
stitchSchemas isn’t lightweight. Building the unified schema requires introspecting both sources at cold start. I mitigated this by caching the schema in memory after the first build, but it’s overhead that federation (Apollo or schema modules) would distribute differently.
The Spf prefix is verbose on the client. Every Shopify query has to use spf_products, SpfProduct, and so on. It’s necessary for namespacing, but makes queries less readable. I could have used a shorter prefix, or only exposed the Shopify fields the client actually needs through custom typedefs, hiding the raw subschema.
Cross-schema resolvers have hidden costs. Product.sanity requires a Sanity query for each product returned — if the list has 20 products, that’s 20 calls. I solved it with DataLoader for batching, but it’s something to design for from the start.
Schema stitching works well when you have two existing GraphQL APIs you need to unify without rewriting them. It’s not the lightest solution, but it keeps Sanity and Shopify independent and upgradeable, with a single access point for the client.