Abrova
☰ Docs menu · Next.js
Coming soon. Abrova A/B Testing is coming soon. This page describes how it will work; it can’t be used yet.

A/B Testing Next.js SDK

Browser SDK for A/B experiments and feature flags on Next.js / React. React Provider, useVariant / useFeatureFlag hooks, signed user tokens, App Router & Pages Router compatible.

Next.js 0.2.3Next.js 13+React 17+Last updated: 27 Oct 2026
AbrovaAbTesting Next.js SDK v0.2.3.

Browser SDK for A/B testing and feature flags on Next.js and React. React Provider for context-based access, useVariant / useFeatureFlag / useFeatureValue hooks, compatible with both App Router and Pages Router. Installs from Abrova Center.

Requirements.
  • Next.js 13+ (App Router or Pages Router).
  • React 17.0.0 or higher.
  • TypeScript supported out of the box.
  • A backend route that mints Abrova user tokens (see step 2).
User tokens.

Signed user tokens are required for anything that acts for a user: assigning variants, tracking events and conversions, and evaluating feature flags. Add a tokenProvider to your config (see step 2). Reading the experiment and flag lists still works with the API key alone, so a page can load them before anyone signs in.

New in 0.2.2: you can start the SDK before sign-in. It waits for the user token instead of failing, and the hooks render again with the real values when the token arrives.

Installation#

bash
# 1. .npmrc in your project root: @abrova packages come from Abrova Center
@abrova:registry=https://center.abrova.ir/api/packages/abrova/npm/
bash
# 2. Install
npm install @abrova/ab-testing-nextjs@^0.2.3

Or with Yarn:

bash
yarn add @abrova/ab-testing-nextjs@^0.2.3

User tokens#

Anyone can read the API key out of your site, so on its own it can't prove which user a request is for. Your server can: it holds your project's server secret and mints a short-lived token for the signed-in user. The SDK sends that token, and the service takes the user from it.

Mint the token on your server

Call the Abrova auth endpoint from your backend with your API key and server secret:

bash
curl -X POST https://auth.abrova.ir/users/token \
  -H "x-api-key: $ABROVA_API_KEY" \
  -H "x-server-secret: $ABROVA_SERVER_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"userId": "user-123", "expiresIn": 3600}'

# → { "token": "eyJhbGciOi…", "userId": "user-123", "expiresIn": 3600, "expiresAt": "…" }

expiresIn is in seconds: 3600 (1 hour) by default, 86400 (24 hours) maximum.

In a Next.js route handler, the Node.js SDK makes the same call for you with createUserToken:

typescript
// app/api/abrova-token/route.ts  (runs on your server)
import { AbrovaAbTesting } from '@abrova/ab-testing-node';

const ready = AbrovaAbTesting.init({
  apiKey: process.env.ABROVA_API_KEY!,
  serverSecret: process.env.ABROVA_SERVER_SECRET,
});

export async function POST() {
  const session = await getSession(); // your auth
  if (!session) return new Response(null, { status: 401 });

  await ready;
  return Response.json(await AbrovaAbTesting.createUserToken(session.user.id));
}
Keep the server secret on the server.

Never put the server secret in client code, a Client Component, or a NEXT_PUBLIC_ variable. Only your route handler reads it; the browser only ever sees the user token.

One token for A/B Testing, Chat and Sync

The token is the same Abrova user token used by Abrova Chat and Abrova Sync. If your app uses them too, mint one token and give it to every SDK's tokenProvider instead of fetching one per product.

Give it to the SDK

Pass a tokenProvider that calls your route. The SDK caches the token, asks again shortly before it expires, and asks once more if the service reports it expired. Return null while no one is signed in.

typescript
async function fetchAbrovaToken(): Promise<string | null> {
  const res = await fetch('/api/abrova-token', { method: 'POST' });
  if (res.status === 401) return null; // no one signed in: the SDK waits
  if (!res.ok) throw new Error('Could not get an Abrova token');
  return (await res.json()).token;
}

If you already hold a token, you can pass it as userToken instead, and hand over a newer one with setUserToken(token). tokenProvider is preferred: a static token expires.

The service takes the user from the token, so the SDK does too: you may leave userId out and let the token say who the user is.

Before your user signs in

You can start the SDK before anyone is signed in: return null from tokenProvider and call refreshUserToken() when the user signs in or out. If you hand tokens over yourself, call setUserToken(token) on sign-in and setUserToken(null) on sign-out.

