Vortos
Feature Flags

Frontend Integration

@vortos/flags — React provider, hooks, payloads, variants, targeting context, exposure tracking, and enterprise-safe refresh behavior.

Frontend Integration

@vortos/flags gives React applications a local, observable copy of the current user's feature flag state. The backend evaluates rollout rules; the frontend reads the result from React context.

Backend evaluates.
Frontend remembers.
Components ask locally.
Backend can change rollout without redeploying frontend.

Feature flags answer: should this product feature be visible or enabled right now?

Permissions answer: is this user allowed to perform this action?

Use both when a feature requires rollout and authorization:

import { useFlag } from '@vortos/flags';
import { usePermission } from '@vortos/permissions';

const rolledOut = useFlag('analytics-tab');
const allowed = usePermission('analytics.view.any');

return rolledOut && allowed ? <AnalyticsNav /> : null;

Installation

npm install @vortos/flags

Basic Setup

Wrap the application once near the router:

src/App.tsx
import { FeatureFlagProvider } from '@vortos/flags';

export function App() {
  return (
    <FeatureFlagProvider endpoint="/api/flags">
      <Router />
    </FeatureFlagProvider>
  );
}

The endpoint is configurable. /api/flags is only the default convention.

<FeatureFlagProvider endpoint="/internal/frontend/flags">
  <App />
</FeatureFlagProvider>

Any route is valid if it returns the expected contract.

Response Contract

The minimal response contains enabled flags:

{
  "flags": ["new-dashboard", "analytics-tab"]
}

Variants support experiments and gradual UI changes:

{
  "flags": ["new-checkout"],
  "variants": {
    "checkout-layout": "variant-b"
  }
}

Payloads carry remote configuration:

{
  "flags": ["new-dashboard"],
  "variants": {
    "checkout-layout": "variant-b"
  },
  "payloads": {
    "new-dashboard": {
      "maxWidgets": 8,
      "layout": "compact"
    }
  },
  "version": "flags_2026_05_04_001"
}

Use booleans for rollout, variants for A/B choices, and payloads for small product configuration values. Do not put secrets in payloads because they are delivered to the browser.

Simple Hooks

The original simple APIs remain backward-compatible:

import { FeatureFlag, useFlag, useVariant } from '@vortos/flags';

const enabled = useFlag('new-dashboard');
const variant = useVariant('checkout-layout', 'control');

Use FeatureFlag for simple rendering:

<FeatureFlag name="new-checkout" fallback={<OldCheckout />}>
  <NewCheckout />
</FeatureFlag>

Stateful Hooks

Large applications need to show loading, stale, refreshing, and error states. Use useFlagState() when the component needs operational state:

import { useFlagState } from '@vortos/flags';

function DashboardEntry() {
  const { enabled, loading, error, stale, refetch } = useFlagState('new-dashboard');

  if (loading) return <DashboardSkeleton />;
  if (error) return <RetryPanel error={error} onRetry={refetch} />;

  return enabled ? <NewDashboard stale={stale} /> : <OldDashboard />;
}

Use useFlagContext() for global diagnostics:

import { useFlagContext } from '@vortos/flags';

function FlagDebugPanel() {
  const { flags, variants, payloads, version, refreshing, refetch } = useFlagContext();

  return (
    <section>
      <p>Version: {version ?? 'unknown'}</p>
      <p>Flags: {flags.length}</p>
      <button disabled={refreshing} onClick={() => refetch()}>
        Refresh flags
      </button>
    </section>
  );
}

Payloads

Payloads are useful when a feature needs configuration, not only on/off state:

import { useFlagPayload } from '@vortos/flags';

type DashboardPayload = {
  maxWidgets: number;
  layout: 'compact' | 'comfortable';
};

const config = useFlagPayload<DashboardPayload>('new-dashboard', {
  maxWidgets: 4,
  layout: 'comfortable',
});

return <Dashboard maxWidgets={config.maxWidgets} layout={config.layout} />;

Payloads should be small, public, and safe to cache. They are not a replacement for backend authorization or private configuration.

Variants

Use variants for experiments and cohort-specific UI:

const variant = useVariant('checkout-layout', 'control');

if (variant === 'variant-b') return <CheckoutB />;
return <CheckoutA />;

Validate allowed variants when the app only supports known values:

const variant = useVariant('checkout-layout', {
  default: 'control',
  allowed: ['control', 'variant-a', 'variant-b'],
});

If the backend returns an unknown variant, the hook returns the configured default. This prevents a backend typo from breaking the frontend render path.

For full state plus exposure tracking:

const { variant, loading, error, trackExposure } = useVariantState(
  'checkout-layout',
  {
    default: 'control',
    allowed: ['control', 'variant-a', 'variant-b'],
    trackExposure: true,
  }
);

Exposure Tracking

A/B tests are only useful when the system records what the user actually saw. @vortos/flags can report exposures in two ways.

Use a callback:

<FeatureFlagProvider
  endpoint="/api/flags"
  onExposure={(event) => {
    analytics.track('flag.exposure', event);
  }}
