Troubleshooting Apollo Client Cache Normalization with Contentful GraphQL API

When architecting headless applications, developers must evaluate Data Fetching & Caching Strategies before committing to a client-side normalization layer. Apollo Client (AC) relies on a normalized cache architecture. It assumes objects share stable identifiers across queries.

Contentful (a headless Content Management System) structures GraphQL API (Application Programming Interface) responses differently. It nests sys.id fields and links entries dynamically. This creates a fundamental mismatch.

AC's automatic merging collides with environment-specific data variants. Published and preview payloads share identical identifiers. Without explicit configuration, the cache overwrites live data with stale drafts.

Reproducible Scenario: Stale Draft Data and Overwritten References

Stack: Next.js 14+ (App Router), @apollo/client 3.8+, Contentful Delivery and Preview APIs.

Initialize AC with InMemoryCache defaults. Fetch published content using the Delivery API token. The UI renders correctly.

Switch to the Preview API token. Refetch the identical GraphQL query. The interface still displays published content.

Console logs show __typename mismatches. AC merges preview and production objects. Draft updates never surface.

Root Cause Analysis

AC normalization defaults to id or _id fields. Contentful uses sys.id as the primary key. The cache fails to map entities correctly.

Contentful responses nest __typename inconsistently across linked assets. AC's possibleTypes matcher drops unknown fragments. Cache entries become orphaned.

The default merge policy treats published and preview payloads as identical. Matching sys.id values trigger cache poisoning. Draft variants silently overwrite live data.

Environment-scoped cache keys are absent. AC cannot isolate draft versus production data. The normalization layer requires explicit boundaries.

Step-by-Step Resolution

1. Configure Explicit Type Policies & Key Fields

Map every Contentful entry type to its correct identifier. Override AC's default key resolution.

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

// CRITICAL: Explicitly map Contentful's nested identifier structure
const cache = new InMemoryCache({
 typePolicies: {
 ContentfulEntry: {
 keyFields: ['sys', ['id']],
 },
 Asset: {
 keyFields: ['sys', ['id']],
 },
 },
});

const httpLink = new HttpLink({
 uri: `https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`,
});

// CRITICAL: Inject auth headers securely
const authLink = setContext((_, { headers }) => ({
 headers: {
 ...headers,
 Authorization: `Bearer ${process.env.CONTENTFUL_ACCESS_TOKEN}`,
 },
}));

export const client = new ApolloClient({
 link: authLink.concat(httpLink),
 cache,
});

2. Environment-Aware Cache Separation

Prevent preview and production payloads from merging. Append environment context to cache identifiers.

const envCache = new InMemoryCache({
 typePolicies: {
 ContentfulEntry: {
 // CRITICAL: Scope keys by environment to prevent cross-pollution
 keyFields: (obj) => 
 obj.sys?.id ? `ContentfulEntry:${obj.sys.id}:${process.env.CONTENTFUL_ENV}` : false,
 },
 },
});

3. Route-Level Fetch Policies

Control cache behavior per Next.js route. Preview routes bypass stale data. Production routes retain performance.

'use client';
import { useQuery, gql } from '@apollo/client';

const GET_ENTRY = gql`
 query GetEntry($id: String!) {
 contentfulEntry(id: $id) {
 sys { id }
 title
 }
 }
`;

export function ContentPreview({ id }: { id: string }) {
 // CRITICAL: Force network fetch for draft routes
 const { data, loading } = useQuery(GET_ENTRY, {
 variables: { id },
 fetchPolicy: process.env.NODE_ENV === 'development' ? 'network-only' : 'cache-first',
 });

 if (loading) return <p>Loading draft...</p>;
 return <h1>{data?.contentfulEntry?.title}</h1>;
}

4. Generate & Inject possibleTypes

Resolve __typename fragmentation. Run introspection against your Contentful schema.

npx @graphql-codegen/cli --config codegen.ts

Inject the generated JSON into the cache constructor. AC now recognizes all union types.

import possibleTypes from './possibleTypes.json';

const cache = new InMemoryCache({
 possibleTypes,
 // ...typePolicies
});

5. Validate & Monitor

Open Apollo DevTools. Inspect the Cache tab. Verify environment-prefixed keys. Trigger Contentful webhooks. Confirm draft payloads populate isolated cache entries.

Pitfalls & DX Tradeoffs

Explicit key fields increase boilerplate. Every new Contentful model requires a policy update. Teams must automate schema synchronization.

Environment-scoped keys double cache memory. Preview and production data occupy separate slots. Monitor client-side heap usage.

network-only policies bypass cache entirely. Preview routes incur higher latency. Accept this tradeoff for data accuracy.

Prevention & Long-Term Architecture

Automate possibleTypes.json generation in CI/CD (Continuous Integration/Continuous Deployment) pipelines. Run introspection on every Contentful schema publish.

Implement cache eviction hooks. Listen to Contentful webhook payloads. Call cache.evict() for stale draft entries.

Standardize GraphQL query batching. Reduce cache fragmentation. Improve AC merge efficiency across component trees.

Document cache configuration as a mandatory onboarding step. Add it to code review checklists. Teams evaluating alternatives should review React Query vs SWR for CMS Data to understand why normalized caching demands strict schema alignment.

FAQ

Why does Apollo merge preview and published Contentful data? AC uses sys.id as the primary key. Without environment scoping, identical IDs trigger automatic merging. Draft payloads overwrite live cache entries.

How do I handle Contentful linked assets in the cache? Define typePolicies for every asset type. Use nested keyFields arrays. Ensure __typename matches your introspection output exactly.

Does network-only hurt performance on preview routes? Yes. It bypasses the normalized cache entirely. Use it only for draft environments. Production routes should default to cache-first or cache-and-network.

How do I clear the cache when Contentful publishes updates? Trigger a webhook to your Next.js API route. Call client.cache.reset() or cache.evict() with specific identifiers. Force a re-fetch on the client.