Until it has a token, the SDK waits: getVariant, the flag getters and the hooks return your defaults at once, nothing is sent, and nothing is reported as an error. When the token arrives, the SDK continues by itself and the hooks render again with the real values.

typescript
AbrovaAbTesting.isAwaitingUserToken;             // true while it waits
AbrovaAbTesting.onAwaitingUserToken(() => {});   // called each time it starts waiting; returns an unsubscribe function
AbrovaAbTesting.on('userTokenReady', () => {});  // a token arrived: read variants again

// After sign-in
await AbrovaAbTesting.refreshUserToken();        // with a tokenProvider: asks it again now
await AbrovaAbTesting.setUserToken(token);       // without one: hand the token over

// After sign-out
await AbrovaAbTesting.clearUserId();             // or setUserToken(null)

If you pass userId to AbrovaAbTestingProvider, the provider makes the sign-in and sign-out calls for you when userId changes.

Events tracked while the SDK waits are kept (up to 1,000, the oldest dropped first). After sign-in they are sent only if they were recorded for that same user, that is, setUserId was called with their ID before the token arrived. Events recorded while no user was set belong to nobody, are not credited to anyone, and are dropped when a user signs in.

Keep it for the session.

A token is valid until it expires. The SDK keeps it in memory and reuses it for every request, calling your route again only when it is close to expiry or after a full page reload. To avoid that call on reloads, your tokenProvider can return a token you kept with the user's session until it nears expiry.

Initialization#

The SDK runs in the browser. init() is ignored on the server, so no visitor's state is shared between requests. For server code (route handlers, Server Components, middleware), use the Node.js SDK.

Option 1: React Provider (Recommended)

typescript
// app/providers.tsx
'use client';

import { AbrovaAbTestingProvider } from '@abrova/ab-testing-nextjs/react';

export function Providers({
  userId,
  children,
}: {
  userId?: string;
  children: React.ReactNode;
}) {
  return (
    <AbrovaAbTestingProvider
      config={{
        apiKey: 'YOUR_API_KEY',
        tokenProvider: fetchAbrovaToken,
        debug: process.env.NODE_ENV === 'development',
      }}
      userId={userId}
      userAttributes={{ plan: 'premium', country: 'US' }}
    >
      {children}
    </AbrovaAbTestingProvider>
  );
}

// app/layout.tsx
import { Providers } from './providers';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await getSession(); // your auth

  return (
    <html>
      <body>
        <Providers userId={session?.user.id}>{children}</Providers>
      </body>
    </html>
  );
}

The provider follows userId. When it changes to another user (a login, a switch of account), the SDK sends the previous user's pending events under their own token, then clears their variants, attributes, token and unsent events before loading the new user's. Clearing userId on sign-out does the same and leaves nothing of the user in the browser.

userId is optional when you use a user token: the SDK takes the user from the token. In that case, call AbrovaAbTesting.refreshUserToken() when the user signs in or out.

Option 2: Direct Import

typescript
'use client';

import { AbrovaAbTesting } from '@abrova/ab-testing-nextjs';

// Initialize once, in the browser (for example in a useEffect)
await AbrovaAbTesting.init({
  apiKey: 'YOUR_API_KEY',
  tokenProvider: fetchAbrovaToken,
  debug: process.env.NODE_ENV === 'development',
  // flushInterval: 10000, // your own send interval in ms; leave it out to follow the console
  maxQueueSize: 1000,
});

// Optional with a user token: the token says who the user is
await AbrovaAbTesting.setUserId(session.user.id);

getVariant, the flag getters and the track… methods never reject when you call them before init() or without a signed-in user: getters resolve with the default you pass, and tracking before init() does nothing.

Client Components only.

Use the SDK in Client Components ('use client'). It reads localStorage and the DOM only when typeof window !== 'undefined', so importing it from a server-rendered file is safe, but it does nothing there.

Send interval and list cache

Two values come from your project's settings in the Abrova console (A/B Testing › Settings › Defaults):

  • Send interval — how often queued events are sent. The SDK follows it when you leave flushInterval out. A flushInterval you pass is your own and is always used.
  • List cache — how long the SDK uses the experiment list before it asks the service again. There is no SDK option for it. Once the list is older than that, the SDK fetches it again in the background the next time your code reads a variant or tracks an event. The call doesn't wait for it, and a failed refresh does not emit an error event.

