Documentation menu

Local flag evaluation and SSR bootstrap

Use @saaspro/node/local-flags when server request handlers need synchronous flag answers. It downloads a bounded rule snapshot with a Management API token, then uses the same pure evaluator as the remote endpoint. Existing browser and Node flags.load() APIs remain available.

Credentials and scope

Create a personal Management API token with read scope. The current member role needs flags.read and people.pii.read; rules and identity overrides can contain personal data. The Flags module must be enabled. Store the token in your server secret manager. Organization tokens use the fixed developer role, which lacks PII access, so they cannot download snapshots. Public and secret ingest keys cannot read snapshots.

GET /api/v1/apps/{appId}/flags/snapshot?env=production returns version 1 with an exact organization, application and environment, SHA-256 content revision, numeric Unix-millisecond timestamps, environment-specific rule configuration and active public-key hashes. It excludes archived flags, other environments and rule descriptions. Responses are private and no-store. Never serialize a snapshot or Management token into HTML, logs or a client bundle.

Each snapshot allows 200 flags, 200 public keys, 1,000 identity overrides per flag and 512,000 encoded bytes. Existing rule limits also apply. Oversized configurations receive HTTP 413; use remote evaluation or reduce the configuration. A revision changes when a flag definition, audience, JSON configuration or active public key changes; refreshed timestamps do not change it.

Node startup and request evaluation

npm install @saaspro/node
import { LocalFlags } from "@saaspro/node/local-flags";

const flags = new LocalFlags({
  token: process.env.SPM_MANAGEMENT_TOKEN!,
  host: "https://control.example.com",
  appId: process.env.SPM_APP_ID!, // exact application ID from setup
  environment: "production",
  fallback: { "new-onboarding": false },
  onError: message => console.warn(message),
});
await flags.refresh(); // false on refusal, invalid snapshot or network failure

// Supply a separate context for every authenticated request.
const context = {
  distinctId: authenticatedUser.id,
  properties: { plan: authenticatedUser.plan },
};
const result = flags.evaluate(context);
const showOnboarding = result.values["new-onboarding"] ?? false;
console.log(result.source, result.revision);

// During process shutdown:
flags.close();

Evaluation returns values, source, revision, expiresAt and remoteOnlyKeys. get(key, context, fallback) reads one value. isEnabled(key, context, fallback) matches existing SDK semantics: boolean true and nonempty variant strings other than "false" are enabled. No per-user results are cached. Contexts accept bounded JSON properties and IDs up to 200 characters; invalid contexts use fallback.

The shared evaluator preserves rule order, overrides, environment switches, MurmurHash rollout buckets and multivariate assignments. Property paths read only own properties, never JavaScript prototypes. Supply the same distinct/anonymous IDs and properties as remote evaluation to obtain the same results. Local evaluation does not record remote evaluation counters or experiment exposures; use the existing experiment exposure API at the point of use.

Refresh and outages

  • fresh: the server-issued snapshot is under 30 seconds old.
  • stale: opt-in only. maxStaleMs extends use after the fresh deadline, up to 270,000 ms. The server-issued five-minute expiry is always a hard stop.
  • fallback: startup without a snapshot, invalid context, expired cache, closed client or no permitted stale window. Your explicit fallback values are returned; revision and expiry are null.

The default refresh interval is 20 seconds; configure 1–30 seconds or zero to disable the periodic timer. Call refresh() at startup. Evaluations without a fresh snapshot also request a background refresh, throttled to at most once per configured interval (20 seconds when periodic refresh is disabled). Concurrent refresh calls share one request. Timer handles do not keep Node alive. A request has a five-second timeout by default, configurable from 100 ms to 30 seconds; redirects are rejected and response size is bounded.

HTTP 401, 403 and 404 clear the cached snapshot. Transient failures preserve it only within the explicitly allowed freshness window. Content revision, version, scope and timestamps are checked on every download. The cache uses elapsed monotonic time to prevent clock rollback from extending its lifetime. close() clears the cache, cancels refresh and stops timers. Keep your supplied fetch implementation abort-aware.

Offline caches cannot receive immediate key, permission or rule revocation. The default maximum cache lifetime is 30 seconds; choosing stale use accepts a maximum five-minute delay. Prefer remote evaluation where this delay is unacceptable. Feature flags are presentation and release controls, never a substitute for server authorization.

Generate only browser-safe answers

const bootstrap = flags.bootstrap(context, {
  publicKey: process.env.NEXT_PUBLIC_SPM_KEY!,
  keys: ["new-onboarding"],
  consent: consentFromAuthenticatedRequest,
});

