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-nodeRuns 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
| Field | Type | Notes |
|---|---|---|
manifestUrl | string | URL to the signed manifest JSON |
Manifest cache
| Field | Type | Notes |
|---|---|---|
refreshIntervalMs | number | Cache TTL in ms; default 30000. Reads beyond TTL trigger a background maybeRefresh() (stale-while-revalidate). |
fetchImpl | typeof fetch | Override the global fetch (proxies, custom retries) |
onRefreshError | (err) => void | Surface background refresh failures to your error tracker |
Telemetry
| Field | Type | Notes |
|---|---|---|
recordExposure | (record) => void | Sink for exposure records |
recordConversion | (record) => void | Sink for conversion records (used by client.track()) |
Sticky storage
| Field | Type | Notes |
|---|---|---|
storage | ServerStickyStorage | cookieStorage() (default), headerStorage(), inMemoryStorage(), noopStorage(), or your own |
Eligibility
| Field | Type | Notes |
|---|---|---|
excluded | bool | (ctx) => bool | Truthy → all treatment() returns null, no exposures |
decisionAdapter | (ctx) => string | null | Override 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
| Field | Type | Notes |
|---|---|---|
previewJwksUrl | string | Public JWKS URL — derived from manifestUrl if omitted |
previewJwks | object | Inline 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;
}| Method | Notes |
|---|---|
treatment | Synchronous; never blocks on a network round-trip. Reads sticky from ctx.headers, writes new sticky to ctx.response. |
treatmentWithPreview | Async because preview JWKS may need to be fetched. Use this when the request URL might carry a ?fxpreview=&fxtoken= from Studio. |
exposure | Convenience: assign + record exposure. For experiments with manualExposure: true, defers the record — call expose() later. |
expose | Emit a deferred exposure (manual exposure timing). |
metadata | Per-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:
- Signed
?fxpreview=URL token (only when usingtreatmentWithPreview) - Manifest
divertTo - Sticky storage (subject was already assigned)
- Test-level
sampleRateexclusion decisionAdapter(skipped if returns null / unknown key / throws)- Built-in murmur3 bucketing
- Per-variant
recipeSampleRategate
Examples
Bot exclusion / consent gating
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 backgroundfetch()that doesn't block the caller. - Use
client.refresh()from a Cloudflare Workerscheduled()handler if you want predictable freshness without serving any stale reads. - HTTP
ETag/If-None-Matchis honored automatically. - Web Crypto only — no
node:cryptoimports 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:
- Next.js middleware runs
client.exposure()server-side. SDK reads no cookie → buckets the user → writesSet-Cookie: fx.homepage-cta=…. - Browser receives the cookie, runs the tag-loader.
- Browser SDK's
cookieStorage()reads the same cookie → returnssticky→ no re-bucketing, no flicker. - 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-checkSee also
- Web SDK reference — same surface, browser-side.
- Web adapters — Snowplow / GA / GTM exposure sinks.
- YAML schema — what the server SDK reads from the manifest.