FabricFabricExperiments
Reference

Server SDK reference

Node / Edge / SSR SDK — assignment in middleware, server components, API routes, and Workers.

Package: @fabricorg/experiments-node

The server SDK is the assignment layer for code that runs before (or instead of) the browser: Next.js middleware, Server Components, API routes, edge functions, mobile-API backends, and worker queues. It mirrors @fabricorg/experiments-web in surface — same treatment() / payload() / metadata() / expose() API, same precedence rules, same preview-token verification — but persists sticky decisions through cookies on the request/response cycle instead of localStorage.

pnpm add @fabricorg/experiments-node

Runs on Node 22+, Cloudflare Workers, Vercel Edge, Deno, and Bun. No node:* imports in the main path; uses Web Crypto for everything.

Quickstart — Next.js middleware

The most common entry point. Read sticky from the request cookie, assign, write sticky back to the response. The cookie format is byte-compatible with the browser SDK so a server-side pick flows through to the client-side treatment() without re-bucketing.

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { NodeClient } from '@fabricorg/experiments-node';
import { forNextRequest, getOrCreateSubjectId } from '@fabricorg/experiments-node/next';

const client = new NodeClient({ manifestUrl: process.env.FX_MANIFEST_URL! });
await client.init();

export async function middleware(request: NextRequest) {
  const responseHeaders = new Headers();
  const ctx = forNextRequest(request, responseHeaders);
  const subjectId = getOrCreateSubjectId(request, responseHeaders);
  const r = await client.exposure('homepage-cta', subjectId, { ctx });

  const response = NextResponse.next();
  responseHeaders.forEach((v, k) => response.headers.append(k, v));
  if (r.variantKey) response.headers.set('x-fx-variant', r.variantKey);
  return response;
}

Quickstart — Cloudflare Workers / Vercel Edge / Deno

import { NodeClient } from '@fabricorg/experiments-node';
import { forFetchRequest } from '@fabricorg/experiments-node/edge';

let client: NodeClient | null = null;

export default {
  async fetch(request: Request, env: { FX_MANIFEST_URL: string }): Promise<Response> {
    if (!client) {
      client = new NodeClient({ manifestUrl: env.FX_MANIFEST_URL });
      await client.init();
    }
    const responseHeaders = new Headers();
    const ctx = forFetchRequest(request, responseHeaders);
    const r = await client.exposure('homepage-cta', subjectIdFrom(request), { ctx });
    return new Response(JSON.stringify({ variant: r.variantKey }), {
      headers: { 'content-type': 'application/json', ...Object.fromEntries(responseHeaders) },
    });
  },
  async scheduled(_event, env: { FX_MANIFEST_URL: string }) {
    // Cron-driven warmup so the next fetch() returns instantly.
    if (client) await client.refresh();
  },
};

Quickstart — Hono

import { Hono } from 'hono';
import { NodeClient } from '@fabricorg/experiments-node';
import { forHono } from '@fabricorg/experiments-node/hono';

const client = new NodeClient({ manifestUrl: env.FX_MANIFEST_URL });
await client.init();

const app = new Hono();
app.get('/', async (c) => {
  const ctx = forHono(c);
  const r = await client.exposure('homepage-cta', subjectIdFor(c.req), { ctx });
  return c.json({ variant: r.variantKey });
});

Quickstart — Express

import express from 'express';
import { NodeClient } from '@fabricorg/experiments-node';
import { forExpressRequest } from '@fabricorg/experiments-node/express';

const client = new NodeClient({ manifestUrl: process.env.FX_MANIFEST_URL! });
await client.init();

const app = express();
app.get('/', async (req, res) => {
  const ctx = forExpressRequest(req, res);
  const r = await client.exposure('homepage-cta', subjectIdFor(req), { ctx });
  res.send(`<h1>variant: ${r.variantKey}</h1>`);
});

Quickstart — bare Node HTTP

import { NodeClient, plainHeaders, nodeResponse } from '@fabricorg/experiments-node';
import { createServer } from 'node:http';

const client = new NodeClient({
  manifestUrl: 'https://manifest.example.com/v1/acme/manifest.json',
  recordExposure: (e) => warehouseClient.write(e),
});
await client.init();

createServer(async (req, res) => {
  const ctx = {
    headers: plainHeaders(req.headers),
    url: `http://${req.headers.host}${req.url}`,
    response: nodeResponse(res),
  };
  const r = await client.exposure('homepage-cta', subjectIdFor(req), { ctx });
  res.setHeader('content-type', 'text/html');
  res.end(`<h1>Variant: ${r.variantKey ?? 'control'}</h1>`);
}).listen(3000);

NodeClientOptions

Required

FieldTypeNotes
manifestUrlstringURL to the signed manifest JSON

Manifest cache

FieldTypeNotes
refreshIntervalMsnumberCache TTL in ms; default 30000. Reads beyond TTL trigger a background maybeRefresh() (stale-while-revalidate).
fetchImpltypeof fetchOverride the global fetch (proxies, custom retries)
onRefreshError(err) => voidSurface background refresh failures to your error tracker

Telemetry

FieldTypeNotes
recordExposure(record) => voidSink for exposure records
recordConversion(record) => voidSink for conversion records (used by client.track())

Sticky storage

FieldTypeNotes
storageServerStickyStoragecookieStorage() (default), headerStorage(), inMemoryStorage(), noopStorage(), or your own

Eligibility

FieldTypeNotes
excludedbool | (ctx) => boolTruthy → all treatment() returns null, no exposures
decisionAdapter(ctx) => string | nullOverride the bucketer; return null to fall through

Targeting context (for audience.rule evaluation) lives on the per-request RequestContext as ctx.user — see YAML targeting for the rule grammar. Set it on the context before calling treatment():

