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.

One entry, two locales, one cache keyWithout the locale in keyFields, the English and German versions of the same entry normalize to one cache key and overwrite each other; with it, they become two entities.Article 7Ht2locale en-USArticle 7Ht2locale de-DEArticle:7Ht2last write winsArticle:7Ht2:en-USArticle:7Ht2:de-DEdefault keydefault keyid + localeid + locale
The default key collapses translations into one entity; adding the locale splits them back apart.

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 write path for one response objectEach object in a response is checked for a type policy; with keyFields it is stored as its own entity, with keyFields false it is embedded in its parent, and without either the default id heuristic applies.Response object__typename: AuthorType policydefined?keyFieldsserialize listed fieldskeyFields: falseembed in parentDefaultid or _id if selectedEntity storeAuthor:{sys:{id,locale}}keyFieldsfalsenone
Every object goes down exactly one of three paths, and only the first two are under your control.

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.

Choosing a keyFields strategy per CMSFor each CMS, where the identifier lives, whether it repeats across locales or drafts, and the recommended keyFields setting.CMSId locationRepeats acrossRecommended keyFieldsContentfulsys.idlocale, preview["sys", ["id", "locale"]]Sanity_iddrafts. prefix only["_id"]Strapi v4id on Entitylocale (i18n plugin)["id", "locale"] on attributes typeHygraphidlocale, stage["id", "locale", "stage"]Directusidnone (translation rows)default
The recommended setting always includes every dimension along which the same id can carry different content.

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.

TypeScript
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.

YAML
# 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 title and slug but not sys { id locale } makes the whole write fail with a missing-field error once keyFields is set. Fix it by adding the key fields to a shared EntryIdentity fragment 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 _id so 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 stage only when it is requested. A policy that keys on stage forces 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 Author entry silently drops the first one’s keyFields. Lint for it with the no-dupe-keys ESLint 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.