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 |
requireSignedManifest | boolean | Defaults to true. Disable only for isolated tests using trusted fixtures. |
manifestJwksUrl | string | Public manifest-verification JWKS URL; derived from manifestUrl by default. |
manifestJwks | object | Inline manifest-verification JWKS for tests or preloaded keys. |
manifestRevocationsUrl | string | Revoked-version record URL; derived from manifestUrl by default. |
manifestRevocations | { versions: number[] } | Inline revocation record for tests or preloaded trust metadata. |
maxManifestAgeMs | number | Optional replay window measured from the signed builtAt timestamp. |
now | () => Date | Test clock used for deterministic exposure and conversion timestamps. |
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; // trusted direct injection; no delivery verification
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;
dimensions?: Record<string, string>;
}): 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.