const ctx = forNextRequest(request, responseHeaders);
ctx.user = { tier, country, accountAgeDays };
const r = await client.exposure('pro-onboarding', subjectId, { ctx });

Preview tokens

FieldTypeNotes
previewJwksUrlstringPublic JWKS URL — derived from manifestUrl if omitted
previewJwksobjectInline JWKS for tests / pre-fetched scenarios

NodeClient API

class NodeClient {
  init(): Promise<void>;
  refresh(): Promise<void>;
  setManifest(m: Manifest): void;
  manifest(): Manifest | null;

  treatment(id: string, subjectId: string, ctx?: RequestContext): TreatmentResult;
  treatmentWithPreview(id: string, subjectId: string, ctx: RequestContext): Promise<TreatmentResult>;

  payload<T>(id: string, subjectId: string, ctx?: RequestContext): T | undefined;
  metadata<T>(id: string): T | undefined;

  exposure(id: string, subjectId: string, opts?: { tenantId?: string; ctx?: RequestContext }): Promise<TreatmentResult>;
  expose(id: string, subjectId: string, tenantId?: string): Promise<void>;

  track(eventName: string, subjectId: string, opts?: { value?: number; tenantId?: string }): Promise<void>;
  close(): void;
}
MethodNotes
treatmentSynchronous; never blocks on a network round-trip. Reads sticky from ctx.headers, writes new sticky to ctx.response.
treatmentWithPreviewAsync because preview JWKS may need to be fetched. Use this when the request URL might carry a ?fxpreview=&fxtoken= from Studio.
exposureConvenience: assign + record exposure. For experiments with manualExposure: true, defers the record — call expose() later.
exposeEmit a deferred exposure (manual exposure timing).
metadataPer-experiment metadata bag, independent of variant.

Sticky storage adapters

cookieStorage(options?) (default)

Reads sticky from the Cookie header, writes via Set-Cookie. Cookie format byte-compatible with the browser SDK's cookieStorage() so a server-side decision flows through to a later client-side call without re-bucketing.

import { cookieStorage } from '@fabricorg/experiments-node';

const client = new NodeClient({
  manifestUrl,
  storage: cookieStorage({
    cookiePrefix: 'acme.fx.',
    sameSite: 'lax',
    secure: true,        // default true
    httpOnly: false,     // default false — browser SDK needs to read these
    domain: '.acme.com', // optional; cross-subdomain stickiness
  }),
});

headerStorage(name)

Backend-to-backend sticky via a custom request/response header. Useful when callers aren't browsers (service mesh, gRPC-Gateway).

storage: headerStorage('x-fx-sticky'),
// Format: "expId1=variant1,expId2=variant2"

inMemoryStorage()

Per-process map keyed by ctx.user.__subjectId__. Only useful for tests or single-tenant servers where you control the subject id.

noopStorage()

Every treatment() re-buckets. Pair with your own DB-backed sticky if you persist assignments elsewhere.

Decision precedence

Same as the browser SDK:

  1. Signed ?fxpreview= URL token (only when using treatmentWithPreview)
  2. Manifest divertTo
  3. Sticky storage (subject was already assigned)
  4. Test-level sampleRate exclusion
  5. decisionAdapter (skipped if returns null / unknown key / throws)
  6. Built-in murmur3 bucketing
  7. Per-variant recipeSampleRate gate

Examples

new NodeClient({
  manifestUrl,
  excluded: (ctx) => {
    const ua = ctx?.headers.get('user-agent') ?? '';
    return /bot|crawler|spider|googlebot/i.test(ua);
  },
});

Force variants for QA

new NodeClient({
  manifestUrl,
  decisionAdapter: ({ experimentId, ctx }) => {
    const isQa = ctx?.headers.get('x-acme-role') === 'qa';
    return isQa ? 'control' : null;
  },
});

Manual exposure (assign during render, log when it actually mattered)

const r = await client.exposure('checkout-flow', subjectId, { ctx });
// ... render the variant ...
// later, after the user submits:
await client.expose('checkout-flow', subjectId);

Custom storage backed by your DB

import type { ServerStickyStorage } from '@fabricorg/experiments-node';

const dbStorage: ServerStickyStorage = {
  read(ctx) {
    const subjectId = String(ctx.user?.id);
    return new Map(db.getStickyAssignments(subjectId));
  },
  write(ctx, expId, variantKey) {
    const subjectId = String(ctx.user?.id);
    void db.setStickyAssignment(subjectId, expId, variantKey);
  },
};

Edge runtime notes

  • No setInterval — manifest cache is stale-while-revalidate. First call after TTL fires off a background fetch() that doesn't block the caller.
  • Use client.refresh() from a Cloudflare Worker scheduled() handler if you want predictable freshness without serving any stale reads.
  • HTTP ETag / If-None-Match is honored automatically.
  • Web Crypto only — no node:crypto imports anywhere in the main path.

Cross-SDK sticky compatibility

The default cookie format (fx.<expId>=<variantKey>) is byte-identical between @fabricorg/experiments-node and @fabricorg/experiments-web. A pattern that just works:

  1. Next.js middleware runs client.exposure() server-side. SDK reads no cookie → buckets the user → writes Set-Cookie: fx.homepage-cta=….
  2. Browser receives the cookie, runs the tag-loader.
  3. Browser SDK's cookieStorage() reads the same cookie → returns sticky → no re-bucketing, no flicker.
  4. Both SDKs share the same exposure record (deduped by your warehouse on (experiment_id, subject_id, variant_key)).

Validating

pnpm --filter @fabricorg/experiments-node test
pnpm --filter @fabricorg/experiments-node type-check

See also

On this page