A change you make in the console (a new experiment, changed weights or traffic) reaches open pages within the list cache time, without a restart. A user who already has a variant keeps it. A changed send interval is picked up at that same refresh. The SDK keeps the console's values within safe limits, and sends every 30 seconds until it knows the console's value. Apps on SDK versions older than 0.2.3 keep their built-in behaviour.

From 0.2.3, a flushInterval set in code always wins.

React Hooks#

Use built-in hooks for seamless React integration. Requires AbrovaAbTestingProvider. Each hook returns its default value until the provider is ready and the user is set. While there is no user token, the hooks return their defaults with isLoading false, and render again when the token arrives or the user changes.

useVariant

typescript
'use client';

import { useVariant } from '@abrova/ab-testing-nextjs/react';

function CheckoutButton() {
  const { variant, isLoading } = useVariant('checkout-button-style', 'control');

  if (isLoading) return <div>Loading...</div>;

  const buttonColor =
    variant === 'Variant A' ? '#22C55E' :
    variant === 'Variant B' ? '#3B82F6' : '#6B7280';

  return (
    <button style={{ backgroundColor: buttonColor }}>
      Checkout
    </button>
  );
}

useFeatureFlag

typescript
import { useFeatureFlag } from '@abrova/ab-testing-nextjs/react';

function CheckoutPage() {
  const { enabled, isLoading } = useFeatureFlag('new-checkout-flow');

  if (isLoading) return <LoadingSpinner />;

  return enabled ? <NewCheckoutFlow /> : <LegacyCheckout />;
}

useFeatureValue

typescript
import { useFeatureValue } from '@abrova/ab-testing-nextjs/react';

function Banner() {
  const { value, isLoading } = useFeatureValue('banner-text', 'Welcome!');

  if (isLoading) return null;

  return <div className="banner">{value as string}</div>;
}

useAbrovaAbTesting

Direct SDK access. It throws until the provider is ready (SDK initialized and user set), so only render components that use it after that, for example behind a hook's isLoading. Elsewhere, import AbrovaAbTesting directly as in the complete example below.

typescript
import { useAbrovaAbTesting } from '@abrova/ab-testing-nextjs/react';

function MyComponent() {
  const sdk = useAbrovaAbTesting();

  const handleClick = async () => {
    await sdk.trackClick('checkout-button-style');
  };

  return <button onClick={handleClick}>Buy Now</button>;
}

Getting Experiment Variants#

The variant always comes from the service (this needs a user token) and is cached for the user; the SDK never works one out in the browser. The assignment is sticky: the same user keeps the same variant.

getVariant and useVariant return the variant's name as it appears in Abrova Console (for example Control or Variant B), not its key (control, variant-b).

typescript
import { AbrovaAbTesting } from '@abrova/ab-testing-nextjs';

// Get variant
const variant = await AbrovaAbTesting.getVariant(
  'checkout-button-color',
  'control'  // returned while there is no user token, or when the service
             // cannot be reached and nothing is cached
);

// Use the variant
switch (variant) {
  case 'Variant A':
    buttonColor = '#22C55E';  // green
    break;
  case 'Variant B':
    buttonColor = '#3B82F6';  // blue
    break;
  default:
    buttonColor = '#6B7280';  // gray (control)
}

Getting Variant Configuration

typescript
const config = await AbrovaAbTesting.getVariantConfig('checkout-button-color');

const buttonText = config?.buttonText ?? 'Buy Now';
const buttonColor = config?.buttonColor ?? '#6B7280';

Listing Experiments

Reading the experiment list works with the API key alone.

typescript
const experiments = await AbrovaAbTesting.getExperiments();
experiments.forEach((exp) => {
  console.log(`${exp.key}: ${exp.variants.length} variants`);
});

Tracking Events#

Track user interactions to measure experiment results. The SDK supports 17 event types. Events are queued in the browser and sent in batches with the user's token; if no valid token is available yet, they stay queued and are sent once one is (see "Before your user signs in" in step 2 for which events are credited). The queue holds at most 1,000 events (the oldest are dropped first), and the SDK waits before retrying when the service is busy or your project has reached its usage limit. Each event carries the time your app recorded it, so events sent together keep their order.

Core Events

typescript
import { AbrovaAbTesting } from '@abrova/ab-testing-nextjs';

// Track view
await AbrovaAbTesting.trackView('checkout-button-color');

// Track click
await AbrovaAbTesting.trackClick('checkout-button-color');