This returns null unless the snapshot is fresh, consent is granted, an identity exists and the supplied public key belongs to this application and environment and is active in that snapshot. It includes only selected evaluated values, application/environment/key/host, identity, context hash and a deadline no later than the snapshot's 30-second fresh deadline. No rule, targeting property or Management credential is included. Identity itself can be personal data; do not expose someone else's payload.

Use the same authenticated identity and anonymous ID as the initialized browser SDK. If the browser has both IDs, the server context must include both. Establish IDs in your own authenticated request/session flow; do not treat a client-supplied identity or consent cookie as authorization. A new anonymous browser without an established shared ID should use remote evaluation until identity is known. Do not change browser identity just to make an old payload match.

Optional browser store

import { spm } from "@saaspro/browser";
import { createBootstrapFlags } from "@saaspro/browser/bootstrap";

// Initialize spm and establish its current identity first.
const store = createBootstrapFlags(spm, {
  appId, environment: "production", key: publicKey, host,
  context, consent: "granted", keys: ["new-onboarding"], bootstrap,
});
await store.ready;
const unsubscribe = store.subscribe(() => render(store.getSnapshot()));
render(store.getSnapshot());
await store.refresh(); // explicit refresh is optional
// When properties change without an SDK identify/reset:
store.setContext(nextContext);
await store.refresh();
// On teardown:
unsubscribe();
store.destroy();

The optional store requires granted, persistent browser consent, matching current IDs, host and key. It checks scope, expiry, selected keys and the complete context SHA-256 synchronously before applying bootstrap values. Unknown versions and mismatches fail closed. The small SHA-256 implementation is bundled only into this optional entry. Consent revoke, opt-out, identity changes and reset clear values and abort pending requests; late responses cannot restore them. Current state includes source: "bootstrap" | "remote" | "fallback" and expiresAt.

Values expire after at most 30 seconds. Expiry clears them and requests fresh remote values; network failure leaves the empty fallback. Explicit refresh is singleflight and bounded to five seconds and 32 KiB. Browser refresh uses the public evaluate endpoint, so application allowed origins still apply. It includes the supplied current properties and accepts only the selected flag keys. The original SDK remote flag cache is independent. No full rule evaluator enters this browser entry or the core script.

React server rendering

import { SaaSProMaxProvider } from "@saaspro/react";
import {
  FlagBootstrapProvider,
  useBootstrapFlag,
} from "@saaspro/react/flags-bootstrap";

function Onboarding() {
  const enabled = useBootstrapFlag("new-onboarding", false);
  return enabled ? <NewOnboarding /> : <CurrentOnboarding />;
}

// Render inside your app with request-specific, server-authorized data.
<SaaSProMaxProvider client={initializedBrowserClient}>
  <FlagBootstrapProvider
    bootstrap={bootstrap}
    scope={{ appId, environment: "production", key: publicKey, host,
      context, consent: requestConsent }}
    keys={["new-onboarding"]}
  >
    <Onboarding />
  </FlagBootstrapProvider>
</SaaSProMaxProvider>

The optional provider renders the server payload for the matching identity/environment and granted server consent. Initial HTML checks the complete context hash and explicit key allowlist as well. Create the payload from the same authorized request context. Browser activation rechecks the current SDK identity, persistent consent and context hash before exposing active values. Props must change when request identity, consent or properties change. useBootstrapFlags() exposes the source and expiry; existing useFlag hooks still use the original remote cache.

Every property in scope.context is serialized to the browser and sent on remote refresh: include only browser-safe data. Use server-only evaluation for secrets or targeting properties that must stay private; do not pass those properties to this provider. Keep personalized HTML private and uncached. Pass data through your framework's escaped serialization; never interpolate JSON directly into a script tag. Reuse one request context for both flags.bootstrap() and provider scope. Server rendering cannot discover consent changed in another browser tab; do not render personalized values until consent is established for that request. If client identity is not ready at hydration, fallback is expected until remote evaluation can be requested with the current context.

Verify the integration

Open Flags → Local evaluation for exact environment configuration. Check that await flags.refresh() is true and evaluate().source is fresh. Compare the result with the remote endpoint for the same IDs/properties. Change an environment rule and refresh: the revision and affected answer should change. Check a staging key cannot bootstrap production answers. Finally revoke consent or change browser identity and verify the optional store immediately returns fallback.

Cohort targets and JSON configuration

