Configuring Apollo typePolicies and keyFields for CMS Content
This guide belongs to Apollo Client GraphQL Caching and sets up the identity half of the cache contract: which field identifies each CMS entry, how locale and preview state become part of that identity, and how union-typed page sections match their fragments.
Apollo’s defaults assume every object has an id or _id field that is unique across the whole API. Headless CMS schemas break that assumption in three different ways: the id lives somewhere else (Contentful’s sys.id), the id repeats across locales, or the id is shared between the draft and published copies of a document. Each breakage produces a different symptom, and all three are fixed in the same place, typePolicies.
The Problem
A product team ships a bilingual marketing site on Contentful and Apollo. A visitor opens the German pricing page, then navigates to the English blog, and the blog’s author card shows German biography text. Nothing is wrong with the queries. Both locales returned an Author whose sys.id is 4kQ9. Apollo either failed to normalize the object because it found no top-level id, or normalized both to one key, depending on which fields the fragment requested. The bug only appears after cross-locale navigation, so it is rarely caught in development.
The same mechanism causes draft leaks in visual editors that share one client between preview and published views, and it causes fragment mismatch warnings (You are using the simple (heuristic) fragment matcher) when a page-builder field returns a union of section types.
How Apollo Builds a Cache Key
When Apollo writes a result, it calls dataIdFromObject for every object that has a __typename. With no policy configured, the id is __typename:id or __typename:_id, if either field is present in the selection set. If neither is present, the object is not normalized; it is stored inline inside its parent and cannot be updated independently.
keyFields replaces that heuristic per type. It accepts field names, nested paths written as ["sys", ["id"]], or a function that receives the object and returns a string. Apollo serializes the listed fields into the key, so keyFields: ["sys", ["id", "locale"]] produces Article:{"sys":{"id":"7Ht2","locale":"en-US"}}. Every listed field must be requested in every query or fragment that returns the type. If one is missing, Apollo throws Missing field 'locale' while extracting keyFields at write time. That error is useful: it turns a silent normalization failure into a loud one.
The default path is the dangerous one for CMS content, because its outcome depends on the selection set of each individual query. The same Author can be normalized in one query, where the fragment happens to include id, and embedded in another that omits it. Explicit policies remove that variability: an object’s identity no longer depends on which component asked for it.
Implementation
The configuration below covers the three fixes together: nested id paths, locale and stage dimensions, and generated possibleTypes for a modular page builder. It is written for Contentful and Hygraph; the Sanity and Strapi variants differ only in the key paths shown in the table above.
import { InMemoryCache } from "@apollo/client";
import type { TypePolicies } from "@apollo/client";
// Generated at build time by @graphql-codegen/fragment-matcher.
import introspection from "./generated/possible-types.json";
interface PossibleTypesResult {
possibleTypes: Record<string, string[]>;
}
const typePolicies: TypePolicies = {
// Contentful: identity lives under sys and repeats per locale.
BlogPost: { keyFields: ["sys", ["id", "locale"]] },
// Assets are locale-independent in this space, so the id alone is enough.
Asset: { keyFields: ["sys", ["id"]] },
// Hygraph: the same id exists in DRAFT and PUBLISHED stages.
LandingPage: { keyFields: ["id", "locale", "stage"] },
// Value objects with no identity of their own: never normalize them.
Seo: { keyFields: false },
Link: { keyFields: false },
// Identity plus a read default for entries that predate the avatarAlt field.
Author: {
keyFields: ["sys", ["id", "locale"]],
fields: {
avatarAlt: {
read(existing: string | undefined, { readField }): string {
return existing ?? `Portrait of ${readField<string>("name") ?? "the author"}`;
},
},
},
},
};
export const cache = new InMemoryCache({
typePolicies,
possibleTypes: (introspection as PossibleTypesResult).possibleTypes,
});
Two lines deserve a comment beyond the inline ones. keyFields: false on Seo and Link tells Apollo these objects are values, not entities: they are stored inside their parent and replaced wholesale when the parent changes. This is correct for embedded objects that have no id in the CMS, and it stops Apollo from emitting warnings about unidentifiable objects. The Author policy shows that identity and field behaviour live in the same entry: keyFields decides which entity an object is, and fields.avatarAlt.read supplies alt text for authors created before the content model had that field, so components never render an image without one.
Generate the possibleTypes file from the CMS schema instead of writing it by hand. With GraphQL Code Generator, the fragment-matcher plugin writes it on every schema pull, so a content modeler adding a VideoSection to the page-builder union is picked up at the next build instead of breaking fragment matching in production.
# codegen.yml
schema:
- https://graphql.contentful.com/content/v1/spaces/${CONTENTFUL_SPACE_ID}:
headers:
Authorization: Bearer ${CONTENTFUL_DELIVERY_TOKEN}
generates:
./src/generated/possible-types.json:
plugins:
- fragment-matcher
config:
apolloClientVersion: 3
Configuration Reference
| Option | Where | What it controls |
|---|---|---|
keyFields: string[] |
type policy | Fields serialized into the cache id; nested paths use ["parent", ["child"]]. |
keyFields: false |
type policy | Store the object inline in its parent; use for value objects without identity. |
keyFields: (obj, ctx) => string |
type policy | Custom id function; use when the id must be derived, such as trimming Sanity’s drafts. prefix. |
possibleTypes |
cache constructor | Map from union or interface name to member types; required for fragments on unions. |
fields.<name>.read |
type policy | Computes or defaults a field on read; runs on every cache read, so keep it cheap. |
dataIdFromObject |
cache constructor | Global fallback id function; prefer per-type keyFields, which are easier to reason about. |
Gotchas & Edge Cases
- A fragment without the key fields. A card component whose fragment requests
titleandslugbut notsys { id locale }makes the whole write fail with a missing-field error oncekeyFieldsis set. Fix it by adding the key fields to a sharedEntryIdentityfragment and spreading it into every fragment for that type. - Sanity drafts share the base id. A draft is stored as
drafts.<id>and the published document as<id>. If preview and published queries share a client, key on the raw_idso they stay separate; if you strip the prefix to align them, you merge draft and published content into one entity. - Hygraph stage defaults. Hygraph returns
stageonly when it is requested. A policy that keys onstageforces every query to select it, which is the point: preview and published copies can never collide. - Changing keyFields in a persisted cache. If you use cache persistence, a key change orphans every stored entity under the old format. Bump the persistence key or purge storage when you deploy a policy change.
- Duplicate type entries in the policy object. JavaScript keeps only the last duplicate property, so a second
Authorentry silently drops the first one’skeyFields. Lint for it with theno-dupe-keysESLint rule.
Verifying the Result
After configuring the policies, inspect the normalized store with cache.extract() in a test or in Apollo DevTools. Each localized entry should appear once per locale with a key that includes the locale string, and value objects like Seo should not appear at the top level at all. A quick regression test seeds two locales of the same entry and asserts that reading one never returns fields from the other. That test catches the cross-locale bug from the problem statement before it ships.
The cursor pagination guide builds on these identities to merge list pages. For how typed schemas are generated for the rest of the application, see TypeScript types from CMS schemas.
Frequently Asked Questions
What happens if two types in my CMS share the same id?
Nothing breaks, because Apollo prefixes every key with __typename. BlogPost:5 and Author:5 are distinct entries. Collisions only occur within a single type, which is why locale and stage belong in the key.
Should I put the locale in keyFields or create one client per locale?
One client per locale is simpler when a page only ever renders one language. Put the locale in keyFields when a page mixes locales, for example a language switcher that prefetches the translated route, or when you share a cache across a locale-prefixed app.
Can keyFields reference a field that is not in the GraphQL response?
No. Apollo reads key fields from the response object, so the field must be selected. If you need a derived key, use a keyFields function, and remember that it can still only read fields that were fetched.