Use Contentful GraphQL data with the Optimization SDKs

Overview

Use this guide when your app fetches Contentful data through the Contentful GraphQL Content API and wants an Optimization SDK to choose which authored entry to render for a visitor.

This guide exists for GraphQL-specific response shaping. Contentful GraphQL responses follow your generated schema, while the SDK’s resolveOptimizedEntry() method consumes what this guide calls an Entry-like object: a plain object with the Contentful Entry fields the resolver checks. You keep your GraphQL query, client, cache, and rendering model; reshape only the data that crosses into the resolver.

The guide uses the following terms:

  • Variant - An authored alternative of an entry.
  • Experience - A rule that decides which visitors see which variant.
  • Experience API - The Contentful service that, per request or visitor, picks the variant for each experience.
  • Baseline entry - The original Contentful entry before the SDK resolves it to a variant.
  • Selected optimizations - The SDK’s per-visitor selection array from an Experience API result or SDK state. The resolver can return selectedOptimization (singular) when one attached optimization matched the entry.
  • Resolving - Swapping a fetched baseline entry for its picked variant, or leaving the baseline entry in place when no picked variant applies.
  • nt_experiences - The SDK-owned field on a baseline entry that links to its Optimization experience entries.
  • nt_experience - The SDK-owned content type for an Optimization experience entry.
  • nt_name - The SDK-owned display name field on an nt_experience entry.
  • nt_type - The SDK-owned optimization kind field on an nt_experience entry.
  • nt_config - The SDK-owned JSON field on an nt_experience entry that describes entry replacement components.
  • nt_variants - The SDK-owned field on an nt_experience entry that contains linked variant entries.
  • nt_experience_id - The SDK-owned field on an nt_experience entry that matches selectedOptimization.experienceId.
  • Variant entries - The authored replacement entries linked from nt_variants. They must already be present in the GraphQL response, but they can use any content type. Query a fragment for every content type your app supports and preserve each node’s content-type ID in the Entry-like object.
  • Entry-like object - A plain object shaped like the Contentful Entry fields the resolver checks: sys.type: 'Entry', sys.id, sys.contentType.sys.id, metadata, and fields.

Do you need this?

Use this recipe when your app already uses GraphQL-shaped Contentful data and you don’t want to move that fetch layer to contentful.js.

Skip it when you fetch entries through contentful.js and can pass those entries directly to resolveOptimizedEntry(), OptimizedEntry, or managed SDK fetching by entry ID.

Quick start

This recipe assumes runContentfulGraphQlQuery is your app-owned GraphQL request function, optimization is the SDK instance from your integration guide, and selectedOptimizations is the SDK-selected array from an accepted Experience API response or current SDK state.

For a resolver-only test, create one known selection. In production, use the array returned by an accepted Experience API call or published by your SDK state. The minimum item shape is experienceId, variantIndex, and variants; sticky is optional. variantIndex: 0 selects the baseline entry, and positive indexes are one-based into the matching EntryReplacement variants in nt_config. The variants object uses opaque Contentful entry IDs: each key is a baseline entry ID, and each value is the selected variant entry ID. Keep it consistent with the source selection; the resolver chooses the entry from nt_config and the linked nt_variants, while the selection map also contributes to cache identity. In the fixture below, the key matches graphqlData.page.sys.id, and the value matches the selected ntVariantsCollection.items[].sys.id.

Adapt this to your use case:

1const selectedOptimizations = [
2 {
3 experienceId: '6IueRX1pS3iMJncbhUQTba',
4 variantIndex: 1,
5 variants: {
6 '4ib0hsHWoSOnCVdDkizE8d': '4k6ZyFQnR2POY5IJLLlJRb',
7 },
8 },
9]

Query the baseline entry, its SDK-owned optimization links, each optimization entry’s validation fields and replacement configuration, and the linked variant entries in the same concrete locale:

Adapt this to your use case:

1query OptimizedPage(
2 $id: String!
3 $locale: String!
4 $preview: Boolean!
5 $useFallbackLocale: Boolean = true
6) {
7 page(id: $id, locale: $locale, preview: $preview, useFallbackLocale: $useFallbackLocale) {
8 sys {
9 id
10 }
11 __typename
12 title
13 slug
14 heroHeadline
15 ntExperiencesCollection(limit: 10) {
16 items {
17 sys {
18 id
19 }
20 __typename
21 ... on NtExperience {
22 ntName
23 ntType
24 ntExperienceId
25 ntConfig
26 ntVariantsCollection(limit: 10) {
27 items {
28 sys {
29 id
30 }
31 __typename
32 ... on Page {
33 title
34 slug
35 heroHeadline
36 }
37 ... on Hero {
38 headline
39 }
40 ... on CallToAction {
41 label
42 }
43 }
44 }
45 }
46 }
47 }
48 }
49}