>
  <App />
</FeatureFlagProvider>

Or post exposure events to an endpoint:

<FeatureFlagProvider
  endpoint="/api/flags"
  exposureEndpoint="/api/flags/exposures"
>
  <App />
</FeatureFlagProvider>

Exposure events are deduplicated per flag and variant during a provider lifecycle:

{
  "name": "checkout-layout",
  "variant": "variant-b",
  "timestamp": 1777837079000
}

Track exposure only when the user actually sees the feature or variant. Do not record exposure during prefetches, hidden tabs, or unused route branches.

Targeting Context

The backend can evaluate flags differently per user, role, tenant, country, plan, or custom attributes. Pass targeting context to the provider:

<FeatureFlagProvider
  endpoint="/api/flags"
  context={{
    userId,
    tenantId,
    role,
    country,
    plan: 'enterprise',
    attributes: {
      federationId,
      betaGroup: 'coaches',
    },
  }}
>
  <App />
</FeatureFlagProvider>

The provider sends this context as a request header named X-Vortos-Flag-Context by default. Change the header name if your gateway or backend expects another convention:

<FeatureFlagProvider
  endpoint="/api/flags"
  context={flagContext}
  contextHeaderName="X-App-Flag-Context"
>
  <App />
</FeatureFlagProvider>

The context is part of the provider's refetch identity. If the user, tenant, role, or plan changes, the provider refetches flags.

Auth Headers

Headers are also part of the provider's refetch identity:

<FeatureFlagProvider
  endpoint="/api/flags"
  headers={{ Authorization: `Bearer ${token}` }}
>
  <Router />
</FeatureFlagProvider>

This matters after login, logout, tenant switch, token refresh, or role changes. The browser should not keep using flag state evaluated for a previous identity.

Refreshing And Staleness

Feature flag state is remote configuration. It should be cached locally, but the cache state must be observable, refreshable, and safely stale.

<FeatureFlagProvider
  endpoint="/api/flags"
  staleTime={30_000}
  refreshInterval={60_000}
  refetchOnWindowFocus
>
  <Router />
</FeatureFlagProvider>
PropWhat it does
staleTimeMarks data stale after the configured number of milliseconds
refreshIntervalPolls the endpoint on an interval
refetchOnWindowFocusRefetches when the browser window becomes active and data is stale
refetchManual refresh function exposed by stateful hooks

Polling is the right first step for most apps. SSE or WebSockets only become necessary when product operators expect open screens to react immediately to flag changes.

Request Safety

The provider cancels in-flight requests with AbortController when endpoint, headers, targeting context, or component lifecycle changes. That prevents an older request from overwriting newer flag state.

Retries are optional:

<FeatureFlagProvider
  endpoint="/api/flags"
  retries={2}
  retryDelayMs={500}
>
  <Router />
</FeatureFlagProvider>

The retry schedule is linear:

try immediately
retry after 500ms
retry after 1000ms
then fail

Session Cache

Use session cache to speed up app boot while a fresh request runs:

<FeatureFlagProvider
  endpoint="/api/flags"
  persist
  cacheKey={`flags:${userId}:${tenantId}`}
>
  <Router />
</FeatureFlagProvider>

Always include identity and tenant information in the cache key:

flags:${userId}:${tenantId}

Without this, one user's cached flags can appear briefly after another user logs in on the same browser.

SSR And Initial Data

Server-rendered apps can pass initial flags to avoid first-paint flicker:

<FeatureFlagProvider
  initialFlags={serverFlags}
  initialVariants={serverVariants}
  initialPayloads={serverPayloads}
  initialVersion={serverFlagVersion}
>
  <App />
</FeatureFlagProvider>

The provider still refetches on the client after mount, so server data is only the initial state.

Observability

Use onError and onUpdate to connect flag state to logging and analytics:

<FeatureFlagProvider
  endpoint="/api/flags"
  onError={(error) => logger.capture(error)}
  onUpdate={(state) => {
    analytics.track('flags.updated', {
      count: state.flags.length,
      version: state.version,
      stale: state.stale,
    });
  }}
>
  <Router />
</FeatureFlagProvider>

This is useful in enterprise systems because flag behavior often depends on tenant, plan, region, and rollout percentage.

<FeatureFlagProvider
  endpoint="/api/flags"
  headers={{ Authorization: `Bearer ${token}` }}
  context={{ userId, tenantId, role, plan }}
  staleTime={30_000}
  refreshInterval={60_000}
  refetchOnWindowFocus
  retries={2}
  retryDelayMs={500}
  persist
  cacheKey={`flags:${userId}:${tenantId}`}
  exposureEndpoint="/api/flags/exposures"
  onError={(error) => logger.capture(error)}
>
  <Router />
</FeatureFlagProvider>

This gives you local reads, observable cache state, targeting-aware refreshes, request cancellation, retry behavior, exposure tracking, and safe tenant-aware cache persistence.

On this page