React SDK for SentientUI — the adaptive ladder. Declare bounded variations (styles, content, arrangement order); a persona-keyed optimizer on the hosted API learns which one converts best for each visitor type, and measures the lift against a held-out control group.
npm install @sentientui/react # or: npx @sentientui/cli init (installs + writes .env.local + an example component)npx @sentientui/cli init
# then follow its printed instructions: wrap your app with <AdaptiveRoot> (or
# <AdaptiveProvider>) and mount the generated components/adaptive-example.tsx
npm run dev
# open http://localhost:3000?sentient_persona=a, then ?sentient_persona=b — <html data-sentient-persona> follows itThe CLI installs the package, writes .env.local, and generates an example component — it does
not edit your layout, so nothing adapts until you do the wrap-and-mount step it prints. The
example hero itself looks the same under both personas: it shows your original until a version
is published from the dashboard, which needs a real key.
With no API key the SDK runs in keyless local mode: deterministic simulated decisions, zero
network. Add a pk_… key from sentient-ui.com to learn from real
traffic:
# .env.local
NEXT_PUBLIC_SENTIENT_API_KEY=pk_your_keyWrap your root layout (Next App Router — server component):
// app/layout.tsx
import { AdaptiveRoot } from '@sentientui/react/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<AdaptiveRoot
apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}
appOrigin={process.env.NEXT_PUBLIC_APP_URL!}
>
{children}
</AdaptiveRoot>
</body>
</html>
);
}suppressHydrationWarning on <html> is required: an inline script (rendered by
AdaptiveRoot as its first child) sets the persona attributes before first paint, exactly like
the next-themes pattern. Other React apps (Vite, CRA, Remix, Pages Router) use
<AdaptiveProvider> from @sentientui/react with the same props — plus
<SentientPersonaScript /> rendered in your document <head> if you want the Rung-1a persona
attributes: only that inline script (which AdaptiveRoot includes for you) writes
data-sentient-persona / data-sentient-confidence in hosted mode. Without it, an
AdaptiveProvider app gets everything except the persona CSS hooks. (Keyless local mode writes
the attributes itself, so the demo works either way — don't let that mask a missing script when
you add a real key.)
Give <SentientPersonaScript> the same consent gate as the provider
(<SentientPersonaScript apiKey="pk_…" consentFrom="cookiebot" />). With consent={false}, or a
consentFrom and no consent={true}, it renders nothing, because its fallback reads the decision
stored on the visitor's device, which has to wait for consent. AdaptiveRoot does this for you.
Install is the integration. The dashboard immediately shows who is arriving: persona mix, device and traffic-source segments, engagement signals.
1a. Persona attributes — zero declaration. The SDK sets on <html>:
data-sentient-persona = <vocabulary key> | unknown
data-sentient-confidence = low | medium | high
The persona value is a key from the project's persona vocabulary (dashboard → Matrix →
Audiences) — declared by your app via the persona prop, or discovered from behavior. The
vocabulary starts empty (every visitor is unknown until a persona exists). Renaming a persona in
the dashboard keeps the old key resolving as an alias, so existing CSS stays intact.
Write plain CSS against them (the canonical block — swap in your own persona keys):
/* Show each visitor type what it cares about. Confidence-gate bold treatments. */
html[data-sentient-persona='admin'] .admin-tools { display: block; }
html[data-sentient-persona='evaluator'] .spec-details { display: block; }
html[data-sentient-confidence='low'] .spec-details { display: none; }In keyless local mode (development without a pk_ key), force any persona with
?sentient_persona=evaluator (any key works locally). The override is not read in hosted (pk_) mode.
1b. Adaptive tokens — learned. Declare a bounded design space; the optimizer picks per
visitor type; values arrive as element-scoped data-* props (SSR-serialized — zero flicker):
import { useAdaptiveTokens } from '@sentientui/react';
function Hero() {
const t = useAdaptiveTokens('hero', {
tone: ['calm', 'urgent'], // first value = baseline (what you show today)
motion: ['none', 'pulse'],
});
return <section {...t.props} className="hero">…</section>;
// t.props → { 'data-sentient-slot': 'hero', 'data-tone': 'urgent', 'data-motion': 'pulse' }
// t.tokens → { tone: 'urgent', motion: 'pulse' }
}.hero[data-tone='urgent'] .cta { font-weight: 700; }
/* Animation values are enum values whose CSS you own — always respect reduced motion: */
.hero[data-motion='pulse'] .cta { animation: pulse 2s infinite; }
@media (prefers-reduced-motion: reduce) {
.hero[data-motion='pulse'] .cta { animation: none; }
}Constraints: 1–4 dims, 2–6 values each, combinations ≤ 64. Enum values only — no arbitrary CSS.
Optional { goal: 'buy_click' } third argument credits conversions to this element directly.
<Adaptive> fills a region one of two ways.
Generated versions (start here). Wrap what you show today — the children are the original:
import { Adaptive } from '@sentientui/react';
<Adaptive id="hero-cta" goal="signup_click">
<a href="/signup">Start free trial</a>
</Adaptive>Mounting it and deploying registers the region. SentientUI then writes versions per visitor type
in the dashboard ("Who sees what") — no redeploy. The children render for holdout/control
traffic, unknown visitor types, visitor types with no version yet, and every error path, so the
worst case is always "nothing changed". This works in any React app under <AdaptiveRoot> or
<AdaptiveProvider> as-is — no server preload: the regions mounted on a page are requested in
one batched call scoped to exactly those ids. A first visit shows the original briefly, then
swaps; return visits start from the last version served. Keyless local mode renders the
originals only. (Formerly <AdaptiveSlot>.)
Code variants. When you want to write the alternatives yourself, pass variants instead of
children:
<Adaptive
id="buy-box"
goal="buy_click" // required
variants={{ control: <CalmBuyBox />, urgent: <UrgentBuyBox /> }} // first key = baseline
/>Use one or the other on a given <Adaptive>, not both. useAdaptive is the hook form of code
variants:
import { useAdaptive } from '@sentientui/react';
function BuyBox() {
const { variant, value, bind, fireGoal } = useAdaptive('buy-box', {
variants: { calm: <CalmBuyBox />, urgent: <UrgentBuyBox /> }, // first key = baseline
goal: 'buy_click', // required
});
return <div {...bind}>{value}</div>;
}bind (ref + data attributes) wires exposure tracking, goal listeners, and engagement signals —
attach it or the component cannot learn (dev mode warns loudly if you don't). <AdaptiveText>
swaps dashboard-managed text.
Region-scope — declared arrangements of keyed children only, never free permutation:
import { AdaptiveGroup } from '@sentientui/react';
<AdaptiveGroup
id="pricing-area"
arrangements={{
standard: ['plans', 'faq', 'social'], // first key = baseline
social_first: ['social', 'plans', 'faq'],
}}
goal="plan_selected"
>
<PlanGrid key="plans" />
<Faq key="faq" />
<Testimonials key="social" />
</AdaptiveGroup>Page-scope — declare sections on AdaptiveRoot and read the order with useLayoutOrder()
(see the API reference below). Use AdaptiveGroup for a region, sections for the page.
Every decision is locked for the session — visitors never see the page reshuffle under them.
Imported from @sentientui/react/next.
| Prop | Type | Description |
|---|---|---|
components |
Array<{ id: string; variantIds: string[] }> (optional, default []) |
Code-variant components to preload server-side. id must match <Adaptive id="…" variants={…}>; generated-version <Adaptive> regions need no entry. Omit when the tree uses only slots/sections or assigns client-side. |
sections |
string[] (optional) |
Page section IDs in default order. When provided, a single POST /v1/decide returns both layout order and assignments; useLayoutOrder() becomes available. Give each section's element data-sentient-id="<sectionId>" — the only attribute a section needs — so the graph scanner can register it; without it the server types the section generic and every persona gets the same order. The provider warns about unresolvable ids in development. |
registrySlotIds |
string[] (optional) |
The generated-mode <Adaptive id>s this page renders. Decides their published versions in the same SSR round trip and serializes the served content into the HTML, so first paint shows the version instead of the original children. Scoped to exactly these ids — an unscoped registry decide would record a trial for every published slot on every page. Omit and the regions decide client-side after mount. |
registrySlots |
{ id: string; authored?: string[]; forms?: boolean }[] (optional) |
Like registrySlotIds, with what each region can render. For a hybrid <Adaptive id variants={…}>{original}</Adaptive>, list its variants keys as authored so the SSR decide can serve a hand-written version on first paint. Without them, only the original or a generated version can be served on first paint. Set forms: false when the <Adaptive> has no onFormSubmit (default true). An id may appear in both props; it is decided once. |
sectionTypes |
Partial<Record<string, SemanticType>> (optional) |
What each section is, keyed by data-sentient-id: { about: 'trust', contact: 'cta' }. Sections are classified from their content, so declare only the ones it gets wrong — a band with little signal-bearing copy (an "About us" block, a contact form) reads generic and is ordered the same for every persona. Any section with a data-sentient-id can appear, reorderable or not. Also accepted by AdaptiveProvider. |
apiKey |
string |
pk_… key — used by both the browser SDK and server-side SSR requests. |
slots |
Array<{ id, dims? | arms?, baseline? }> (optional) |
The useAdaptiveTokens / <AdaptiveGroup> slots this tree renders. Declaring them decides them in the same SSR round trip, so their values serialize into the server HTML; undeclared ones decide after mount. |
appOrigin |
string (optional) |
Your app origin (e.g. https://yourapp.com). Must be on the project's allowed domains. Defaults to https://<Host header> in production and http://localhost:3001 in development — set it explicitly in production. |
searchParams |
page searchParams (optional) |
Pass the page's searchParams on landing pages that get ad traffic: a Server Component can't read the request URL, so without it the SSR session carries no utm_* or click IDs. |
pathname |
string (optional) |
The page's path, e.g. /products/trail-shoe. Pass it with searchParams on landing pages that get ad or email traffic, so a paid click to a product page or a /discount/ link is recognised on the server render. |
context |
'landing' | 'ecommerce' | 'saas' | 'marketplace' (optional, deprecated) |
Unused — the project's type is set in the dashboard. Safe to omit. |
persona |
string (optional) |
Declared persona — the role your app already knows for this visitor (e.g. from your auth context). Must be a key in the project's persona vocabulary (dashboard → Matrix → Audiences); unrecognized values are ignored server-side. Served at full confidence, overriding the inferred persona; forwarded through both SSR paths. Stable for the session — remount to apply a new value. Never a user id or email. |
consent |
boolean (default true) |
Set false to skip SDK init (no cookies, no events). Flip to true after the visitor accepts. |
respectDoNotTrack |
boolean (default true) |
Honor the browser's Do Not Track signal. When on and DNT is enabled, the SDK sets no cookies and sends no tracking data (overriding consent: true), and grantConsent() won't re-enable it. Set false to make your own consent gate authoritative. |
consentFrom |
preset or { cookie, value, event } (optional) |
Your consent platform — see Consent management. Cookie-based sources are also read on the server, so a consented visitor gets personalized HTML and a non-consented one gets none. |
timeoutMs |
number (default 1000) |
Server-side fetch timeout before falling back to the first variant. Typical decide is well under 150 ms; the full budget is only reached on a cold start or an API distant from your SSR host. |
nonce |
string (optional) |
CSP nonce for the inline pre-paint script and the <style> the client injects. |
captureAgents |
boolean (default true) |
Logs a known AI-agent fetch (GPTBot, Claude-User, PerplexityBot, …, matched by user-agent) to your agent analytics — machine telemetry only, no cookie or session. Set false to opt out. |
agentFeed |
{ path?, content? } (optional) |
Emits a server-rendered JSON-LD block describing the page's winning variants and layout for AI crawlers that don't run JS. Omit to emit nothing. |
debug |
boolean |
Log assignment and event activity to the console. |
- Every route under it renders dynamically. It reads
headers()andcookies()to decide per visitor, so Next.js can't statically generate or ISR-cache those routes. - It adds one API round trip to time-to-first-byte, usually well under 150 ms and capped by
timeoutMs(default 1000 ms). On timeout the page renders the originals and decides in the browser instead. - It skips the round trip entirely for visitors it must not track (DNT/GPC, consent refused or not yet given).
Want static pages? Put <AdaptiveProvider> in a client component instead, only around the routes that adapt.
Decisions then happen in the browser after hydration: the server HTML shows the originals, a returning
visitor's last decision is restored from the SDK's local snapshot as soon as the client starts (no network
wait), and a first-time visitor's arrives after one round trip.
@sentientui/react/next is ESM-only (the App Router always loads it as ESM). The package root and
/server ship both ESM and CommonJS.
Accepts the same apiKey, persona, consent, debug props as <AdaptiveRoot>, plus onAssignment (not available on <AdaptiveRoot> — function props can't cross the RSC boundary) and:
| Prop | Type | Description |
|---|---|---|
initialAssignments |
Record<string, string> |
SSR-preloaded assignments — the assignments field of loadAdaptiveAssignments' return value (not the whole object). |
ssrSessionId |
string |
The sessionId field of loadAdaptiveAssignments' return value. Required with initialAssignments so the browser adopts the same session the server assigned — otherwise exposures and goals attach to a different session than the SSR assignment. |
sessionSegment |
string |
Segment from SSR (device:source). Must match the value used in loadAdaptiveAssignments. |
initialLayoutOrder |
string[] | null |
Preloaded section order from loadAdaptiveDecision (for Pages-Router-style SSR with sections). |
initialUnified |
string[] |
The unified field of loadAdaptiveAssignments / loadAdaptiveDecision: components you moved onto the shared track, which <Adaptive> then renders as slots from the first paint. <AdaptiveRoot> passes it for you. |
One component, two modes — pass children (generated versions) or variants (code variants),
never both. AdaptiveProps is the union of the two exported prop types below.
Generated versions — AdaptiveGeneratedProps:
| Prop | Type | Description |
|---|---|---|
id |
string |
Unique region identifier within your project. |
children |
ReactNode |
The original. Renders for holdout/control traffic, unknown visitor types, visitor types with no version, and every error path. |
goal |
string | GoalConfig (optional) |
Conversion goal credited to this region. |
onFormSubmit |
(values: Record<string, string>) => void (optional) |
Receives the values when a generated form version submits. Form versions are only offered when this is present; values never reach SentientUI. |
as |
'div' | 'section' | 'li' | 'span' | … (optional, default 'div') |
Wrapper element — e.g. 'li' inside a list. Always a real element, never display: contents: it is what exposure and goals observe. |
className |
string (optional) |
Class for the wrapper element. |
reportBaselineText |
boolean (default true) |
At first registration, send this region's rendered text (capped at 400 chars) so generated versions are grounded in what they replace. Set false for a region wrapping personalized or account content. |
Code variants — AdaptiveVariantsProps:
| Prop | Type | Description |
|---|---|---|
id |
string |
Unique component identifier within your project. |
variants |
Record<string, ReactNode> |
Map of variant ID → content. Any two or more keys; the first key is the control, and the bandit explores them all. |
goal |
string | GoalConfig |
Conversion goal (required). A string is a click-goal label; an object is an explicit GoalConfig. |
funnel |
string (optional) |
Funnel this component serves — see Funnels. |
microSignalGoals |
MicroSignalGoals (optional) |
When a passive micro-signal fires on this component, also record a named goal: micro-signal type → goal name (or { name, weight?, stepIndex? }), e.g. { rage_click: 'confused_by_hero' }. |
agentDataByVariant |
Record<string, unknown> (optional) |
Structured data keyed by variant ID that AI agents can consume via GET /v1/agent/layout. Only the assigned variant's entry is sent to the server. Preferred over agentData. A new or changed value is served to agents once visitors on a few different networks have reported it, so one forged request cannot rewrite it. |
agentData |
unknown (optional, deprecated) |
Deprecated in favour of agentDataByVariant. Single value stored once regardless of which variant is shown; kept for backward compatibility. |
as, className |
(optional) | Wrapper element and its class, as for generated versions. |
clientOnly |
boolean |
Render nothing on the server; resolve on the client only. Use for cookie-dependent components. Without it, a component with no preloaded assignment server-renders its first variant. (This replaces the deprecated provider-level ssrFallback="none", which is still accepted.) |
One canonical path per situation — the others are for the cases these can't reach:
| You want to record… | Use | Credits |
|---|---|---|
| your main goal, or any conversion you record yourself (a signup, a purchase) | useGoal(name, { primary?, componentId? }) |
the session, or that component's version |
| a click, submit or scroll inside an adaptive region | the goal prop on <Adaptive> |
the version that region showed |
| a visitor reaching a page or route (pricing, checkout) | usePageGoal(name, { componentId? }) |
that component's version, or the session |
| an event you fire yourself, tied to a region (a phone call, a custom widget) | useAdaptiveGoal(componentId) |
the version that region showed |
| a conversion outside React (server, another framework) | client.goal(name, { value, externalId }) from @sentientui/core |
the session |
useAssignment records nothing (use useAdaptive), and the positional
client.goal(name, metadata, weight, stepIndex) form is deprecated in favour of
client.goal(name, options); both leave in 1.0.
A string is the goal's NAME; an object config has none. goal="signup_click" reports under
signup_click and can be promoted to a primary goal in the dashboard. An object config records
under its bare type — click, form_submit, scroll_depth — so every inline click goal on the
project collapses into one row named click, and no goal definition is ever created for it.
Prefer a named string; reach for an object only when you need a selector, a threshold or a
composite, and expect the generic name.
// Any click inside the variant (button, a, role=button). The string is the goal's name.
goal="signup_click"
// Click only on elements matching a CSS selector inside the variant.
// NOTE: unnamed — this reports as "click", not as a goal of your own.
goal={{ type: 'click', selector: 'button.cta' }}
// 80% of the component visible in the viewport (IntersectionObserver, 0–1 scale).
goal={{ type: 'scroll_depth', threshold: 0.8 }}
// A <form> inside the variant fires submit.
goal={{ type: 'form_submit' }}
// Composite — all sub-goals must fire (in any order) before the reward is recorded.
goal={{
type: 'composite',
all: [
{ type: 'scroll_depth', threshold: 0.8 },
{ type: 'click' },
],
}}
// Weighted composite — a funnel written in code. Its steps become the funnel "<id>-steps"
// (or the `funnel` prop's id), and a visit earns the credit of the FURTHEST step it reached —
// never the sum of the steps; reaching the last step earns full credit. Steps fire as they
// complete and need not happen in order. The funnel orders steps by weight, so the
// highest-weight step is the completion (dev builds warn when that reorders your list).
goal={{
type: 'weighted_composite',
steps: [
{ goal: { type: 'scroll_depth', threshold: 0.5 }, name: 'viewed_pricing', weight: 0.2 },
{ goal: { type: 'click' }, name: 'clicked_cta', weight: 0.4 },
{ goal: { type: 'form_submit' }, name: 'signed_up', weight: 1.0 },
],
}}Each goal fires at most once per variant mount. WeightedCompositeGoal is credited as a funnel (furthest step reached); CompositeGoal waits for all sub-goals and fires reward 1.0 once.
Add funnel="<funnelId>" to <Adaptive>, useAdaptive, useAdaptiveTokens, or <AdaptiveGroup> to declare that the component serves a multi-step funnel (the journey shown on the dashboard's Goals → Funnels tab). The optimizer then scores the component on journey progress: small credit for reaching intermediate steps, full credit at completion (revenue-scaled when the final conversion carries a value).
// Declares BOTH the funnel's steps and this component's membership —
// a weighted_composite goal + funnel id creates the funnel server-side.
<Adaptive
id="hero"
funnel="checkout"
goal={{
type: 'weighted_composite',
steps: [
{ goal: { type: 'scroll_depth', threshold: 0.5 }, name: 'viewed_pricing', weight: 0.2 },
{ goal: { type: 'click' }, name: 'clicked_cta', weight: 0.4 },
{ goal: { type: 'form_submit' }, name: 'signed_up', weight: 1.0 },
],
}}
variants={...}
/>
// Membership-only: joins a funnel built in the dashboard or chat.
<Adaptive id="pricing-cta" funnel="checkout" goal="plan_selected" variants={...} />The funnel id is stable — a funnel created in the dashboard or chat is referenced from code with the exact id it shows. Code declarations never overwrite a funnel edited by a human; the dashboard version wins and the component still serves it.
Lightweight text-only variant (renders an inline <span> wrapper by default — change it via the component prop). Useful when you publish text variants from the dashboard WYSIWYG. Pass goal (a name or a goal config, exactly like <Adaptive>) to score the copy — without it the wording is served and logged but never learns.
import { AdaptiveText } from '@sentientui/react';
<h1>
<AdaptiveText id="hero_headline" default="Ship faster with SentientUI" goal="signup_click" />
</h1>function useAdaptiveTokens(
id: string,
dims: Record<string, readonly string[]>, // 1–4 dims × 2–6 values; first value = baseline
opts?: { goal?: string | GoalConfig },
): { tokens: Record<string, string>; props: Record<string, string> };
// props keys: `data-${dim}` per dim, plus `data-sentient-slot`: id — goal
// wiring locates the slot's element through it, so always spread all of `props`.Spread props onto the element you style. Values serialize through SSR markup — no flicker, no
hydration mismatch. Renaming a value is a cold start for that value's learning.
function useAdaptive<T>(
id: string,
config: {
variants: Record<string, T>; // first key = baseline
goal: string | GoalConfig;
funnel?: string;
microSignalGoals?: MicroSignalGoals; // same mapping as <Adaptive microSignalGoals>
},
): {
variant: string;
value: T;
bind: { ref: (el: HTMLElement | null) => void; 'data-sentient-id': string; 'data-sentient-variant': string };
fireGoal: (goalType?: string, opts?: ComponentGoalOptions) => void;
};Headless Swap-rung hook. goal is required and bind must be attached to a rendered element —
learning needs both. Supersedes useAssignment. It runs the same exposure, goal, funnel and
micro-signal tracking as <Adaptive variants> (everything waits for the real assignment, not the
first-key placeholder), minus the wrapper <div> and the hover cursor_signal.
| Prop | Type | Description |
|---|---|---|
id |
string |
Unique group identifier. |
arrangements |
Record<string, string[]> |
Arrangement id → ordered child keys. First entry = baseline. |
baseline |
string (optional) |
Explicit baseline arrangement id (defaults to the first key). |
goal |
string | GoalConfig (optional) |
Conversion goal credited to this group. |
children |
keyed ReactNodes |
Every key referenced by an arrangement must exist. |
Reorders via React keys (DOM moves, state preserved). Declared orders only.
AdaptiveRoot renders an inline script (its first child) that sets data-sentient-persona and
data-sentient-confidence on <html> before first paint; the client SDK adopts those values
and never rewrites them mid-session. Requirements: suppressHydrationWarning on <html>,
AdaptiveRoot at the top of the tree. In keyless local mode (development without a pk_ key),
override with ?sentient_persona=<persona> — the parameter is ignored in hosted (pk_) mode.
For stores, the visitor's buying stage can go on <html> too, so CSS can make changes everyone at
that stage should get — hiding the first-order discount from people who have already bought:
<AdaptiveRoot apiKey="pk_…" stageAttributes>{children}</AdaptiveRoot>html[data-sentient-stage='returning'] .first-order-offer { display: none; }
html[data-sentient-stage='carted'] .free-shipping-bar { order: -1; }data-sentient-stage is new, browsing, carted or returning (has bought here before). Any
script on the page, and anyone at a shared computer, can read it, so customer and lapsed are
only shown separately with stageAttributes="detailed". How the visitor arrived (an ad, an
email) is never written to the page. It is set only once stages are turned on for the project, and
never for visitors in the control group: a stage rule is the same for everyone at that stage, so
it is not an experiment, and the control group is how its effect is measured.
AdaptiveRoot writes it in the server HTML. AdaptiveProvider takes the same prop and writes it
after each decision; pass stageAttributes to <SentientPersonaScript> as well to have it set
before paint from the visitor's last visit (the decision then corrects it).
In the devtools panel, a persona previewed on a page opened with ?sentient_context=<context>
(carted, cart_recovery, …) is simulated in that context: the published versions you limited
to it with Show to become eligible, and its pins apply. Nothing is recorded.
Deprecated — use
useAdaptiveinstead; it carries the goal and exposure wiringuseAssignmentleaves to you. Kept for backward compatibility.
Lower-level hook when you need the variant ID inside your own render logic (e.g. full-page layout tests where <Adaptive>'s wrapper <div> would break a flex/grid layout).
import { useAssignment, useSentient } from '@sentientui/react';
function Hero() {
const client = useSentient();
const { variantId, isLoading } = useAssignment('hero_cta', ['control', 'variant_a']);
if (isLoading) return <Skeleton />;
return variantId === 'variant_a' ? <AccentHero /> : <DefaultHero />;
}When using useAssignment you are responsible for firing the goal — <Adaptive>'s automatic impression and goal tracking does not apply. Use useAdaptiveGoal (recommended) or client?.goal(name) from useSentient().
Returns a fireGoal(goalType, opts?) callback that records a conversion attributed to the variant currently served for componentId — so it shows up in the per-variant CVR funnel with no manual variantId/projectId plumbing. Use it for imperative handlers (click, form submit, custom events) when you're not using the declarative <Adaptive goal={…}> prop.
import { useAdaptiveGoal } from '@sentientui/react';
function HeroContact({ method }: { method: string }) {
const fireContact = useAdaptiveGoal('hero_headline');
return (
<a href="tel:+1..." onClick={() => fireContact('hero_contact', { metadata: { method } })}>
Call us
</a>
);
}The served variant is resolved from the SDK's assignment cache (the same one <Adaptive id="hero_headline"> populates), so render that component before firing.
opts.once records the goal at most once per mounted component, however many times you call it — use it for conversions that are a state rather than an action ("reached step 3", an effect that may re-run). Leave it off for genuine repeat actions: each click of "add to cart" is its own conversion.
opts accepts reward (0–1, default 1) and metadata. This replaces hand-rolled helpers that call client.track({ eventType: 'goal_achieved', componentId, variantId, … }) — you no longer need to pass or track the variant yourself.
Declares a goal where it happens and returns the function that records it. Declaring is what makes the goal show up in the dashboard straight away — at an honest 0, before anyone has completed it.
'use client';
import { useGoal } from '@sentientui/react';
export function SignupForm() {
const signup = useGoal('signup', { primary: true }); // your main goal
return <form onSubmit={() => signup()}>…</form>;
}
const purchase = useGoal('purchase', { componentId: 'hero_cta' });
purchase({ value: order.total, externalId: order.id, once: true });primary: truemakes it the project's main goal: every component that doesn't name a goal of its own learns from it, and results are measured on it. Mark one goal; a secondprimaryon the same page is ignored with a console warning.- When a change takes effect. The first main goal is set by the first page load that declares it — localhost included. A different main goal from code takes over once real visitors on a few different networks have loaded the new version (the public key is in your page source, so one request can't change it). Local, LAN, tunnel and preview-deploy hosts never replace an existing main goal. A main goal picked in the dashboard sticks until your code names a different one. Every change is in the project's activity log.
componentIdalso credits the version currently served for that component, likeuseAdaptiveGoal.- The returned function takes the same per-conversion options as
fireGoal:value,currency,externalId,metadata,once.
Declare your goal ids once and every goal name the SDK takes — useGoal, usePageGoal, useAdaptiveGoal's fire function, the goal prop — autocompletes and rejects typos. A misspelled goal isn't a runtime error: it quietly records a goal of its own, and a misspelled main goal reads 0% forever.
// anywhere in your app, e.g. sentient.d.ts
declare module '@sentientui/react' {
interface Register { goal: 'signup' | 'viewed_pricing' | 'purchase' }
}Without it, any string works as before.
Records a conversion once, when a page or route is reached — the funnel steps that are a destination rather than a click: landing on /pricing, reaching a signup form, opening checkout.
import { usePageGoal } from '@sentientui/react';
function PricingPage() {
// Credit reaching this page to whichever hero CTA sent the visitor here.
usePageGoal('pricing_view', { componentId: 'hero_cta' });
return <Pricing />;
}Prefer this over a click goal on the link that led here. Arrival survives the navigation, and it also counts visitors who came from the nav, a search result or a shared link — none of whom clicked the CTA you're measuring. Pass componentId to credit the variant currently served for it (resolved from the localStorage-backed assignment cache, so it works across the page hop); omit it for a session-level goal with no per-variant attribution. opts also accepts reward and metadata, like useAdaptiveGoal.
Two failure modes it exists to prevent, both silent if you hand-roll this with useAdaptiveGoal in a useEffect:
- Double counting. A hand-rolled effect re-runs — React's double-invoked effects in development, or the client arriving — and records the arrival again unless you pass
useAdaptiveGoal's{ once: true }. Both latches are per mounted component, so a genuine remount records again with either. - Losing the goal to a consent gate. Behind a cookie banner the client doesn't exist when the page mounts, so firing on mount drops the arrival for every visitor who accepts a moment later (and
oncespends its latch on that dropped call).usePageGoalholds it until the SDK is running.
Returns the resolved persona-specific section order, or null when not configured / below confidence threshold. Always fall back to your default order.
import { useLayoutOrder } from '@sentientui/react';
function Page() {
const order = useLayoutOrder();
const defaultOrder = ['hero', 'pricing', 'features', 'social_proof'];
const sections: Record<string, React.ReactNode> = {
hero: <Hero />, pricing: <Pricing />, features: <Features />, social_proof: <SocialProof />,
};
return <main>{(order ?? defaultOrder).map((id) => <Fragment key={id}>{sections[id]}</Fragment>)}</main>;
}import { loadAdaptiveAssignments } from '@sentientui/react/server';
export async function getServerSideProps({ req }) {
const { assignments, sessionId } = await loadAdaptiveAssignments(
[{ id: 'hero_cta', variantIds: ['control', 'variant_a'] }],
{
// Pages Router's req.cookies is a plain object; the helper reads cookies via get(name).
cookies: { get: (name) => (req.cookies[name] ? { value: req.cookies[name] } : undefined) },
apiKey: process.env.NEXT_PUBLIC_SENTIENT_API_KEY!,
baseUrl: 'https://api.sentient-ui.com/v1',
origin: process.env.NEXT_PUBLIC_APP_URL,
},
);
return { props: { initialAssignments: assignments, ssrSessionId: sessionId } };
}Pass assignments as initialAssignments and sessionId as ssrSessionId on <AdaptiveProvider> in _app.tsx — the return value is { assignments, sessionId }, and without ssrSessionId the browser starts a different session than the one the server assigned, so the preload silently does nothing and events attach to the wrong session. For pages with a sections layout, use loadAdaptiveDecision (same options shape, plus sections: string[]) — its return value carries both assignments and layoutOrder.
Both also return unified (on loadAdaptiveDecision it can be absent, meaning none): the components you moved onto the shared track, which are not in assignments. Pass it as initialUnified. In the browser, client.isUnified(id) answers the same question for ids learned after load.
<AdaptiveProvider
apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}
onAssignment={(componentId, variantId) => {
posthog.capture('$feature_flag_called', { $feature_flag: componentId, $feature_flag_response: variantId });
mixpanel.register({ [`variant_${componentId}`]: variantId });
}}
>
{children}
</AdaptiveProvider>Fires at most once per component ID per page load. Full Segment / GA4 / Mixpanel recipes: see sentient-ui.com/docs/integrations.
Force a variant without touching the bandit:
# URL parameter (stackable)
https://yourapp.com?sentient_variant=hero_cta:variant_a&sentient_variant=pricing:annual_first
# Or, before SDK init:
window.__sentient_overrides = { hero_cta: 'variant_a' };
Overrides bypass the bandit entirely — no events recorded, weights unchanged.
Some conversions happen where the browser can't see them: a payment confirmed by a webhook, an order saved by a server action. trackServerGoal records one with your secret key (sk_…, server-only).
Same request (the visitor's browser made the request, so their cookie is there):
import { cookies } from 'next/headers';
import { readSessionCookie, trackServerGoal } from '@sentientui/react/server';
const sessionId = readSessionCookie(await cookies(), process.env.NEXT_PUBLIC_SENTIENT_API_KEY!);
await trackServerGoal({
serverKey: process.env.SENTIENT_SECRET_KEY!,
sessionId,
name: 'purchase',
dedupeKey: order.id, // your own id for this conversion
value: order.total,
currency: 'USD',
});Later, from a webhook (Stripe, Clerk, a queue: no cookie in sight). Save the visitor id when the visitor acts, then use it when the confirmation arrives:
- In the browser, read
client.getVisitorId()and send it along with the checkout or sign-up (for example in its metadata). It isnulluntil the visitor has agreed to tracking, so nothing is saved for people who haven't. - In your webhook handler, read that id back from the event and pass it as
sessionId.
trackServerGoal resolves 'recorded', 'skipped_no_visitor' (no id, so nothing is sent) or 'failed'. It never throws. Sending the same name and dedupeKey for the same visitor again, for example when a webhook is redelivered, counts once. The id is scoped to the visitor's session, so another site using the same order number never collides with yours. It refuses to run in a browser and never sends the secret key there.
DOM graph scanning and behavioral engagement capture are on by default — no props needed. The SDK:
- captures your page structure and auto-detects what each section is (pricing, hero, social proof, …) — types declared in
sectionTypesalways win over the heuristic; - records per-section attention (visible time + scroll depth), which is what powers audience profiles with zero tagging.
Both modules are loaded on demand after init, so the base bundle stays lean, and neither ever runs for a Do-Not-Track, Global Privacy Control, or consent-gated visitor (see Consent below).
What they cost, so you can decide: about 21 KB gzip on top of the ~17 KB core client, fetched after hydration (never on the critical path). One MutationObserver watches the page for sections added later, and it batches a burst of DOM changes into a single scan 200 ms after the burst starts, so a chat or a live list doesn't make it do work on every frame. Turn graph scanning off and the dashboard's page-structure view goes empty. Turn engagement off and audiences stop learning from on-page behaviour.
Opt out per feature:
<AdaptiveProvider apiKey="pk_…" enableGraph={false} engagement={false}>
<App />
</AdaptiveProvider>Direct @sentientui/core users enable graph scanning by importing init from @sentientui/core/graph with graph: true, and engagement capture via startEngagementCapture from @sentientui/core/engagement.
Tracking starts on first paint unless you gate it — opt-in consent is something you wire up,
not the default (in development the provider warns once when neither consent nor
consentFrom is set). While the SDK is gated no cookie is written and nothing about the
visitor is sent. The one exception is preConsentBehavior="statistical_winner", which asks
GET /v1/winner for the best version, a read-only request that carries no identifier.
A refusal forgets the visitor: the cookie, the assignment cache and the decision snapshot.
That means consent flipping to false, or the platform recording a "no". A platform that is
still loading, or reopened by a visitor who already said yes, only pauses tracking. Once the
platform has answered in the page load, a return to "no answer" (a banner reset that deletes its
cookie) also pauses: AdaptiveRoot's server-side read applies only until the browser answers.
Conversions fired before consent is known: with preConsentBehavior they are held in memory
and sent, in order and ahead of later goals, if the visitor accepts on the same page (dropped on a refusal). Without it there is no
client yet, so useSentient() returns null and a conversion fired then is not recorded. Use
usePageGoal, which waits, or fire once useSentient() returns a client.
Every key the SDK stores is listed in SDK_COOKIE_DISCLOSURE.md.
Point consentFrom at your CMP. The provider reads the platform's own API, events and cookie,
starts the moment it grants, and gates and forgets again on withdrawal — no reload, no callback
wiring:
<AdaptiveProvider apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!} consentFrom="onetrust">
{children}
</AdaptiveProvider>| Preset | Reads | Default category |
|---|---|---|
'cookiebot' |
Cookiebot.consent, CookieConsent cookie |
statistics — { cmp: 'cookiebot', category: 'marketing' } to change |
'onetrust' |
OnetrustActiveGroups (exact group match), OptanonConsent cookie |
C0002 — { cmp: 'onetrust', group: 'C0004' } |
'cookieyes' |
getCkyConsent(), cookieyes-consent cookie |
analytics |
'tcf' |
IAB TCF v2.2 __tcfapi, euconsent-v2 cookie |
purposes 1, 5, 6, 8 (8 may rest on legitimate interest); { cmp: 'tcf', vendorId } also requires vendor consent |
'google-consent-mode' |
the latest consent default/update in dataLayer |
analytics_storage; pass region: 'ES' (e.g. from your CDN's country header) to resolve region-scoped defaults — without it a region-scoped denial reads as denied |
'shopify' |
Shopify Customer Privacy API | analytics |
Your own banner works too: consentFrom={{ cookie: 'analytics_consent', value: 'granted', event: 'consent-decided' }},
or any JS API: consentFrom={{ check: () => window.myCmp?.analytics === true, event: 'mycmp:changed' }}.
A check returning false only pauses tracking; add refused: () => window.myCmp?.answered === true
so a refusal also deletes the visitor's data.
Hand-rolled checks are where CMP bugs live (OnetrustActiveGroups.includes('C0002') is also
true for C00021), so prefer a preset.
To show the best-performing version while the banner is pending, instead of the first one, add
preConsentBehavior="statistical_winner" — it calls only GET /v1/winner, a read-only endpoint
that stores nothing about the visitor.
<AdaptiveRoot> is a Server Component: it takes every preset and the cookie form (a check
function cannot cross the server→client boundary). For the cookie-based presets
(cookiebot, onetrust, cookieyes, tcf) and the cookie form it also reads the decision on
the server, so an already-consented visitor gets personalized server HTML:
<AdaptiveRoot apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!} consentFrom="cookiebot">
{children}
</AdaptiveRoot><AdaptiveProvider apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!} consent={hasConsent}>Flip hasConsent to true on accept and back to false on withdrawal. Don't wire a CMP
callback to grantConsent() in React: with consent={false} and no preConsentBehavior the
provider has no client to upgrade.
import { init, grantConsent } from '@sentientui/core';
const client = init({ apiKey: 'pk_...', consent: false });
grantConsent(); // on accept: upgrades the client in place
client.destroy(); // on withdrawal: forgets the visitorFull SDK reference: sentient-ui.com/docs.
Integrations: sentient-ui.com/docs/integrations.
MIT