Map the camelCase GraphQL fields back to the SDK-owned field names before calling the resolver. This mapping belongs to your app: GraphQL __typename values and Contentful content-type IDs are related but are not interchangeable strings. The example explicitly maps Page to page, Hero to hero, and CallToAction to callToAction; replace both sides with values that exactly match your content model.

The next example also assumes appLocale is your app-owned concrete locale, preview is your app-owned preview-mode boolean, and renderHero, renderCta, and renderPageFromEntry are your existing render functions. runContentfulGraphQlQuery, optimization, and selectedOptimizations remain the app-owned values defined at the start of the quick start.

The typed path below defines a Contentful entry skeleton for each supported content type. A skeleton declares an entry’s content-type ID and fields. PossibleSkeleton contains the baseline and every possible variant and becomes the resolver’s first type argument, S.

The adapter imports types from contentful, so add it as a development dependency if the app does not already use it: pnpm add -D contentful. The example imports isEntryOfContentType from React Web. For Web, Next.js, Node, or React Native, use the same /api-schemas path from @contentful/optimization-web, @contentful/optimization-nextjs, @contentful/optimization-node, or @contentful/optimization-react-native, respectively.

Adapt this to your use case:

1// Use the /api-schemas pass-through from the Optimization SDK package your app installed.
2import { isEntryOfContentType } from '@contentful/optimization-react-web/api-schemas'
3import type { Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful'
4
5type PageSkeleton = EntrySkeletonType<
6 {
7 title: EntryFieldTypes.Symbol
8 slug: EntryFieldTypes.Symbol
9 heroHeadline: EntryFieldTypes.Symbol
10 nt_experiences: EntryFieldTypes.Array<EntryFieldTypes.EntryLink<EntrySkeletonType>>
11 },
12 'page'
13>
14type HeroSkeleton = EntrySkeletonType<{ headline: EntryFieldTypes.Symbol }, 'hero'>
15type CtaSkeleton = EntrySkeletonType<{ label: EntryFieldTypes.Symbol }, 'callToAction'>
16type PossibleSkeleton = PageSkeleton | HeroSkeleton | CtaSkeleton
17
18type GraphQlCollection<T> = {
19 items?: Array<T | null> | null
20}
21
22type GraphQlNode<T extends string> = {
23 sys: { id: string }
24 __typename: T
25}
26
27type GraphQlPageVariant = GraphQlNode<'Page'> & {
28 title?: string | null
29 slug?: string | null
30 heroHeadline?: string | null
31}
32
33type GraphQlPage = GraphQlPageVariant & {
34 ntExperiencesCollection?: GraphQlCollection<GraphQlExperience> | null
35}
36
37type GraphQlHero = GraphQlNode<'Hero'> & {
38 headline?: string | null
39}
40
41type GraphQlCta = GraphQlNode<'CallToAction'> & {
42 label?: string | null
43}
44
45type GraphQlVariant = GraphQlPageVariant | GraphQlHero | GraphQlCta
46
47type GraphQlExperience = GraphQlNode<'NtExperience'> & {
48 ntName?: string | null
49 ntType?: 'nt_experiment' | 'nt_personalization' | null
50 ntExperienceId?: string | null
51 ntConfig?: unknown
52 ntVariantsCollection?: GraphQlCollection<GraphQlVariant> | null
53}
54
55function present<T>(value: T | null | undefined): value is T {
56 return value != null
57}
58
59function entryLike(
60 node: GraphQlNode<string>,
61 contentTypeId: string,
62 fields: Record<string, unknown>,
63): Entry<EntrySkeletonType> {
64 return {
65 sys: {
66 type: 'Entry',
67 id: node.sys.id,
68 contentType: {
69 sys: {
70 type: 'Link',
71 linkType: 'ContentType',
72 id: contentTypeId,
73 },
74 },
75 },
76 metadata: {},
77 fields,
78 } as Entry<EntrySkeletonType>
79}
80
81function toPageEntry(page: GraphQlPage): Entry<PageSkeleton, undefined> {
82 return entryLike(page, 'page', {
83 title: page.title,
84 slug: page.slug,
85 heroHeadline: page.heroHeadline,
86 nt_experiences:
87 page.ntExperiencesCollection?.items?.filter(present).map(toExperienceEntry) ?? [],
88 }) as Entry<PageSkeleton, undefined>
89}
90
91function toExperienceEntry(experience: GraphQlExperience): Entry<EntrySkeletonType> {
92 return entryLike(experience, 'nt_experience', {
93 nt_name: experience.ntName,
94 nt_type: experience.ntType,
95 nt_experience_id: experience.ntExperienceId,
96 nt_config: experience.ntConfig,
97 nt_variants: experience.ntVariantsCollection?.items?.filter(present).map(toVariantEntry) ?? [],
98 })
99}
100
101function toVariantEntry(variant: GraphQlVariant): Entry<EntrySkeletonType> {
102 switch (variant.__typename) {
103 case 'Hero':
104 return entryLike(variant, 'hero', {
105 headline: variant.headline,
106 })
107 case 'CallToAction':
108 return entryLike(variant, 'callToAction', {
109 label: variant.label,
110 })
111 case 'Page':
112 return entryLike(variant, 'page', {
113 title: variant.title,
114 slug: variant.slug,
115 heroHeadline: variant.heroHeadline,
116 })
117 }
118}
119
120const graphqlData = await runContentfulGraphQlQuery({
121 id: '4ib0hsHWoSOnCVdDkizE8d',
122 locale: appLocale,
123 preview,
124})
125
126const baselineEntry = toPageEntry(graphqlData.page)
127const resolved = optimization.resolveOptimizedEntry<PossibleSkeleton>(
128 baselineEntry,
129 selectedOptimizations,
130)
131
132if (!resolved.isEmptyVariant) {
133 if (isEntryOfContentType<HeroSkeleton, undefined>(resolved.entry, 'hero')) {
134 renderHero(resolved.entry.fields.headline)
135 } else if (isEntryOfContentType<CtaSkeleton, undefined>(resolved.entry, 'callToAction')) {
136 renderCta(resolved.entry.fields.label)
137 } else {
138 renderPageFromEntry(resolved.entry)
139 }
140}

The isEmptyVariant branch makes no render call. The skeleton names and fields belong to this example content model; replace them and the GraphQL fragments with the content types your app supports. isEntryOfContentType checks the preserved sys.contentType.sys.id and narrows the union; it does not validate fields. When the baseline and every variant use PageSkeleton, omit the generic and TypeScript infers that single skeleton. For an open-ended content model, use EntrySkeletonType for S; this avoids maintaining a closed union, but fields are unchecked and must be validated before rendering. See TypeScript content-model choices for the complete modeling trade-offs.

The adapter input boundary is the GraphQL page object passed to toPageEntry(). To verify without calling Contentful, save one real graphqlData.page response as a JSON fixture with the shape shown above, type the loaded object with satisfies GraphQlPage, and pass it directly to toPageEntry(fixturePage). For the variant case, keep one matching experience whose ntExperienceId matches selectedOptimizations[0].experienceId, set variantIndex: 1, keep the first configured variant in both ntConfig and ntVariantsCollection.items, and confirm the result ID matches that variant. For the fallback case, reuse the same fixture with the matching item removed from ntVariantsCollection.items; the same resolver call must return the baseline entry ID.

Default recipe

Keep GraphQL fetching app-owned

The GraphQL query, GraphQL client, cache keys, preview token policy, and rendering components belong to your app. The Optimization SDK owns the nt_* content-model identifiers and the resolver contract.

Do not add a separate SDK-owned GraphQL client. App-owned GraphQL fetching stays on the manual side of the entry-source boundary, which means the app fetches the data and hands an entry to the SDK instead of asking the SDK to fetch by ID. Fetch the data, create the Entry-like shape, call resolveOptimizedEntry(), and render the result.

Query the optimization data

GraphQL fields are schema-shaped. Content fields are selected directly, Object fields such as ntConfig are returned as JSON, and array links are selected through generated *Collection fields. Request one concrete locale for the entry you pass to the resolver. GraphQL does not support the CDA locale=* wildcard, but mixing several localized GraphQL payloads into one Entry-like object creates the same problem: the resolver expects one localized value per field.

For optimized entry replacement, the query must include:

  • The baseline entry’s sys.id, __typename, render fields, and ntExperiencesCollection.
  • Each linked nt_experience entry’s sys.id, ntName, ntType, and ntExperienceId.
  • Each linked nt_experience entry’s ntConfig and ntVariantsCollection so entry replacement can resolve to a variant.
  • Each linked variant entry’s sys.id, __typename, and a fragment containing the render fields for every content type your app supports.

Adapt at the resolver boundary

Keep the adapter narrow. Convert only the GraphQL nodes that enter resolveOptimizedEntry(). The adapter must preserve SDK-owned field names inside fields, even though GraphQL exposes those names in camelCase:

GraphQL response fieldEntry-like resolver field
ntExperiencesCollectionfields.nt_experiences
ntNamefields.nt_name
ntTypefields.nt_type
ntExperienceIdfields.nt_experience_id
ntConfigfields.nt_config
ntVariantsCollection.itemsfields.nt_variants

The resolver checks sys.type, sys.id, sys.contentType.sys.id, metadata, and fields. It also validates linked nt_experience entries, so missing required fields such as fields.nt_name or fields.nt_type make the optimization entry unusable for resolution. After validation, the resolver matches selectedOptimization.experienceId to fields.nt_experience_id, reads fields.nt_config, and looks for the selected variant in fields.nt_variants. Preserve each GraphQL node’s __typename as the corresponding sys.contentType.sys.id; the selected linked variant does not have to match the baseline content type.

Render from the resolved result

The quick start renders the reshaped Entry-like object directly. When your components expect the original GraphQL-native objects, map the already-resolved entry ID back to those objects:

Follow this pattern:

1const graphQlVariants =
2 graphqlData.page.ntExperiencesCollection?.items
3 ?.filter(present)
4 .flatMap((experience) => experience.ntVariantsCollection?.items?.filter(present) ?? []) ?? []
5
6const graphQlEntriesById = new Map(
7 [graphqlData.page, ...graphQlVariants]
8 .filter(present)
9 .map((entry) => [entry.sys.id, entry] as const),
10)
11
12if (!resolved.isEmptyVariant) {
13 const entryToRender = graphQlEntriesById.get(resolved.entry.sys.id) ?? graphqlData.page
14 renderGraphQlEntry(entryToRender)
15}

When your runtime emits tracking metadata manually, derive it after resolution. Tracking metadata is the resolved entry and optimization context a runtime uses for entry view, click, hover, or tap events. Use the resolved entry ID where applicable. SDK components and wrappers do this for you; custom renderers must not keep rendering or tracking against the baseline ID after a variant resolves.

Runtime or vendor variants

React Web

Use the React Web integration guide for provider setup and event timing. Inside components that already receive GraphQL data, memoize the Entry-like baseline from the GraphQL response and call useEntryResolver() or useOptimization().resolveOptimizedEntry(...). Render the resolved Entry-like object directly, or map resolved.entry.sys.id back to the GraphQL object your component already understands.

If you need Web interaction tracking, prefer OptimizedEntry when you can pass an Entry-like baselineEntry. For fully custom GraphQL renderers, add Web tracking metadata after resolution instead of before it.

Next.js App Router or Pages Router

Use your route loader, Server Component, getServerSideProps, or API route to run the GraphQL query with the route’s concrete locale and preview state. Resolve on the server when the route already has request-local selected optimizations, then pass either the rendered result or the resolved entry ID to the client.

For client hydration after server rendering, hydrate Optimization state through the relevant Next.js integration guide and keep the same ID-map strategy on the client. The server and client must agree on the GraphQL IDs and the locale used to build the Entry-like object.

Manual Node or server rendering

Use a request-bound Node SDK instance for consent, profile, locale, and Experience events. After an accepted event returns data.selectedOptimizations, adapt the GraphQL response and call resolveOptimizedEntry(baselineEntry, data.selectedOptimizations).

Server caches remain app-owned. Cache the GraphQL response by route, locale, preview state, and any application cache dimensions. Treat the resolved entry as request-local unless a cache-safe handoff guide tells you to render shared output for a preselected variant permutation.

Validate the integration

  • Confirm the GraphQL query includes ntExperiencesCollection, ntName, ntType, ntExperienceId, ntConfig, ntVariantsCollection, and a fragment with render fields for every supported variant content type.
  • Confirm the query receives one concrete locale string for the entry being resolved.
  • Confirm variant entries are present as objects in ntVariantsCollection.items, not only as IDs or unresolved links.
  • Resolve a variant whose content type differs from the baseline, and confirm the preserved sys.contentType.sys.id selects the expected typed branch or GraphQL-native renderer.
  • Resolve with a known selectedOptimizations item whose experienceId matches ntExperienceId, and confirm resolved.entry.sys.id is the expected variant entry ID.
  • Remove ntConfig, ntVariantsCollection, or the matching variant entry in a test fixture, and confirm the resolver returns the baseline entry ID instead of throwing.
  • When you emit tracking metadata manually, inspect the rendered metadata or event payload and confirm it uses resolved.entry.sys.id where the runtime expects the resolved entry identity.

Governance notes

The app owns GraphQL documents, fragments, generated types, clients, preview credentials, cache policy, route loaders, supported content-type union, and rendering. Keep those decisions in the app layer.

The SDK owns nt_experiences, nt_experience, nt_name, nt_type, nt_config, nt_variants, nt_experience_id, the selectedOptimizations shape, and the resolver contract. Do not rename SDK-owned fields inside the Entry-like object, and do not invent replacement identifiers in GraphQL fragments.

Keep the adapter close to the resolver call. A small adapter is easier to audit when the content model changes, and it avoids turning your GraphQL schema into a second Contentful SDK model.