// Track conversion with value
await AbrovaAbTesting.trackConversion('checkout-button-color', 300000);

// Track custom event
await AbrovaAbTesting.trackCustomEvent(
  'checkout-button-color',
  'add_to_cart',
  { product_id: 'SKU123', quantity: 2 }
);

Conversion Events

typescript
await AbrovaAbTesting.trackAddToCart('experiment-key', 500000, 'prod-123', {
  category: 'electronics',
});

await AbrovaAbTesting.trackPurchase('experiment-key', 1250000, 'txn-789', {
  currency: 'Toman',
  items: 3,
});

await AbrovaAbTesting.trackBeginCheckout('experiment-key', 1500000);
await AbrovaAbTesting.trackRemoveFromCart('experiment-key', 500000, 'prod-123');

Engagement Events

typescript
await AbrovaAbTesting.trackScroll('experiment-key', 75, { section: 'pricing' });
await AbrovaAbTesting.trackFormSubmit('experiment-key', 'checkout_form');
await AbrovaAbTesting.trackSearch('experiment-key', 'search query');
await AbrovaAbTesting.trackShare('experiment-key', 'twitter');

Auth & Media Events

typescript
await AbrovaAbTesting.trackSignUp('experiment-key', 'google');
await AbrovaAbTesting.trackLogin('experiment-key', 'email');
await AbrovaAbTesting.trackLogout('experiment-key');

await AbrovaAbTesting.trackVideoStart('experiment-key', 'video-id');
await AbrovaAbTesting.trackVideoComplete('experiment-key', 'video-id');

User Identification#

typescript
// Set user ID (persisted in localStorage)
await AbrovaAbTesting.setUserId('user-123');

// Get current user ID
const userId = await AbrovaAbTesting.getUserId();

// On sign-out: send the user's events, then drop their data
await AbrovaAbTesting.clearUserId();

// Set user attributes for targeting
await AbrovaAbTesting.setUserAttributes({
  plan: 'premium',
  country: 'US',
  age: 25,
  is_beta_user: true,
});

With a user token, setUserId is optional: the token says who the user is. If you call it, the user ID must match the user your tokenProvider returns a token for.

When a user signs out (clearUserId(), setUserToken(null)) or another signs in (setUserId with their ID, or a token for another user), the previous user's variants, attributes, token and unsent events are dropped before anything is sent for the next one. Events still waiting go out under the previous user's own token, never under another user's.

Feature Flags#

Evaluating a flag for a user (isFeatureEnabled, getFeatureValue) needs the user token. Listing flags (getFeatureFlags) works with the API key alone. While there is no token, or if evaluation fails, you get the default value you passed.

typescript
// Check if feature is enabled
const isEnabled = await AbrovaAbTesting.isFeatureEnabled('new-checkout-flow', false);

if (isEnabled) {
  showNewCheckout();
} else {
  showLegacyCheckout();
}

// Get feature value
const value = await AbrovaAbTesting.getFeatureValue('banner-text', 'Welcome');

// Get all feature flags
const flags = await AbrovaAbTesting.getFeatureFlags();
flags.forEach((flag) => {
  console.log(`${flag.key}: ${flag.enabled ? 'ON' : 'OFF'}`);
});

// Refresh flags from server (emits 'featureFlagsRefreshed')
await AbrovaAbTesting.refreshFeatureFlags();

Advanced Usage#

Event Listeners

typescript
const unsubscribe = AbrovaAbTesting.on('experimentAssigned', (event) => {
  const { experimentKey, variantKey } = event.data as { experimentKey: string; variantKey: string };
  console.log(`Assigned ${variantKey} for ${experimentKey}`);
});

AbrovaAbTesting.on('syncCompleted', (event) => {
  const { synced } = event.data as { synced: number };
  console.log(`Synced ${synced} events`);
});

// Also fires when the service rejects the user token (event.data.code says why)
AbrovaAbTesting.on('error', (event) => {
  console.error('SDK error:', (event.data as { message: string }).message);
});

// Cleanup
unsubscribe();

