Integrate the Optimization iOS SDK in a SwiftUI app
Overview
Use this guide to add Contentful personalization to a SwiftUI app using the Optimization iOS SDK. By the end of the quick start, the SDK is initialized inside your SwiftUI app and emits one screen event that its consent gate accepts — the event Contentful uses to keep that visitor’s personalization consistent.
New to personalization? Here is a breakdown of how it works:
- In Contentful you author variants of an entry and attach them to an experience — a rule that decides which visitors see which variant.
- As the app runs, Contentful’s Experience API looks at who the visitor is and picks the variant for each experience. Swapping a fetched entry for its picked variant is called resolving the entry.
- The Experience API also returns a profile: the anonymous, per-visitor identity and state used to keep personalization consistent across requests or app launches.
- Your app hands a Contentful entry to the SDK at the point where that entry becomes output. The SDK gives back the selected variant, or the original entry when no variant applies—the baseline fallback.
- You render the returned entry with the same application components you already use.
The iOS SDK persists the profile to UserDefaults across app launches when persistence consent
allows it.
That is enough to start. The guide introduces policy and optional capabilities at the point you need them.
You will get there in two milestones:
- Milestone 1 — the SDK initialized, reporting one screen event, and resolving entries (the quick
start below plus the Core entry sections). The quick start proves initialization and one accepted
screen event. The Contentful entry fetching and locale shape
and Entry resolution and fallback rendering sections
then add entries resolving through
OptimizedEntryonce your app passes it fetched Contentful entries. This is shippable on its own once its consent posture matches your policy: the quick start starts with consent already accepted, and the Consent and privacy-policy handoff section replaces that shortcut with an app-owned decision. - Milestone 2 — the opt-in layers (later). Consent handoff, interaction tracking, identity, Custom Flags, live updates, the preview panel, strict event policy, and offline delivery, each introduced by the section that needs it.
This guide uses the ContentfulOptimization Swift Package. You mount one OptimizationRoot around
the SwiftUI tree that uses SDK views; it creates and initializes the SDK client, restores state from
UserDefaults, and provides it to the components and modifiers below it. Your app still owns its
Contentful entry fetching, consent policy, identity policy, navigation, and final rendering. If your
app is UIKit-based, use
the UIKit iOS integration guide instead.
There is one SDK behind both guides, so a mixed app can mix surfaces: a UIKit app that hosts some
screens in SwiftUI through UIHostingController can use the SwiftUI views on those hosted screens,
because they read one OptimizationClient from the SwiftUI environment. Provide the hosted root with
the client the app already created (.environmentObject(client)) rather than mounting a second
OptimizationRoot per hosted screen, and follow the UIKit guide for the rest of the app.
Quick start
Most SwiftUI + Contentful apps share one shape: an App whose WindowGroup wraps a root view, with
screens fetched or built inside that tree. This quick start assumes that shape and proves the
smallest result: the SDK initializes and emits one screen event that its consent gate accepts — an
“accepted” event is one the SDK’s local consent and allow-list checks let through to send, which is
what you can observe on the device; it is not a confirmation that Contentful received it. Entry
rendering needs an app-specific Contentful fetch, so it moves to
Entry resolution and fallback rendering in Core; here you
wrap your app root in OptimizationRoot and mark one screen with .trackScreen(name:).
This quick start assumes your application policy permits Optimization to start with accepted consent
and renders no end-user consent UI, so it sets defaults: StorageDefaults(consent: true) — the
shorthand that accepts both consent axes at once. Treat that line as a temporary shortcut for the
first run: a configured StorageDefaults value is a startup default the SDK applies on every
launch, taking precedence over whatever consent is stored on the device, so shipping it would keep
re-accepting consent for a visitor who declined. If personalization must wait for a consent decision,
keep this structure and replace that line before you ship as described in
Consent and privacy-policy handoff, which explains the two
axes, the split form that sets them separately, and why an app that collects a choice leaves
defaults unset.
-
Add the
ContentfulOptimizationSwift Package. In Xcode, choose File → Add Package Dependencies and enter the package URLhttps://github.com/contentful/optimization.swift. If your app is defined by aPackage.swiftmanifest, add the dependency and product there instead and set a real version forfrom:.Adapt this to your use case:
There is no
pod installstep for a Swift Package. After adding the package, build and run the app on a simulator (Product → Run, or ⌘R) so the SDK’s bundled JavaScript runtime resource is linked into the build.That resource is the SDK’s optimization core — the same core the other Optimization SDKs run, shipped with the package as a JavaScript bundle. It executes inside a JavaScriptCore context the SDK client owns, and the Swift API you call is a thin native layer over it. The SDK’s logs call that Swift-to-JavaScript boundary the bridge; you never call it yourself, but step 3 has you read its log lines.
-
Wrap your app root in
OptimizationRoot, pass your Optimization client ID — the value from your Optimization project settings, listed under Before you start — setlogLevel: .debugso the SDK logs its activity, and add.trackScreen(name:)to one screen you already render.Adapt this to your use case:
The
MyAppandHomeScreenscaffolding above is illustrative context to match against your own app, not a file to paste over yours. Wrap your existing app root inOptimizationRootand add.trackScreen(name:)to a screen you already render — keep the rest of your views as they are. -
Verify the first run. Launch the app on a simulator. Because
logLevel: .debugis set, the SDK logs its activity to the Xcode console under thecom.contentful.optimizationsubsystem. The.trackScreen(name:)modifier sends the screen event throughtrackCurrentScreen, so filter the console foroptimizationand look for the pair of bridge lines it logs —[bridge] Calling trackCurrentScreen asyncfollowed by[bridge] trackCurrentScreen succeeded, whose result payload contains"accepted":true. Thatsucceededline withacceptedtrue is the proof the event passed the consent gate. The Custom events and analytics diagnostics section adds a programmaticeventStreamobserver for asserting on events in code rather than reading logs.
Before you start
The sections below walk the integration in order. First, gather the few things you can only get from outside this guide:
-
A native SwiftUI app you can build in Xcode, with its own Contentful entry fetching already working. The iOS SDK does not fetch Contentful entries for your application UI — you fetch them in the app layer and pass each fetched single-locale
Contentful.EntrytoOptimizedEntryorresolveOptimizedEntry(...). The SDK targets iOS 15+. It ships as a Swift Package with nopod installstep, so you add it in Xcode (orPackage.swift) and run a normal build on a simulator. -
Contentful delivery credentials — space ID, delivery token, and environment — read from your app’s runtime configuration and used by your own Contentful fetching layer.
-
A configured
contentful.swiftclient that already fetches at least one entry with a concrete locale. That is the client this guide’s fetch examples extend; the SDK ships an adapter for theContentful.Entryvalues it returns, so those entries go straight into the SDK’s typed entry APIs. -
At least one entry with a variant attached to an experience, authored in Contentful. Without an authored variant, the integration can still run correctly while returning the baseline, so you cannot yet distinguish working personalization from a content-authoring gap. For the first personalized-content test, target all visitors so the test request or visitor matches automatically.
-
Your Optimization project values — client ID and environment, from your Optimization project ssettings. In the Contentful web app the path depends on which navigation your organization uses: in classic navigation, go to Apps → Installed apps → Contentful Personalization → SDK keys; in new navigation (the Contentful app with ExO navigation enabled), go to Platform/Apps → Installed apps → Contentful Personalization → SDK keys. The client ID and environment are listed there.
The Experience API (which picks variants) and the Insights API (which receives event and interaction delivery) each have a base URL that defaults correctly; you set them only for mocks or non-default hosts (see Install and initialize the SwiftUI root).
Everything else — consent, entry resolution, screen tracking, interaction tracking, identity, live updates, preview, offline delivery — is introduced by the section that needs it.
Read the SDK and Contentful config from your app’s runtime configuration. This guide’s examples use inline placeholder strings for clarity; the reference implementation reads its values from shared app configuration because it runs against shared mock defaults. Use whatever configuration convention your iOS app already uses and keep it consistent.
Core integration
Install and initialize the SwiftUI root
Integration category: Required for first integration
You wrapped your app root in OptimizationRoot in the quick start, so this section adds only what the
quick start left out: the environment and locale values, api endpoint overrides, and how a view
below the root reaches the client. The quick start’s defaults and logLevel lines stay as they
were — the Consent and privacy-policy handoff section is what
replaces defaults, and you lower logLevel when you stop needing the console output.
OptimizationRoot is the normal SwiftUI entry point: it owns one
OptimizationClient as a @StateObject, calls the client’s initialize(config:) in a .task,
injects the client into the SwiftUI environment as an @EnvironmentObject, provides tracking
defaults to descendant OptimizedEntry views, and renders a ProgressView() until the client
reports isInitialized. Descendant views that call SDK methods directly read the client with
@EnvironmentObject.
initialize(config:) is synchronous and throws — it loads the SDK’s bundled JavaScript runtime and
runs bridge initialization inline on the main actor, so it briefly blocks the main actor at startup
rather than awaiting. OptimizationClient is a @MainActor type; call its methods from SwiftUI view
tasks, event handlers, or other main-actor contexts.
- Add
ContentfulOptimizationas a Swift Package dependency and build the app on a simulator. - Create one
OptimizationConfigwith the Optimization client ID.environmentdefaults tomain, so pass it only when your Contentful environment differs. - Pass
localewhen Experience API responses and event context must use the same app locale as your Contentful entry fetches. - Pass
apiendpoint overrides only for staging, mocks, or non-default hosts; both base URLs default correctly otherwise, so most apps omitapi. - Read the initialized client from
@EnvironmentObjectinside descendant views that call SDK methods directly.
Adapt this to your use case:
logLevel defaults to .error; .debug and .log also enable remote JavaScript inspection in
debug builds. OptimizationClient methods that emit events are async and called from tasks or
event handlers — Custom events and analytics diagnostics
shows that shape. For lifecycle details, see
iOS SDK runtime and interaction mechanics.
For package status and installation options, see
the Optimization iOS SDK README.
Consent and privacy-policy handoff
Integration category: Common but policy-dependent
Consent policy stays application-owned. Consent has two independent axes: event consent (may the
SDK personalize and emit events) and persistence consent (may the SDK store profile continuity in
UserDefaults). The boolean call client.consent(_:) sets both at once; the split call
client.consent(events:persistence:) sets them independently. StorageDefaults(consent: true)
accepts both axes at startup — use it only when application policy permits Optimization by default and
you render no consent UI.
StorageDefaults values are startup defaults, not one-time seeds. A configured value takes precedence
over the value stored in UserDefaults on every launch, so a configured consent: true keeps
re-accepting consent for a visitor who declined on an earlier launch: the stored choice never wins
against it. That is the reason for the rule below — an app that collects its own consent decision
leaves StorageDefaults.consent (and persistenceConsent) unset, and calls client.consent(...)
from resolved app policy instead, so the SDK reflects only what the app passes.
- Set
StorageDefaults(consent: true)only when policy permits default-on Optimization and no consent UI is shown. - Otherwise leave consent unset and call
client.consent(true)after the visitor accepts,client.consent(false)after they reject. - Use the split form when events are allowed but durable profile continuity must stay session-only.
- Read
client.state.consentandclient.state.persistenceConsentwhen consent UI must reflect SDK state.client.state.consentis tri-state —true,false, ornilwhen the visitor has not decided yet — so gate the banner onclient.state.consent == nilto show it only until a choice is made.
Adapt this to your use case:
Copy this:
Before event consent is accepted, allowedEventTypes is the whole admission rule, and the native
default allows identify and screen. Every type absent from that list is blocked: entry-view events
(delivered as component), tap events (component_click), custom track events, and page events —
the page-view event the SDK shares with the web SDKs, which SwiftUI apps replace with screen.
Accepting event consent admits every type at once; adding a type to allowedEventTypes admits that one
type with no consent decision at all. client.consent(false) clears event and persistence consent,
purges queued events, and clears durable profile continuity, while in-memory state stays usable until
reset or teardown. To block every SDK event before consent — including identify and screen — set
allowedEventTypes: []; see
Strict event policy and endpoint controls. For the
cross-SDK consent model, see
Consent management in the Optimization SDK Suite.
Contentful entry fetching and locale shape
Integration category: Required for first integration
The iOS SDK does not fetch managed Contentful entries for your application UI. Fetching remains in
your app regardless of how the route identifies an entry. If the app already has a Contentful entry
ID, keep its existing single-entry ID request. If a route carries a public slug, the app can query by
content type and slug instead. Either way you pass the fetched Contentful.Entry to OptimizedEntry
or client.resolveOptimizedEntry(...) — never the ID or the slug, which the native SDK does not read.
The only thing the SDK fetches for itself is the preview panel’s own audience and experience
definitions.
Two properties of that fetch decide whether personalization can work at all, and both fail quietly:
- Exactly one concrete locale. The resolver reads direct field values, so an all-locale response
(the delivery API’s
locale=*mode) falls back to baseline even though the request succeeded and the entry looks complete. - Enough
includedepth.nt_experiencesis the SDK-owned link field the resolver reads on an optimized entry; it links that entry’snt_experienceentries, and each experience links itsnt_variants(andnt_audience). These are fixed Optimization content-model identifiers you do not choose. Fetch deep enough to pull all of them back in one payload — the reference implementation uses a depth of 10.nt_configis a JSON field on the experience, not a link, so it needs no extra depth. If a link is missing from the payload, resolution falls back to baseline.
The SDK Experience/event locale is distinct from the Contentful delivery locale: your app chooses the
delivery locale for its own fetch, and OptimizationConfig(locale:) sets the locale the Experience API
and events use. Keep them aligned when rendered content and Experience responses must match.
- Choose the application Contentful locale in your app’s navigation, i18n, or account layer.
- Pass the same locale to
OptimizationConfig(locale:)when Experience responses and event context must align with rendered content. - Fetch by entry ID, or by content type and slug when the route supplies one, and pass the one fetched entry to native resolution.
- When the app locale changes, call
client.setLocale(...), refetch entries with the new locale, and re-render.setLocale(...)updates only the SDK Experience/event locale; it does not refetch Contentful or refresh profile state, and it throws before initialization or on an invalid locale. - After the locale change, emit a fresh Experience event — the
screen,identify, orpagecall your app already owns for the current state — when rendered output depends on SDK-derived profile data,selectedOptimizations(the visitor’s current set of variant selections), flags, or merge tags that must reflect the new locale. Without a new event, those stay on the previous locale’s response.
Both fetch properties are set on the contentful.swift query your app already builds. The
contentfulClient parameter below is the client from Before you start; its
fetchArray reports through a completion handler, so an async call site bridges it with a
continuation.
Adapt this to your use case:
When the route carries a slug instead of an ID, the same two properties apply, plus a limit of two so a duplicate slug is detectable rather than silently resolved to whichever entry came back first. Return the entry only when the response contains exactly one item; surface zero items through the app’s not-found path and more than one as an authoring or configuration error. The content type and slug-field IDs belong to your app and content model — the native SDK never reads them or performs this request.
Adapt this to your use case:
Pass the entry either query returns to the OptimizedEntry or direct-resolution path in
Entry resolution and fallback rendering.
Before you start assumes you already have a contentful.swift client. If you do
not, construct one from your space ID, environment, and Delivery API token — the queries above run
through it:
Adapt this to your use case:
For the full data shape and locale boundary, see Entry optimization and variant resolution and Locale handling in the Optimization SDK Suite.
Entry resolution and fallback rendering
Integration category: Required for first integration
OptimizedEntry renders a Contentful entry through the resolver. It detects an optimized entry by the
presence of the nt_experiences field; a non-optimized entry passes through unchanged, and an
optimized entry resolves against the visitor’s selected variants. When you pass a Contentful.Entry,
the render closure receives the SDK-owned CTEntry wrapper.
Read that wrapper in two steps, because the entry you get back is not necessarily shaped like the one you passed in: a selected variant is its own linked entry and can use any Contentful content type.
CTEntry.contentTypeIdtells you which Contentful content type you actually received, so your app can choose the renderer for it.- Inside that renderer, call
hasField(...)beforegetField(...).contentTypeIdidentifies the content type; it does not validate that the entry carries the fields that content type usually has.
The content type IDs and renderers below belong to your app.
An empty variant is a variant authored to render nothing rather than to replace content. When one
is selected, OptimizedEntry omits your app’s content and does not call your content closure. The SDK still retains
the resolved selection and tracking metadata. Because no visible content supplies geometry in that
state, there may be nothing measurable or tappable, so a view or tap event is not guaranteed. A later
non-empty result calls the closure with the current entry. An absent or invalid empty-variant field
renders normally.
Resolution is synchronous and fail-soft. client.resolveOptimizedEntry(baseline:selectedOptimizations:)
returns the SDK-owned ResolvedOptimizedEntry, which contains the resolved CTEntry, the applied
selectedOptimization — the single selection that produced this entry’s variant, as opposed to the
client’s selectedOptimizations, which is the visitor’s whole current set — an optional
optimizationContextId, and isEmptyVariant. Only a
boolean true marks an empty variant. If resolution fails, it contains the baseline entry with
selectedOptimization and optimizationContextId set to nil instead of
breaking the UI. Pass nil for selectedOptimizations to use current client state, or pass an
explicit snapshot.
- Pass the fetched
Contentful.EntrytoOptimizedEntry, branch onCTEntry.contentTypeId, and read fields only inside the matching branch. - Provide your own loading treatment while the app-owned fetch is pending —
OptimizedEntryneeds an entry to render, so gate it on your fetched state. - Use
client.resolveOptimizedEntry(...)directly only when a component must separate resolution from rendering, and route itsCTEntrythrough the same renderer.
Adapt this to your use case:
The direct resolver keeps the same native call shape and uses current client state when
selectedOptimizations is omitted.
Follow this pattern:
Both examples pass the fetched Contentful.Entry straight to the typed API rather than hand-mapping it
to a dictionary first: the SDK-owned adapter builds the {sys, fields, metadata} shape the resolver
expects, and what comes back is a CTEntry you read with getField instead of a raw dictionary you
cast.
For the fallback rules, see Entry optimization and variant resolution.
Screen events and SwiftUI navigation
Integration category: Required for first integration
You added .trackScreen(name:) in the quick start. The modifier calls client.trackCurrentScreen(name:)
when the view appears, when a consent change allows a previously blocked screen to emit, and when the
screen name changes. trackCurrentScreen dedupes in the bridge by route key (defaulting to the name),
so a repeat of the same current screen is skipped and a blocked attempt is retried once consent
allows. Plain client.screen(name:) emits with no dedupe.
Attach .trackScreen(name:) once to a screen’s stable root. For a dynamic screen name or an
app-defined route key — for example a detail screen whose name depends on loaded data — call
client.trackCurrentScreen(name:properties:routeKey:) from a task after the data is available
instead. Track a given route through one path only: do not attach .trackScreen and also call
trackCurrentScreen/screen for the same route, or you will emit duplicate or conflicting events.
- Attach
.trackScreen(name:)to the stable root of each screen that maps to an analytics screen. - Use stable names for navigation destinations so downstream reporting can group events.
- For dynamic names or an explicit route key, call
client.trackCurrentScreen(name:properties:routeKey:)from a.taskonce the data is available. - Use one screen-tracking path per route.
Follow this pattern:
Adapt this to your use case:
Entry interaction tracking
Integration category: Common but policy-dependent
OptimizedEntry tracks two interactions for the entry it wraps: entry views and entry taps. Both
default to enabled. OptimizationRoot sets the tree-wide defaults through its trackViews and
trackTaps parameters, and each OptimizedEntry can override them per entry.
Three sets of names describe those same two interactions, and only the first set is yours to choose:
trackViews/trackTaps are the configuration switches you pass; trackView/trackClick are fixed
SDK-owned consent keys the SDK checks internally, as hasConsent(method: "trackView") for views and
hasConsent(method: "trackClick") for taps; and component/component_click are the event types an
entry view and an entry tap are delivered as — the names you use in allowedEventTypes and see in
event payloads. Both interactions stay blocked until event consent (or an allow-list entry) permits
them.
View tracking is viewport-based. Wrap scrollable content in OptimizationScrollView so view timing
uses the real scroll position; without an enclosing scroll view, tracking assumes scrollY is 0 and
uses the screen height as the viewport, which suits only non-scrolling or already-visible layouts. The
default view threshold is 80% visibility (minVisibleRatio 0.8) for a cumulative 2000 ms
(dwellTimeMs) — visible time accumulates, so the threshold does not require one unbroken visible
window. After the first view event, duration updates emit every 5000 ms
(viewDurationUpdateIntervalMs) while the entry stays visible.
A tap observer on the OptimizedEntry wrapper emits the component_click event, then calls the
optional onTap closure. That closure receives the baseline entry you passed in, not the resolved
variant — only the render closure receives the resolved entry — so do not read variant-dependent
fields from it. Because onTap runs through that same tap observer, trackTaps: false on that
OptimizedEntry disables both the tap event and onTap.
- Leave view and tap tracking enabled for entries that need exposure and interaction analytics.
- Set
trackViews: falseortrackTaps: falseonOptimizationRootto change the default for the whole tree, or on an individualOptimizedEntryfor one surface. A roottrackTaps: falseis a default, not a lock: an entry that passestrackTaps: true, or a non-nilonTap, still emitscomponent_click. - Wrap scrollable entry lists in
OptimizationScrollViewfor accurate viewport timing. - Tune
dwellTimeMs,minVisibleRatio, andviewDurationUpdateIntervalMsper entry only when analytics requirements differ from the defaults. - Use a
Buttonor app gesture inside the render closure for navigation, andonTaponly when the SDK tap event should also drive it.
Adapt this to your use case:
The snippets in this section show SDK call shapes only, so place each expression inside your own View
body or Scene — a bare OptimizationRoot(...) or OptimizedEntry(...) expression cannot sit at file
scope. config, posts, cta, navigate(to:), analytics, and the card views are your app’s.
Adapt this to your use case:
Adapt this to your use case:
For timing thresholds, scroll context, and delivery behavior, see iOS SDK runtime and interaction mechanics.
Identity, profile state, and reset
Integration category: Common but policy-dependent
Identify a user when your product has an application-owned identity to associate with the profile.
client.identify(userId:traits:) links that identity to the current profile. The SDK publishes its
state reactively: client.selectedOptimizations and client.locale are top-level @Published
properties, and client.state publishes a snapshot carrying the profile, consent, and changes —
the inline field and flag values the Experience API returned for this visitor. SwiftUI views observe
any of them directly. Keep traits limited to values approved for Optimization profile use.
When persistence consent allows it, the SDK stores profile continuity — profile, changes, selected
optimizations, and the anonymous id — in UserDefaults across app launches, reading it once at
startup and running from in-memory state thereafter. client.reset() clears that continuity (profile,
changes, selected optimizations, anonymous id, and the current-screen dedupe) but preserves the stored
consent decision, so the next SDK activity still follows the visitor’s existing consent. reset()
no-ops before initialization.
- Call
identify(userId:traits:)from the authenticated flow or account state change that owns identity. - Read
client.state.profilewhen SwiftUI must react to profile state; readclient.selectedOptimizationsonly for app-owned resolution or diagnostics (OptimizedEntryobserves it for you). - Call
client.reset()on sign-out or a privacy reset that must clear profile continuity. - Re-emit a screen event after reset when the active journey needs fresh anonymous state.
- Use
client.consent(events:persistence:)when profile-continuity persistence must differ from event consent.
Adapt this to your use case:
Optional integrations
Custom events and analytics diagnostics
Integration category: Optional
Use client.track(event:properties:) for application-owned business events, and the SDK event streams
for debug surfaces, local validation, or forwarding to your analytics pipeline.
client.eventStream is a passthrough Combine publisher fed by every emitted event; it does not replay
prior events to late subscribers, so subscribe before the events you want to observe (for example in
the root screen’s .task, before child views can emit) or accept that earlier events are missed. This
is the programmatic observer the quick start pointed to: subscribe to eventStream to assert on the
accepted screen event in code instead of reading the Xcode console. client.blockedEventStream (and
the onEventBlocked config callback) surfaces events blocked by consent or the allow-list. Keep any
downstream destination consent checks in your app before forwarding.
- Call
client.track(event:properties:)from the SwiftUI handler that owns the business action. - Subscribe to
client.eventStreambefore the actions you need to observe; it does not buffer. - Subscribe to
client.blockedEventStreamor setonEventBlockedwhen a debug UI or logger must explain consent-blocked events. - Apply destination consent in your app before forwarding events.
Adapt this to your use case:
Adapt this to your use case:
For cross-SDK forwarding patterns, see Forwarding Optimization SDK context to analytics and tag management tools.
Custom Flags and MergeTag rendering
Integration category: Optional
Custom Flags are named values the Experience API returns for the current visitor alongside variant
selections — a switch, label, or number your app reads and applies itself instead of rendering a
replacement entry. A merge tag is the inline counterpart inside Rich Text: an embedded nt_mergetag
entry that resolves to a value from the visitor’s profile. Both read profile-backed values, separately
from entry variant selection.
Flag names are not app-invented. client.getFlag(_:) looks the name up in the flag values the
Experience API returned for this visitor, so the key has to match the one in your Optimization data;
"priorityBadge" below stands in for that name.
client.getFlag(_:) is a one-time, non-reactive read; client.flagPublisher(_:)
returns a Combine publisher that updates as the flag value changes. Subscribing to a flag registers a
flag observation that emits a component flag-view event through the event stream when consent and
profile allow, so flag delivery is an analytics exposure — apply the same governance you use for other
SDK events.
client.getMergeTagValue(mergeTagEntry:) resolves an inline nt_mergetag entry — the SDK-owned
merge-tag content-model identifier — against the current profile and returns the resolved string, or
nil when it cannot resolve. Your app owns extracting the embedded nt_mergetag entry from Rich Text
before calling it, and owns where the value renders.
- Use
client.getFlag(_:)for a one-time flag read after the SDK is initialized. - Use
client.flagPublisher(_:)when SwiftUI state must follow flag changes. - Resolve Rich Text
nt_mergetagentries withclient.getMergeTagValue(mergeTagEntry:)after your fetcher has inlined the target entry. - Provide app-owned fallback rendering when a flag or merge-tag value is missing.
Adapt this to your use case:
Live updates
Integration category: Optional
By default, OptimizedEntry locks to the first variant it resolves, so content does not change while
a visitor is reading it. Enable live updates when a screen must react to profile changes or preview
overrides without a reload.
- Set
liveUpdates: trueonOptimizationRootwhen most optimized entries in the tree must update as SDK state changes. - Set
liveUpdates: trueon an individualOptimizedEntryfor a localized live section. - Set
liveUpdates: falseon an individualOptimizedEntryto keep it locked even under a live global default. - Expect the preview panel to force live updates while it is open so overrides apply immediately.
Adapt this to your use case:
As in the tracking section, these are call shapes to place inside your own View body or Scene, and
config, dashboardEntry, legalCopyEntry, and the two content views are your app’s.
The resolution order is: an open preview panel forces live updates, then a per-entry liveUpdates
value, then the OptimizationRoot liveUpdates default, then the locked default. When the preview
panel closes, a locked OptimizedEntry snapshots the current selections so applied overrides persist.
For the precedence rules, see
iOS SDK runtime and interaction mechanics.
Preview panel
Integration category: Optional
Use the preview panel only in debug or internal builds. PreviewPanelConfig is the preferred SwiftUI
path because OptimizationRoot mounts PreviewPanelOverlay for you. The panel fetches nt_audience
and nt_experience definitions — the SDK-owned audience and experience content types — through an
app-supplied PreviewContentfulClient, then lets users override audiences and variants locally.
- Gate the panel behind a debug, internal, or feature-flag condition.
- Pass
PreviewPanelConfig(enabled: false)in builds where the panel must not render. - Pass a
PreviewContentfulClientso the panel shows audience and experience names instead of raw identifiers. - Use
ContentfulHTTPPreviewClientfor a direct CDA-backed panel, or implementPreviewContentfulClientaround your existing Contentful client.
Adapt this to your use case:
Build previewPanel where your app builds its configuration, and keep the OptimizationRoot
expression inside your Scene as in the quick start.
PreviewPanelOverlay reads the client from the SwiftUI environment, so it must sit under an
OptimizationRoot. It remains available when the app needs to place the panel’s overlay itself, but
PreviewPanelConfig keeps the setup attached to the root SDK provider.
Advanced integrations
Strict event policy and endpoint controls
Integration category: Advanced or production-only
Use advanced configuration when production policy requires stricter pre-consent behavior, explicit event allow-lists, non-default endpoints, or queue observability.
- Pass
allowedEventTypes: []when no SDK event can emit before consent. - Pass a narrow
allowedEventTypeslist when policy permits only specific pre-consent events. Its elements are event type names:identify,screen,page,track,component(an entry view), andcomponent_click(an entry tap). LeavingallowedEventTypesunset behaves like["identify", "screen"], the native default. - Configure
OptimizationApiConfigonly for approved non-default Experience API or Insights API endpoints. - Configure
onEventBlockedor subscribe toblockedEventStreamwhen release validation needs proof that denied events are blocked. - Configure
QueuePolicyonly when production operations need non-default queue limits, retry timing, or queue callback telemetry.
Adapt this to your use case:
Offline delivery and lifecycle flushing
Integration category: Advanced or production-only
After initialization the SDK monitors network reachability and app lifecycle. A NetworkMonitor
(NWPathMonitor) calls setOnline(_:) on connectivity changes and flush() on reconnect, and an
AppStateHandler calls flush() when the app resigns active for a best-effort background drain. That
app-state handler is compiled in wherever UIKit can be imported, which is the case in an iOS app build,
so a SwiftUI-lifecycle app gets the resign-active flush too — you do not need to add your own.
Queues are in-memory only — there is no durable outbox — and the offline Experience buffer is capped
at 100 events by default (tunable via QueuePolicy.offlineMaxEvents); nothing survives process death.
- Keep one
OptimizationClientalive for the app or scene lifetime so the in-memory queue can survive transient network changes. - Use
client.setOnline(false)andclient.setOnline(true)only for tests or deliberate app-owned network simulation. - Call
client.flush()from app-owned shutdown or critical-flow checkpoints when policy requires a best-effort delivery attempt before leaving the flow. - Use the
QueuePolicycallbacks when operations teams need telemetry for offline drops, flush failures, circuit-open events, or recovery.
Follow this pattern:
For deeper runtime behavior, see iOS SDK runtime and interaction mechanics.
Production checks
Before release, verify these checks against the target app build:
- Credentials and runtime configuration — the app uses the intended Optimization client ID and environment, the SDK Experience/event locale, and any approved Experience API or Insights API endpoint overrides; mock or localhost base URLs are absent from production configuration.
- Consent behavior — default-on consent is used only when policy permits it; user-choice flows
call
consent(true | false); split event/persistence consent matches your persistence policy; and rejected consent blocks non-allowed event types. - Event delivery — screen, entry view, entry tap, Custom Flag, and custom business events are accepted or blocked according to consent state, and offline replay plus background flush behave as expected on your supported platforms.
- Content fallback — the Contentful client fetches single-locale entries with enough include depth for optimized entries, and baseline rendering still works when no variant matches or data is incomplete.
- Duplicate-tracking prevention — one
OptimizationRootowns the SwiftUI tree, each route uses one screen-tracking path,.trackScreen(name:)is attached once per logical screen, and the app does not wrap the same rendered entry more than once for one impression. - Privacy and governance — forwarded analytics payloads apply destination consent and do not replay events the SDK blocked, profile traits are approved, the preview panel is absent from public builds or gated to approved internal users, and persisted profile continuity matches consent records.
- Local validation path — validate against the iOS reference implementation or the app’s own targeted XCUITest flow before relying on production telemetry.
Troubleshooting
Use these checks for common SwiftUI integration failures:
Reference implementations to compare against
- iOS reference implementation — the maintained SwiftUI and UIKit shells that exercise shared native iOS bridge behavior, single-locale Contentful fetching, entry resolution, interaction tracking, screen tracking, Custom Flags, offline delivery, and preview-panel overrides against the same mock API.