SDK Documentation
@sentientui/react — adaptive UI with automatic variant learning
Overview
SentientUI is a React SDK that automatically learns which variant of a UI component converts best for each visitor segment. You define the variants; the SDK runs a multi-armed bandit in the background, shifting traffic toward the winner without any manual analysis or feature flags.
It works in any React app and has first-class support for Next.js App Router with server-side rendering — variants appear in the initial HTML for SEO and zero layout shift on first paint.
Installation
Install the React package. The core engine is included automatically as a dependency.
npm install @sentientui/react
# or
yarn add @sentientui/react
# or
pnpm add @sentientui/reactYou only ever import from @sentientui/react. The @sentientui/core package is the framework-agnostic engine — it ships as a dependency and you do not need to install or import it directly.
API key
Each project has one API key, generated when you create the project in the dashboard. It looks like pk_xxxxxxxxxxxxxxxx. The full key is only shown once at creation — copy it before closing the dialog. If you lose it, you can revoke it and generate a new one from Settings.
pk_ (public key). It is safe to expose in browser bundles — the API enforces an allowed-origins allowlist on every request so other domains cannot use your key. Add your production domain in Settings → Allowed origins.Environment variables
For Next.js you need the same key in two variables. This is a Next.js requirement: variables without the NEXT_PUBLIC_ prefix are only available on the server; the browser SDK needs a NEXT_PUBLIC_ copy to initialise on the client.
# .env.local
# Your API key — set the same value in both variables
SENTIENT_API_KEY=pk_your_key_here
NEXT_PUBLIC_SENTIENT_API_KEY=pk_your_key_here
# API base URL (without /events)
SENTIENT_API_URL=https://api.yoursentient.app/v1
# Events ingest endpoint
NEXT_PUBLIC_SENTIENT_INGEST_URL=https://api.yoursentient.app/v1/eventsSENTIENT_API_URL and NEXT_PUBLIC_SENTIENT_INGEST_URL are endpoint URLs, not keys. Contact your account admin for the correct values if you are self-hosting.Setup — Next.js App Router
Wrap your root layout with <AdaptiveRoot>. This is a server component that fetches variant assignments before the HTML is sent to the browser, so every component renders with real content on the first paint — no loading states, no layout shift, and crawlers see the actual variant markup.
// app/layout.tsx
import { AdaptiveRoot } from '@sentientui/react/next';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<AdaptiveRoot
components={[
{ id: 'hero_cta', variantIds: ['control', 'variant_a'] },
{ id: 'pricing', variantIds: ['monthly', 'annual_first'] },
]}
serverApiKey={process.env.SENTIENT_API_KEY!}
apiBaseUrl={process.env.SENTIENT_API_URL!}
apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}
ingestUrl={process.env.NEXT_PUBLIC_SENTIENT_INGEST_URL!}
context="saas"
>
{children}
</AdaptiveRoot>
</body>
</html>
);
}AdaptiveRoot props
componentsArray<{ id: string; variantIds: string[] }><Adaptive> component you want server-side preloaded. The id must match exactly what you pass to <Adaptive id="...">. The variantIds must include all variant keys you define in the variants prop.serverApiKeystringNEXT_PUBLIC_ env var so it is never exposed in the client bundle.apiBaseUrlstringhttps://api.yoursentient.app/v1. Used for server-side session and assignment calls.apiKeystringserverApiKey, but as a NEXT_PUBLIC_ variable so it is available to the browser SDK for client-side event tracking.ingestUrlstringhttps://api.yoursentient.app/v1/events. The browser SDK posts batched events here every 5 seconds.context'saas' | 'landing' | 'ecommerce' | 'marketplace'consentboolean (default: true)false to prevent the SDK from initialising. No events are sent, no cookies are written. Flip to true once the visitor grants consent. See Consent / GDPR below.appOriginstring (default: 'http://localhost:3001')https://yourapp.com. Must match a value in the project's allowed origins list. Used in server-side assignment and session requests. Always set this in production — the default is only suitable for local development.ssrFallback'first' | 'none' (default: 'first')components preload list. 'first' renders the first variant key (safe for SEO). 'none' renders nothing (use with clientOnly for decorative slots).Setup — other React apps
For Vite, Create React App, Remix, or any React app without Next.js App Router, use <AdaptiveProvider> directly. Variants are assigned on the client after mount — there is no SSR preloading, but the bandit still learns and optimises normally.
// main.tsx or App.tsx
import { AdaptiveProvider } from '@sentientui/react';
export default function App() {
return (
<AdaptiveProvider
apiKey="pk_your_key_here"
ingestUrl="https://api.yoursentient.app/v1/events"
context="saas"
>
<YourApp />
</AdaptiveProvider>
);
}<AdaptiveProvider> accepts the same context, consent, ssrFallback, and onAssignment props as <AdaptiveRoot>, plus initialAssignments if you are handling SSR yourself (see SSR — Pages Router below), and debug?: boolean to log assignment and event activity to the console.
<Adaptive> component
The main building block. Wrap any piece of UI you want to test. The bandit picks which variant to show, tracks impressions automatically, and records a reward when the goal fires.
import { Adaptive } from '@sentientui/react';
<Adaptive
id="hero_cta"
goal="signup_click"
variants={{
control: <button className="btn-dark">Start free trial</button>,
variant_a: <button className="btn-blue">Get instant access →</button>,
}}
/>Props
idstringid in your AdaptiveRoot components list if you want SSR preloading.variantsRecord<string, ReactNode>control, variant_a, short, with_image, etc.). You can have two or more variants. The bandit will explore all of them and exploit the winner over time.goalstring | GoalConfigclientOnlyboolean (default: false)true, the component renders nothing on the server and waits until the client has hydrated. Use this for personalised or decorative slots where you explicitly do not want SSR content (e.g. a "welcome back" banner that depends on a cookie). When false (the default), the component SSR-renders the preloaded variant, which is recommended for above-the-fold content.Components vs full pages
<Adaptive> works at any granularity — a button, a hero section, or an entire page layout. However, the right tool depends on what you are testing.
Use <Adaptive> for isolated components
This is the recommended pattern. Wrap a CTA, pricing block, banner, or any self-contained piece of UI. The component adds a wrapper <div> around its content, which is fine for most layouts and enables automatic impression tracking, goal wiring, and the HTML preview in the dashboard.
<Adaptive
id="hero_cta"
goal="signup_click"
variants={{
control: <button>Start free trial</button>,
variant_a: <button>Get instant access →</button>,
}}
/>Use useAssignment for full-page or route-level tests
When you want to test entire page layouts, the wrapper <div> that <Adaptive> renders can break your root CSS (flex/grid direct children, body margin, etc.). For those cases, use the useAssignment hook directly — you get the assigned variant ID and branch your own JSX with no wrapping element.
import { useAssignment, useSentient } from '@sentientui/react';
function LandingPage() {
const client = useSentient();
const { variantId } = useAssignment('landing_layout', ['control', 'sidebar']);
// Fire the goal manually wherever it makes sense in your page.
function handleSignup() {
client?.track({
componentId: 'landing_layout',
variantId: variantId!,
eventType: 'goal_achieved',
goalType: 'signup',
payload: { reward: 1.0 },
});
}
return variantId === 'sidebar'
? <SidebarLayout onSignup={handleSignup} />
: <StackedLayout onSignup={handleSignup} />;
}useAssignment directly you are responsible for tracking events. The automatic impression (variant_assigned) and goal (goal_achieved) tracking that <Adaptive> provides does not apply.Two other limitations apply specifically to full-page use: the previewHtml capture that populates the dashboard preview is capped at 30 KB and will be a truncated, style-less snapshot of a full page — not useful. And the current automatic goal types (click, scroll_depth) track interactions within the component boundary; for page-level conversions like sign-up or purchase completion you will always want a manual client.track() call anyway.
Goal types
The goal prop defines what the bandit treats as a successful outcome. When the goal fires, the currently shown variant receives a reward of 1.0 and the bandit updates its weights. Each goal fires at most once per variant mount — a second click on the same variant does not double-count.
Click — string shorthand
<Adaptive id="cta" goal="signup_click" variants={...} />
// Any click on a <button>, <a>, or role="button" element inside
// the variant fires the goal. The string is recorded as the goal
// label in your analytics. Use any descriptive name.Click — explicit object
<Adaptive
id="cta"
goal={{ type: 'click' }}
variants={...}
/>
// Identical behaviour to the string shorthand.Scroll depth
<Adaptive
id="banner"
goal={{ type: 'scroll_depth', threshold: 0.8 }}
variants={...}
/>
// Fires when at least 80% of the component is visible in the
// viewport. Uses IntersectionObserver — no scroll listeners.
//
// threshold: number 0–1
// 0.5 = half visible
// 1.0 = fully visible (the component has been completely read)
//
// Useful for banners, feature sections, and content you want
// visitors to actually see before counting a conversion.Form submit
<Adaptive
id="signup_form"
goal={{ type: 'form_submit' }}
variants={{
control: <SignupFormA />,
short: <SignupFormB />,
}}
/>
// Fires when a <form> element inside the variant fires a submit
// event — whether triggered by a button click, keyboard Enter, or
// programmatic form.submit(). Fires at most once per variant mount.
//
// Use this instead of goal="click" when your variant contains a form,
// because a click goal only triggers on <button> and <a> elements
// and misses keyboard submissions.Composite — all sub-goals must fire
<Adaptive
id="feature_section"
goal={{
type: 'composite',
all: [
{ type: 'scroll_depth', threshold: 0.8 },
{ type: 'click' },
],
}}
variants={...}
/>
// Both sub-goals must fire (in any order) before the reward is
// recorded. Useful for "read AND clicked" patterns — ensures the
// variant earned the conversion rather than getting lucky clicks
// from visitors who never saw the content.
//
// 'all' accepts any mix of click and scroll_depth goals.Context types
The context prop describes the type of product. It is stored with the project and used to inform segment weighting and analytics grouping.
| Value | Use case |
|---|---|
| saas | SaaS products, dashboards, onboarding flows, upgrade prompts |
| landing | Marketing sites, landing pages, waitlists, announcement pages |
| ecommerce | Product pages, collection listings, carts, checkout flows |
| marketplace | Two-sided marketplaces, listing pages, search results |
useAssignment hook
Use this when you need the assigned variant ID inside your own component logic rather than passing content as variants props.
import { useAssignment } from '@sentientui/react';
function Hero() {
const { variantId, isLoading } = useAssignment(
'hero_cta', // component id
['control', 'variant_a'], // all possible variant ids
);
if (!variantId) return <HeroSkeleton />;
return variantId === 'variant_a'
? <AccentHero />
: <DefaultHero />;
}variantIdstring | nullnull during SSR before any assignment is available (only when not using initialAssignments).isLoadingbooleantrue while the first assignment fetch is in flight. Typically falseimmediately when initialAssignments are provided via AdaptiveRoot.useAssignment does not track impressions or wire up goal events automatically. If you use the hook directly instead of <Adaptive>, you are responsible for tracking events yourself via the client.track() method from useSentient(). Use <Adaptive> whenever possible.SSR — Pages Router
For Next.js Pages Router or any custom SSR setup, use loadAdaptiveAssignments to fetch variant assignments on the server and pass them to the provider as initialAssignments. This helper reads the session cookie automatically — you don't need to handle it yourself.
// pages/_app.tsx
import { AdaptiveProvider } from '@sentientui/react';
export default function App({ Component, pageProps }) {
return (
<AdaptiveProvider
apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY}
ingestUrl={process.env.NEXT_PUBLIC_SENTIENT_INGEST_URL}
context="saas"
initialAssignments={pageProps.initialAssignments ?? {}}
>
<Component {...pageProps} />
</AdaptiveProvider>
);
}
// pages/index.tsx
import { loadAdaptiveAssignments } from '@sentientui/react';
export async function getServerSideProps({ req }) {
const assignments = await loadAdaptiveAssignments(
[
{ id: 'hero_cta', variantIds: ['control', 'variant_a'] },
{ id: 'pricing', variantIds: ['monthly', 'annual_first'] },
],
{
cookies: req.cookies,
apiKey: process.env.SENTIENT_API_KEY,
baseUrl: process.env.SENTIENT_API_URL,
origin: process.env.SENTIENT_APP_ORIGIN, // must be in allowed origins
},
);
return { props: { initialAssignments: assignments } };
}Consent / GDPR
The SDK writes a session cookie (_snt_uid) and sends events to the API. If your product requires explicit consent before setting cookies or tracking behaviour, use the consent prop.
const [consented, setConsented] = useState(false);
<AdaptiveProvider
apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY}
ingestUrl={process.env.NEXT_PUBLIC_SENTIENT_INGEST_URL}
context="saas"
consent={consented} // false = SDK not initialised
>
<CookieBanner onAccept={() => setConsented(true)} />
{children}
</AdaptiveProvider>When consent={false}: no SDK is initialised, no cookies are written, no events are sent, and all <Adaptive> components fall back to ssrFallback behaviour. Flipping to true initialises the SDK and begins tracking from that point.
_snt_uid cookie is a first-party session identifier. It contains no personal data — only a random UUID used to maintain session continuity across page loads. See the Privacy page for a full breakdown of what SentientUI collects and how long data is retained.Forwarding to your own analytics
Pass onAssignment to the provider to be notified once per component the first time a variant is resolved in that session. Use this to send the assignment to Mixpanel, PostHog, Segment, or any other tool without wrapping every <Adaptive> manually.
<AdaptiveProvider
apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY}
ingestUrl={process.env.NEXT_PUBLIC_SENTIENT_INGEST_URL}
context="saas"
onAssignment={(componentId, variantId) => {
// Called once per component per session when the variant is first resolved.
mixpanel.register({ [`variant_${componentId}`]: variantId });
posthog.capture('$feature_flag_called', { flag: componentId, variant: variantId });
analytics.track('Variant Assigned', { componentId, variantId });
}}
>
{children}
</AdaptiveProvider>onAssignment fires at most once per component ID per page load, even if the component re-renders. It does not fire for dev overrides — see Local overrides below.Local overrides (development)
Force a specific variant without touching the bandit — useful for QA, visual testing, and building new variants before they go live.
URL parameter
Append sentient_variant=componentId:variantId to any page URL. Repeat for multiple components. Works in any environment where the URL is accessible.
# Force a single component
https://yourapp.com/pricing?sentient_variant=hero_cta:variant_a
# Force multiple components at once (repeat the param)
https://yourapp.com/pricing?sentient_variant=hero_cta:variant_a&sentient_variant=pricing:annual_firstGlobal object
Set window.__sentient_overrides before the SDK initialises. Useful for Storybook, Playwright, or any test harness where you control the page environment.
// In a test setup file or Storybook decorator:
window.__sentient_overrides = {
hero_cta: 'variant_a',
pricing: 'annual_first',
};How it works
Each <Adaptive> component runs an independent ε-greedy multi-armed bandit. The bandit operates per segment — a combination of the visitor's device class and traffic source.
| Dimension | Values |
|---|---|
| Device class | mobile, tablet, desktop |
| Traffic source | direct, search, social, referral |
This means mobile visitors from search may have a different winning variant than desktop visitors arriving directly. The bandit learns independently per segment — no manual cohort setup required.
Assignment flow
1. Visitor arrives → session created (or resumed from cookie)
2. AdaptiveRoot (or useAssignment) calls POST /v1/assign
→ API looks up segment weights for this (component, segment) pair
→ ε-greedy: exploit the best-known variant or explore randomly
→ returns variantId
3. Component renders the assigned variant
4. SDK sends a variant_assigned event (impression)
5. When the goal fires → SDK sends goal_achieved event
6. API updates variant weights: avg_reward shifts toward the winner
7. Over time: epsilon decays, exploitation increases, winner dominatesThe learning progress bar in the dashboard tracks bandit pulls toward a configurable target (default 500). Below that threshold the algorithm is still actively exploring. Above it, confidence is high enough to consider promoting a winner manually or letting the bandit converge fully.
Events are batched in memory and flushed every 5 seconds, or immediately when the page becomes hidden. No data is lost on tab close — the SDK uses fetch with keepalive: true for unload-safe delivery.