Open a flag's Delivery tab to select up to ten existing People segments, attach JSON payloads or schedule rollout steps. Cohort audiences match any selected segment, before overrides and rules. Membership is resolved from stored distinct or anonymous identities, including live dynamic definitions and static members. Supplied properties cannot grant membership. People segments span the application; they are not environment-specific. Unknown identities, deleted segments and a disabled People module do not match. These flags remain presentation controls, never authorization.

Snapshots omit cohort-dependent rule definitions and expose only remoteOnly: [{key, reason: "cohort"}]. Other flags remain locally evaluable. A synchronous local result returns the configured fallback for remote-only keys, or omits them when no fallback exists; remoteOnlyKeys identifies those exceptions even when snapshot source is fresh. bootstrap() excludes these keys rather than bootstrapping fallback values. Existing version 1 consumers safely omit them. Refresh boundaries still apply after an audience edit; use remote resolution when the maximum offline delay is unacceptable.

const result = await flags.resolve(context, {
  keys: ["beta-checkout", "new-onboarding"],
  configKeys: ["beta-checkout"], // optional JSON payload selection
});
if (result.source === "remote") {
  const variant = result.values["beta-checkout"];
  const configuration = result.payloads["beta-checkout"];
}
const bootstrap = await flags.bootstrapResolved(context, {
  publicKey, keys: ["beta-checkout"], consent: "granted",
});

resolve() uses the same Management token against POST /api/v1/apps/{appId}/flags/resolve?env=. It returns fresh selected answers and optional payloads, or explicit source: "fallback" with caller defaults and no payloads on failure. It shares identical in-flight requests, permits at most four distinct concurrent resolutions, uses the configured request timeout, bounds responses to 100,000 bytes and caches no identity results. Select at most 50 flag keys and five configuration keys. Permission refusals clear the local snapshot too. close() aborts outstanding resolutions. No local or server resolution call records experiment exposure; continue using the experiment API separately.

bootstrapResolved() resolves all requested keys fresh on the server and verifies the supplied public key against the current application/environment. It uses the existing browser/React bootstrap format and context checks, with a maximum 30-second deadline. It never includes JSON payloads, targeting rules or cohort membership. Request consent and identity remain the host application's responsibility; never bootstrap a privileged identity chosen by a browser parameter.

A configuration contains payloads, a JSON object keyed by "true"/"false" for booleans or variant keys/"false" for multivariate flags, and an optional schema. Missing payloads are omitted; JSON null remains a valid payload. The schema subset supports a required type, properties, required, additionalProperties, items, enum, minimum, maximum, minLength, maxLength, minItems and maxItems. References, patterns and unknown schema keywords are rejected. The complete configuration is capped at 16 KiB, eight JSON nesting levels and 2,000 nodes. Every supplied payload is validated on save. These values are readable with public ingest keys: use only public application configuration, never credentials or personal data.

Browser integrations can add configKeys: ["beta-checkout"] to a normal consented POST /api/v1/flags/evaluate request. The existing flags response stays boolean/string, with selected JSON in a separate payloads object. Configuration is not automatically cached by the core SDK and is not part of SSR bootstrap. Apply the same consent, identity-change and request-cancellation rules as other personalized requests.

Scheduled rollouts

A plan has one environment, one to ten explicitly timed steps, and a reason. Steps set its enabled state and environment rollout percentage; they preserve variants, rules and identity overrides. The UI shows device-local input with an exact UTC preview, and history always shows UTC. The server accepts only ISO timestamps with explicit offsets. The first step and each subsequent step must be at least one minute ahead, and the plan must finish within 90 days.

Only one plan may be pending for a flag. Creation, cancellation and audience/configuration edits require flags.write, CSRF, same-origin checks and a passkey verified within ten minutes. The worker rechecks the creating member's active account, current membership/permission and module/environment availability. Its expected revision advances only after its own successful step. Any other definition edit, toggle, archive or configuration change stops the plan at its next due step with a visible failure. A step more than ten minutes overdue fails instead of changing production unexpectedly. Canceling leaves applied steps in place and prevents remaining steps.

Plans and next-step jobs persist in Postgres. A minute-based recovery job checks up to 100 due plans, including jobs stranded by a worker crash. Row locks serialize duplicate deliveries and cancellation; applied changes, history, audit and next-step state commit atomically. Database outages retry through the queue/recovery path; operational refusal appears in the plan history. The remote flag cache converges within ten seconds across processes, default local caches within 30 seconds, and optional stale local caches within five minutes. No new schema or mutation is shared with randomized experiments; experiment design, assignments and exposures remain independent. Group targets use current customer-group membership and group-stable rollout. See Customer groups for the selector, properties and compatibility contract.