Available events: initialized, error, experimentAssigned, experimentsRefreshed, featureFlagsRefreshed, syncCompleted, awaitingUserToken (the SDK started waiting for a user token) and userTokenReady (a token arrived or the signed-in user changed: read variants again). (offlineMode and onlineMode are in the AbrovaAbTestingEventType type, but this SDK doesn't emit them.)

Manual Flush & Reset

typescript
// Flush events immediately
await AbrovaAbTesting.flush();

// Refresh experiments from server
await AbrovaAbTesting.refreshExperiments();

// Reset SDK: clears the user, token, cached assignments and queued events.
// Call init() again before using the SDK afterwards.
await AbrovaAbTesting.reset();

Flush on Page Hide

The SDK automatically sends queued events on visibilitychange (page hide). No additional setup needed.

Complete Example#

A complete Next.js App Router example with the Provider pattern:

typescript
// app/providers.tsx
'use client';

import { AbrovaAbTestingProvider } from '@abrova/ab-testing-nextjs/react';

async function fetchAbrovaToken(): Promise<string | null> {
  const res = await fetch('/api/abrova-token', { method: 'POST' });
  if (res.status === 401) return null; // no one signed in: the SDK waits
  if (!res.ok) throw new Error('Could not get an Abrova token');
  return (await res.json()).token;
}

export function Providers({
  userId,
  children,
}: {
  userId?: string;
  children: React.ReactNode;
}) {
  return (
    <AbrovaAbTestingProvider
      config={{
        apiKey: 'YOUR_API_KEY',
        tokenProvider: fetchAbrovaToken,
        debug: process.env.NODE_ENV === 'development',
      }}
      userId={userId}
    >
      {children}
    </AbrovaAbTestingProvider>
  );
}

// app/checkout/page.tsx
'use client';

import { useEffect } from 'react';
import {
  AbrovaAbTesting,
  useVariant,
  useFeatureFlag,
} from '@abrova/ab-testing-nextjs/react';

export default function CheckoutPage() {
  const { variant, isLoading } = useVariant('checkout-style', 'control');
  const { enabled: showBanner } = useFeatureFlag('promo-banner');

  useEffect(() => {
    // isLoading turns false once the SDK is ready and the user is set
    if (!isLoading) {
      AbrovaAbTesting.trackView('checkout-style');
    }
  }, [isLoading]);

  const handlePurchase = async (total: number) => {
    await AbrovaAbTesting.trackConversion('checkout-style', total);
    await AbrovaAbTesting.trackPurchase('checkout-style', total, 'txn-' + Date.now());
  };

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {showBanner && <div className="promo">Special Offer!</div>}

      <h1>Checkout ({variant})</h1>

      <button
        style={{
          backgroundColor: variant === 'Variant B' ? '#3B82F6' : '#6B7280',
        }}
        onClick={() => handlePurchase(1000000)}
      >
        Complete Purchase
      </button>
    </div>
  );
}

The token route is the app/api/abrova-token/route.ts handler from step 2.

Best Practices#

Mint tokens on your server

Only your backend holds the server secret. The browser calls your route and gets a short-lived user token back.

One token for every Abrova SDK

The same user token works for A/B Testing, Chat and Sync. Fetch it once and share it.

Follow sign-in and sign-out

Give the provider the same user your token route mints for and change it on login and logout, or leave `userId` out and call `refreshUserToken()`. Either way the last user's variants are cleared.

Browser only

Use this SDK in Client Components. For route handlers and Server Components, use @abrova/ab-testing-node with forUser().

TypeScript Types#

The SDK includes full TypeScript support with exported types:

typescript
import type {
  AbrovaAbTestingConfig,
  Experiment,
  Variant,
  Assignment,
  FeatureFlag,
  FlagVariant,
  AbrovaAbTestingEventType,
  AbrovaAbTestingEvent,
} from '@abrova/ab-testing-nextjs';
import { EventType } from '@abrova/ab-testing-nextjs';

interface AbrovaAbTestingConfig {
  apiKey: string;
  tokenProvider?: () => string | null | undefined | Promise<string | null | undefined>; // a user token minted by your server; null while no one is signed in
  userToken?: string;       // a token you already hold (tokenProvider is preferred)
  debug?: boolean;
  flushInterval?: number;   // ms between sends; leave it out to follow the console's Send interval
  maxQueueSize?: number;    // events kept while they cannot be sent (default and maximum: 1000)
}

interface Experiment {
  id: string;
  key: string;
  name: string;
  status: 'draft' | 'running' | 'paused' | 'completed' | 'archived';
  trafficAllocation: number;
  variants: Variant[];
}

interface FeatureFlag {
  key: string;
  enabled: boolean;
  rolloutPercentage: number;
  targetingRules?: Record<string, unknown>;
  variants?: FlagVariant[];
  defaultValue?: unknown;
}
Was this page helpful?
Report a problem