Use this guide when you want to add personalization, analytics, screen tracking, and a preview panel
to a React Native (or Expo) application using @contentful/optimization-react-native.
The React Native SDK builds on the Optimization Core Library and adds React Native-specific providers, hooks, and components. It lets consumers:
OptimizationRoot or explicit providersOptimizedEntryThe React Native SDK does not replace your Contentful delivery client. Your application still fetches Contentful entries, decides how consent works, decides when a user becomes known, and controls where personalized content renders.
Most React Native integrations follow this sequence:
OptimizationRoot with the minimum config (clientId).<OptimizedEntry>.<OptimizationScrollProvider> so viewport tracking is accurate.OptimizationNavigationContainer or per screen
with useScreenTracking.Optional additions include live updates when entries need to continuously react to optimization state changes, and the preview panel when the application needs authoring or preview overrides.
The React Native reference implementation in this repository shows those patterns in a working application:
pnpm add @contentful/optimization-react-native @react-native-async-storage/async-storage
For offline support (recommended), also install:
pnpm add @react-native-community/netinfo
The SDK uses AsyncStorage to persist consent and, when persistence consent permits it, profile state
and selected optimizations across app launches. netinfo is optional but lets the SDK queue events
while the device is offline and flush them automatically when connectivity returns.
The Optimization SDK depends on native modules (e.g. @react-native-clipboard/clipboard for the
preview panel). Expo apps using Optimization need a custom dev build (expo run:ios /
expo run:android) — Expo Go is not enough. The in-tree React Native reference implementation
README documents the monorepo setup and E2E
commands.
Wrap your app's root in <OptimizationRoot>. This is the recommended entry point — it composes the
OptimizationProvider, LiveUpdatesProvider, and InteractionTrackingProvider for you and manages
the SDK instance lifecycle.
import { OptimizationRoot } from '@contentful/optimization-react-native'
export default function App() {
return (
<OptimizationRoot clientId="your-client-id">
<YourApp />
</OptimizationRoot>
)
}
That is the minimum viable setup. clientId is the only required prop; everything else falls back
to safe defaults (environment defaults to 'main', channel to 'mobile', etc.).
A fuller application usually adds environment-specific config, optional preview panel settings, and navigation integration:
<OptimizationRoot
clientId={OPTIMIZATION_CLIENT_ID}
environment={OPTIMIZATION_ENVIRONMENT}
contentfulLocales={{
default: 'en-US',
supported: ['en-US', 'de-DE', 'fr-FR'],
}}
locale="en-US"
logLevel={__DEV__ ? 'info' : 'warn'}
previewPanel={{
enabled: __DEV__,
contentfulClient: client,
}}
>
<OptimizationNavigationContainer>
{(navigationProps) => (
<NavigationContainer {...navigationProps}>
{/* ...stack/tab navigators... */}
</NavigationContainer>
)}
</OptimizationNavigationContainer>
</OptimizationRoot>
Common props on OptimizationRoot:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
clientId |
string |
Yes | N/A | Your Contentful Optimization client identifier |
environment |
string |
No | 'main' |
Optimization environment to read from |
defaults |
{ consent?: boolean, ... } |
No | undefined |
Initial values applied at startup (e.g. consent: true) |
allowedEventTypes |
EventType[] |
No | ['identify', 'screen'] |
Event types allowed before consent is explicitly set |
logLevel |
LogLevels |
No | 'error' |
Minimum console log level |
previewPanel |
PreviewPanelConfig |
No | undefined |
Enables the in-app preview panel; see Preview Panel |
liveUpdates |
boolean |
No | false |
Global live-updates default for <OptimizedEntry /> |
locale |
string |
No | undefined unless locale config is set |
Initial app/content locale candidate |
trackEntryInteraction |
{ views?, taps? } |
No | { views: true, taps: false } |
Default interaction tracking for <OptimizedEntry /> |
onStatesReady |
(states) => cleanup |
No | undefined |
Attach app-level state subscribers when SDK state is ready |
The full configuration reference (API endpoints, fetch retries, queue policy, event-builder overrides) is documented in the React Native SDK README.
Use contentfulLocales when the same app screen renders localized Contentful entries. Configure
contentfulLocales.default for single-locale apps, and add contentfulLocales.supported when the
app needs device locale matching across multiple Contentful locales. Copy those codes from
Contentful locale settings or the CMA locale list. The locale prop supplies the initial
app/content locale. The resolved optimization.locale, when present, is the Contentful locale code
used by withOptimizationLocale() and by default Experience API localization unless you provide an
explicit api.locale override.
Changing the provider locale prop after initialization calls optimization.setLocale(nextLocale)
and updates optimization.locale plus optimization.states.locale. It does not fetch content or
refresh profile state; call screen, identify, or CDA methods again when your app needs localized
data refreshed. For the full matching rules, configuration cases, and Experience API locale
behavior, see
Locale handling in the Optimization SDK Suite.
Inside the provider tree, use useOptimization() to interact with the SDK directly:
import { useOptimization } from '@contentful/optimization-react-native'
function MyComponent() {
const optimization = useOptimization()
const handlePress = async () => {
await optimization.identify('user-123', { plan: 'pro' })
}
return <Button onPress={handlePress} title="Identify" />
}
useOptimization() throws if used outside an OptimizationProvider / OptimizationRoot, and is
guaranteed to return a ready SDK instance — OptimizationProvider does not render its children
until the SDK has finished initializing.
React Native provider-owned initialization is async: the provider renders no children while platform
state setup is pending, runs any onStatesReady callback, and then renders children. This matches
the React Web provider contract, but React Native cannot use the Web layout-effect timing because
SDK creation depends on async storage and platform APIs.
Use onStatesReady when application code needs SDK state subscriptions that line up with provider
initialization. The provider calls it after async SDK state setup completes and before child screen,
navigation, or entry effects can emit events.
<OptimizationRoot
clientId="your-client-id"
onStatesReady={(states) => {
const subscriptions = [
states.eventStream.subscribe((event) => {
if (event) devToolsPanel.logEvent(event)
}),
states.blockedEventStream.subscribe((blocked) => {
if (blocked) devToolsPanel.logBlockedEvent(blocked)
}),
]
return () => {
subscriptions.forEach((subscription) => subscription.unsubscribe())
}
}}
>
<OptimizationNavigationContainer>
{(navigationProps) => (
<NavigationContainer {...navigationProps}>{/* navigators */}</NavigationContainer>
)}
</OptimizationNavigationContainer>
</OptimizationRoot>
onStatesReady receives only the states surface. Use regular hooks and React effects for
component-local state.
Use OptimizationProvider directly with a pre-built sdk only when an application or framework
adapter owns initialization. Without onStatesReady, children render immediately because the SDK is
already available. When onStatesReady is provided, the provider waits until those subscribers are
attached before children mount and runs the returned cleanup on unmount. In both cases, it does not
call destroy() on the injected SDK.
React Native applications usually choose one startup policy: seed accepted consent when application
policy permits Optimization by default, or leave SDK consent unset and connect application-owned
controls to consent(true | false).
trueIf your application policy permits Optimization by default and you do not render an end-user consent
prompt, set defaults.consent: true so events flow immediately:
<OptimizationRoot clientId="your-client-id" defaults={{ consent: true }}>
<YourApp />
</OptimizationRoot>
The default is applied once at startup; user input later takes precedence. It also permits durable
profile-continuity storage for profile, selected optimizations, changes, and the anonymous ID. Set
this default during SDK initialization rather than from a component effect so the persistence policy
is in place before child effects, screen tracking, or manual identify() calls can run.
When durable profile-continuity persistence is allowed, SDK state from an Experience response is published only after the corresponding AsyncStorage write for that same response snapshot has settled or failed gracefully. This affects durability timing, not the live source of truth: after startup hydration, SDK state and rendered SDK-derived UI read from in-memory SDK state. Wait for SDK-derived state or UI instead of adding sleeps before relaunching or terminating the app in tests.
When your application policy depends on user choice, leave defaults.consent unset and call
consent() from your banner UI:
import { useOptimization } from '@contentful/optimization-react-native'
import { View, Text, Button } from 'react-native'
function ConsentBanner() {
const optimization = useOptimization()
return (
<View>
<Text>Allow personalized experiences and analytics?</Text>
<Button title="Accept" onPress={() => optimization.consent(true)} />
<Button title="Reject" onPress={() => optimization.consent(false)} />
</View>
)
}
When consent is accepted (true), all event types are permitted. When consent is rejected
(false), non-allowed event types are blocked and <OptimizedEntry /> view/tap tracking will be
silently dropped at the SDK boundary. Consent state persists across app launches via AsyncStorage.
By default, only identify and screen events are allowed before consent is explicitly set. All
other event types, including entry view/tap tracking, are blocked until consent is granted or the
event type is allow-listed. For cross-SDK consent policy guidance, see
Consent management in the Optimization SDK Suite.
Subscribe to the SDK's consent observable when you need to render UI conditionally:
import { useOptimization } from '@contentful/optimization-react-native'
import { useEffect, useState } from 'react'
import { Text } from 'react-native'
function ConsentStatus() {
const optimization = useOptimization()
const [consent, setConsent] = useState<boolean | undefined>(undefined)
useEffect(() => {
const sub = optimization.states.consent.subscribe(setConsent)
return () => sub.unsubscribe()
}, [optimization])
return <Text>Consent: {String(consent)}</Text>
}
A common pattern is to gate personalized content rendering on consent and fall back to the baseline entry while consent is missing or rejected.
To revoke consent after it was previously accepted, just call consent(false):
optimization.consent(false)
Use these options only when your application policy needs a stricter or split consent model:
allowedEventTypes={[]} for strict opt-in before any Optimization event can emit.optimization.consent({ events: true, persistence: false }) when events are allowed but
durable profile continuity must stay session-only.<OptimizedEntry /> is the unified component for resolving optimized variants and tracking
interactions on Contentful entries. It:
nt_experiences (i.e. is optimized) and resolves the correct
variant for the current user profile.include: 10For variant data to resolve, the entry must be fetched with linked optimization references included.
Use include: 10 and one CDA locale on Contentful's Delivery API call:
const optimization = useOptimization()
const contentful = optimization.withOptimizationLocale(contentfulClient)
const cta = await contentful.getEntry(CTA_ENTRY_ID, {
include: 10,
})
The React Native reference implementation centralizes this Contentful fetching pattern in its application helper layer.
Configure contentfulLocales.default for single-locale apps, and add contentfulLocales.supported
for localized apps that need device locale matching. The recommended withOptimizationLocale()
helper lets Contentful entry fetches use the live resolved locale by default; data layers that need
direct control can pass optimization.locale explicitly. Use optimization.setLocale(nextLocale)
when the app changes language after initialization. contentful.js withAllLocales and raw CDA
locale=* return locale-keyed fields; the SDK resolver expects a standard single-locale CDA entry
shape where fields.nt_experiences and fields.nt_variants are direct field values. See
Entry personalization and variant resolution
for the entry contract and
Locale handling in the Optimization SDK Suite
for the broader locale model.
Pass the baseline entry to <OptimizedEntry> and render with a render prop that receives the
resolved entry:
import { OptimizedEntry } from '@contentful/optimization-react-native'
function HeroSection({ baselineEntry }) {
return (
<OptimizedEntry baselineEntry={baselineEntry}>
{(resolvedEntry) => <CTAHeader entry={resolvedEntry} />}
</OptimizedEntry>
)
}
resolvedEntry is either the resolved variant (when the SDK has selected one for the current
profile) or the baseline entry (when no variant qualifies). Either way, resolvedEntry.fields has
the same shape as the baseline — so the renderer downstream doesn't need to know whether it's seeing
a variant or not.
Use useEntryResolver() when a component needs the same resolution behavior without the
OptimizedEntry wrapper:
import { useEntryResolver } from '@contentful/optimization-react-native'
function HeroData({ baselineEntry }) {
const { resolveEntry } = useEntryResolver()
const resolvedEntry = resolveEntry(baselineEntry)
return <CTAHeader entry={resolvedEntry} />
}
When you only want to track an entry (no variant resolution), pass static children instead of a render prop:
<OptimizedEntry baselineEntry={blogPost}>
<BlogPostCard post={blogPost} onPress={...} />
</OptimizedEntry>
This is the same tracking pattern used by the React Native reference implementation: entries are wrapped so the SDK can track views/taps, while non-optimized content passes through unchanged.
<OptimizedEntry /> tracks two interactions: views (the entry was at least N% visible for at
least M ms) and taps (the user tapped the entry). View tracking is enabled by default; tap
tracking is opt-in.
For the deeper event timing, visibility ratio, consent-gating, and viewport-state details, see React Native SDK Interaction Tracking Mechanics.
Set a global default for all <OptimizedEntry /> components via trackEntryInteraction on the
root:
<OptimizationRoot clientId="your-client-id" trackEntryInteraction={{ views: true, taps: true }}>
<YourApp />
</OptimizationRoot>
The default is { views: true, taps: false }.
Override the global setting on individual entries with trackViews and trackTaps.
Track taps on a CTA, regardless of the global setting:
<OptimizedEntry baselineEntry={cta} trackTaps>
{(resolved) => <CTAHeader entry={resolved} />}
</OptimizedEntry>
Disable view tracking for a high-frequency entry:
<OptimizedEntry baselineEntry={feedItem} trackViews={false}>
<FeedItemCard item={feedItem} />
</OptimizedEntry>
You can also pass onTap to receive the resolved entry after a tap is tracked. Providing onTap
implicitly enables tap tracking unless trackTaps={false} is explicit:
<OptimizedEntry
baselineEntry={cta}
onTap={(resolved) => navigation.navigate('CTA', { id: resolved.sys.id })}
>
{(resolved) => <CTAHeader entry={resolved} />}
</OptimizedEntry>
By default, view tracking fires when the entry is 80% visible for 2000 ms. Customize per-entry:
<OptimizedEntry
baselineEntry={hero}
minVisibleRatio={0.5} // 50% visible
dwellTimeMs={1000} // for 1 second
>
{(resolved) => <Hero entry={resolved} />}
</OptimizedEntry>
After the initial view event, the SDK emits periodic view-duration update events every 5000 ms by
default; configure with viewDurationUpdateIntervalMs.
Inside a scrolling container, viewport-based view tracking needs to know the actual scroll position.
Wrap the scrollable screen in <OptimizationScrollProvider>:
import { OptimizationScrollProvider, OptimizedEntry } from '@contentful/optimization-react-native'
function BlogPostDetailScreen({ post }) {
return (
<OptimizationScrollProvider>
<OptimizedEntry baselineEntry={post}>
<ArticleBody post={post} />
</OptimizedEntry>
</OptimizationScrollProvider>
)
}
The React Native reference implementation wraps
its entry list in OptimizationScrollProvider before rendering optimized entries.
Without OptimizationScrollProvider, the SDK assumes scroll position is always 0 and the viewport
equals the screen. That's fine for a single full-screen component, but for content that appears
below the fold, wrap the screen so tracking fires when the user scrolls the entry into view.
Screen tracking emits a screen event each time the user navigates to a new screen. The SDK uses
these events to update profile attribution and route-aware properties.
If you use React Navigation, the easiest setup is <OptimizationNavigationContainer />, which wraps
<NavigationContainer /> and emits a screen event on every active route change:
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import {
OptimizationRoot,
OptimizationNavigationContainer,
} from '@contentful/optimization-react-native'
const Stack = createNativeStackNavigator()
export default function App() {
return (
<OptimizationRoot clientId="your-client-id">
<OptimizationNavigationContainer>
{(navigationProps) => (
<NavigationContainer {...navigationProps}>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="BlogPostDetail" component={BlogPostDetailScreen} />
</Stack.Navigator>
</NavigationContainer>
)}
</OptimizationNavigationContainer>
</OptimizationRoot>
)
}
The React Native reference implementation
exercises this adapter in its navigation test flow. The render-prop pattern means the wrapper does
not depend on @react-navigation/native directly — navigation props are passed through to your real
NavigationContainer.
Available props:
| Prop | Required | Default | Description |
|---|---|---|---|
children |
Yes | N/A | Render prop receiving ref, onReady, and onStateChange |
onStateChange |
No | — | Called after screen tracking fires on navigation state changes |
onReady |
No | — | Called after the initial screen tracking on container ready |
includeParams |
No | false |
Whether to include route params in the screen event properties |
useScreenTrackingIf you don't use React Navigation, or if you want fine-grained control, call useScreenTracking
inside each screen component:
import { useScreenTracking } from '@contentful/optimization-react-native'
function HomeScreen() {
useScreenTracking({ name: 'Home' })
return <View>...</View>
}
By default this fires once on mount. To delay tracking until data is loaded, pass
trackOnMount: false and call trackScreen() manually:
function DetailsScreen() {
const { trackScreen } = useScreenTracking({
name: 'Details',
trackOnMount: false,
})
useEffect(() => {
if (dataLoaded) {
void trackScreen()
}
}, [dataLoaded, trackScreen])
return <View>...</View>
}
useScreenTrackingCallbackWhen the screen name isn't known at render time (e.g. derived from navigation state or a deep-link),
use useScreenTrackingCallback to get a stable callback you can fire on demand:
import { useScreenTrackingCallback } from '@contentful/optimization-react-native'
function DynamicScreen({ screenName }: { screenName: string }) {
const trackScreenView = useScreenTrackingCallback()
useEffect(() => {
trackScreenView(screenName, { source: 'deep-link' })
}, [screenName, trackScreenView])
return <View>...</View>
}
Use this optional step when your mobile app already sends events to a customer-data platform, product analytics destination, or vendor SDK. The Optimization SDK still sends events to Contentful. Your application decides which approved Contentful context, if any, should also be forwarded.
| Reporting need | React Native SDK handoff |
|---|---|
| SDK screen, custom, view, tap, or flag view | Register one states.eventStream subscription from onStatesReady. |
| Business event attribution | Add Contentful fields in the tap handler or screen action that owns the event. |
| Entry or variant attribution | Use the resolved entry metadata from OptimizedEntry or the action render path. |
| Custom Flag attribution | Forward from the same component or hook path that reads or renders the flag. |
| Consent or duplicate-delivery verification | Use states.blockedEventStream, messageId dedupe, and destination debuggers. |
eventStream is a live handoff, not a replay queue. Register the subscription at provider
initialization; if the analytics destination is not ready yet, buffer forwarded payloads in
application code with an explicit size, TTL, and drop policy.
Attach app-level analytics subscriptions with onStatesReady so screen, navigation, and entry
effects cannot emit SDK events before the subscriber is registered:
<OptimizationRoot
clientId="your-client-id"
onStatesReady={(states) => {
const forwardedMessageIds = new Set<string>()
const eventSubscription = states.eventStream.subscribe((event) => {
if (!event) return
if (forwardedMessageIds.has(event.messageId)) return
if (!canForwardSdkEvent(event)) return
forwardedMessageIds.add(event.messageId)
analytics.track(`Contentful ${event.type}`, pickContentfulEventProperties(event))
})
return () => eventSubscription.unsubscribe()
}}
>
<OptimizationNavigationContainer>
{(navigationProps) => (
<NavigationContainer {...navigationProps}>{/* navigators */}</NavigationContainer>
)}
</OptimizationNavigationContainer>
</OptimizationRoot>
Use
Forwarding Optimization SDK context to analytics and tag-management tools
for the canForwardSdkEvent and pickContentfulEventProperties helpers, vendor mappings, consent,
identity, dedupe, and governance guidance.
By default, <OptimizedEntry /> locks to the first variant it receives for the lifetime of the
component. If a user later qualifies for a different variant mid-session (e.g. after identify()),
they continue to see the original variant until the component unmounts. This prevents UI flashing
when audience membership changes while the user is viewing content.
Set liveUpdates on OptimizationRoot to enable real-time updates for every <OptimizedEntry />
in the app:
<OptimizationRoot clientId="your-client-id" liveUpdates>
<YourApp />
</OptimizationRoot>
Override the global setting on individual entries.
Always react to changes immediately:
<OptimizedEntry baselineEntry={dashboard} liveUpdates>
{(resolved) => <Dashboard entry={resolved} />}
</OptimizedEntry>
Lock to the first variant, even when global liveUpdates is enabled:
<OptimizedEntry baselineEntry={hero} liveUpdates={false}>
{(resolved) => <Hero entry={resolved} />}
</OptimizedEntry>
Inherit the global setting:
<OptimizedEntry baselineEntry={banner}>{(resolved) => <Banner entry={resolved} />}</OptimizedEntry>
The effective live-updates state for a given <OptimizedEntry /> is resolved in this order (highest
to lowest priority):
liveUpdates prop — explicit per-component override.OptimizationRoot liveUpdates prop — global setting.false).| Preview panel | Global setting | Component prop | Result |
|---|---|---|---|
| Open | any | any | Live updates ON |
| Closed | true |
undefined |
Live updates ON |
| Closed | false |
true |
Live updates ON |
| Closed | true |
false |
Live updates OFF |
| Closed | false |
undefined |
Live updates OFF |
To read the current state programmatically, use useLiveUpdates():
import { useLiveUpdates } from '@contentful/optimization-react-native'
function StatusBadge() {
const liveUpdates = useLiveUpdates()
const isLive = liveUpdates?.globalLiveUpdates ?? false
return <Text>{isLive ? 'Live' : 'Locked'}</Text>
}
The preview panel is an in-app developer surface that lets you browse audiences, override variant selection, and inspect the current profile — all without modifying real user data. It's the React Native counterpart to the Web preview panel.
Pass a previewPanel config to OptimizationRoot. You must also pass an initialized Contentful
client so the panel can fetch audience and experience entries:
import { OptimizationRoot } from '@contentful/optimization-react-native'
import { createClient } from 'contentful'
const contentfulClient = createClient({
space: 'your-space-id',
accessToken: 'your-delivery-token',
environment: 'main',
})
export default function App() {
return (
<OptimizationRoot
clientId="your-client-id"
previewPanel={{
enabled: __DEV__,
contentfulClient,
}}
>
<YourApp />
</OptimizationRoot>
)
}
With enabled: true, a floating action button appears on top of your app. Tap it to open the panel
drawer.
For real apps, gate on __DEV__ (or another build flag) so the FAB doesn't appear in production.
Use fabPosition and showHeader to fine-tune placement and chrome:
<OptimizationRoot
clientId="your-client-id"
previewPanel={{
enabled: __DEV__,
contentfulClient,
fabPosition: { bottom: 50, right: 20 },
showHeader: true,
onVisibilityChange: (visible) => console.log('preview panel visible:', visible),
}}
>
<YourApp />
</OptimizationRoot>
When the preview panel is open, all <OptimizedEntry /> components automatically enable live
updates, regardless of their liveUpdates prop or the global setting. This is what makes "override
audience → see variant change immediately" work in the panel without a screen reload.
You can read the current panel visibility via useLiveUpdates():
import { useLiveUpdates } from '@contentful/optimization-react-native'
function DebugBadge() {
const liveUpdates = useLiveUpdates()
return <Text>Preview panel: {liveUpdates?.previewPanelVisible ? 'open' : 'closed'}</Text>
}
OptimizedEntry rendering plus tap tracking, navigation tracking, and live-updates behavior.