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/react

You 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.

The key starts with 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/events
SENTIENT_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[] }>
List every <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.
serverApiKeystring
Your API key used for server-side assignment requests. Use the non-NEXT_PUBLIC_ env var so it is never exposed in the client bundle.
apiBaseUrlstring
Base URL of the SentientUI API without a trailing slash, e.g. https://api.yoursentient.app/v1. Used for server-side session and assignment calls.
apiKeystring
Same API key as serverApiKey, but as a NEXT_PUBLIC_ variable so it is available to the browser SDK for client-side event tracking.
ingestUrlstring
Full URL of the events endpoint, e.g. https://api.yoursentient.app/v1/events. The browser SDK posts batched events here every 5 seconds.
context'saas' | 'landing' | 'ecommerce' | 'marketplace'
The type of product. See Context types below.
consentboolean (default: true)
Set to 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')
The origin of your app, e.g. 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')
What to render during SSR when a component is not in the 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

idstring
Unique identifier for this component within your project. Use the same string everywhere this component appears. Must match the id in your AdaptiveRoot components list if you want SSR preloading.
variantsRecord<string, ReactNode>
A plain object mapping variant IDs to React content. Keys are your own strings — use anything descriptive (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 | GoalConfig
What counts as a conversion for this component. See Goal types below. Required.
clientOnlyboolean (default: false)
When 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} />;
}
When using 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.

ValueUse case
saasSaaS products, dashboards, onboarding flows, upgrade prompts
landingMarketing sites, landing pages, waitlists, announcement pages
ecommerceProduct pages, collection listings, carts, checkout flows
marketplaceTwo-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 | null
The assigned variant ID. null during SSR before any assignment is available (only when not using initialAssignments).
isLoadingboolean
true while the first assignment fetch is in flight. Typically falseimmediately when initialAssignments are provided via AdaptiveRoot.
Important: 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 } };
}

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.

The _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_first

Global 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',
};
Overrides are client-only and log a console message in non-production environments so you can confirm which component is being overridden. Overrides bypass the bandit entirely — no events are recorded and the variant weights are not affected.

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.

DimensionValues
Device classmobile, tablet, desktop
Traffic sourcedirect, 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 dominates

The 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.