# Feature flags without the flicker

> Client-side flags render the control experience first and swap it a beat later. Evaluate on the server, pass the decision down, and keep a bootstrap for the client hooks.

You ship a flagged redesign of the pricing page. It works. But every visitor
sees the old hero for a few hundred milliseconds, then it snaps to the new one.
On a slow connection it is a full second. Your experiment results come back
noisy, someone files "the page flashes", and a CLS regression shows up in your
Core Web Vitals.

Nothing is broken. This is what client-side flag evaluation looks like.

## Why the flicker exists

The browser SDK cannot know a flag's value until it has asked. The sequence is:

1. HTML arrives and renders: the flag is unknown, so your code takes the
   fallback branch (the control).
2. The SDK loads.
3. The SDK requests flag values for this distinct id.
4. The response arrives, the hook re-renders, the variant appears.

Steps 2-4 are a network round trip, often behind other work on the main thread.
Anything rendered from a flag in that window is wrong, and the user watches it
being corrected.

The naive fix (hide the content until flags load) trades a flicker for a blank
region, which is worse for both perceived speed and layout stability.

## The right way: decide on the server

The server can evaluate the flag before a single byte of HTML is written. Then
the page renders once, correctly.

```tsx
// src/app/(marketing)/pricing/page.tsx
import { cookies } from "next/headers";
import { serverFeatureFlag } from "@/lib/analytics/posthog-server";
import { PricingV1 } from "@/components/pricing-v1";
import { PricingV2 } from "@/components/pricing-v2";

export default async function PricingPage() {
  const distinctId = await stableDistinctId();
  const variant = await serverFeatureFlag("pricing-redesign", distinctId);

  return variant === "test" ? <PricingV2 /> : <PricingV1 />;
}
```

`serverFeatureFlag` wraps `posthog-node`'s `evaluateFlags` and, importantly,
never throws:

```ts
export async function serverFeatureFlag(
  flag: string,
  distinctId: string,
  options?: { groups?: Record<string, string> },
): Promise<boolean | string | undefined> {
  const posthog = client();
  if (!posthog) return undefined;

  try {
    // posthog-node 5: evaluateFlags replaces the deprecated getFeatureFlag.
    const flags = await posthog.evaluateFlags(distinctId, {
      flagKeys: [flag],
      groups: options?.groups,
    });
    return flags.getFlag(flag);
  } catch (error) {
    console.warn(`[analytics] flag "${flag}" could not be evaluated:`, error);
    return undefined; // fall back to control; a flag service outage is not a page outage
  }
}
```

## The part everyone gets wrong: a stable distinct id on the server

A flag's value is a hash of the flag key and the distinct id. Generate a fresh
id per request and every visitor gets a fresh coin flip: the same person sees
the variant, then the control, then the variant. Experiment results become
meaningless, and worse, they look plausible.

For a signed-in user, use the database id you already identify with. For an
anonymous visitor, read PostHog's own cookie, which the browser SDK sets and
which persists across requests:

```ts
// src/lib/analytics/distinct-id.ts
import { cookies } from "next/headers";

export async function stableDistinctId(): Promise<string> {
  const jar = await cookies();
  const key = process.env.NEXT_PUBLIC_POSTHOG_KEY ?? "";
  const raw = jar.get(`ph_${key}_posthog`)?.value;

  if (raw) {
    try {
      const parsed = JSON.parse(decodeURIComponent(raw)) as { distinct_id?: string };
      if (parsed.distinct_id) return parsed.distinct_id;
    } catch {
      // Malformed cookie: fall through to the anonymous bucket.
    }
  }

  // First visit, before the SDK has ever run. Everyone in this state shares one
  // bucket, which is honest: they are not yet a tracked person.
  return "anonymous";
}
```

Signed-in pages should skip that entirely and pass `user.id`, which is stable by
construction.

## Local evaluation, so a flag is not a network call per render

Every `evaluateFlags` call is an HTTP request to PostHog unless the SDK can
evaluate locally. With a personal API key, `posthog-node` downloads flag
definitions and evaluates in-process:

```ts
new PostHog(key, {
  host,
  personalApiKey: process.env.POSTHOG_API_KEY,
  featureFlagsPollingInterval: 60_000,
});
```

Two caveats worth knowing before you enable it. Local evaluation cannot resolve
flags that depend on person properties the server does not have, so pass them
explicitly:

```ts
const flags = await posthog.evaluateFlags(distinctId, {
  flagKeys: ["pricing-redesign"],
  personProperties: { plan: user.plan },
  onlyEvaluateLocally: false, // allow a remote call when local cannot decide
});
flags.getFlag("pricing-redesign");
```

And a personal API key is powerful: scope it read-only, keep it server-side,
and never let it near `NEXT_PUBLIC_`.

## Keeping the client hooks flicker-free too

Server evaluation solves the first paint. Interactive components that use
`useFeatureFlagEnabled()` still start from nothing. Bootstrap the browser SDK
with the values you already resolved on the server:

```tsx
// server component
const flags = {
  "pricing-redesign": (await serverFeatureFlag("pricing-redesign", distinctId)) ?? false,
};

return <AnalyticsProvider bootstrapFlags={flags} distinctId={distinctId}>{children}</AnalyticsProvider>;
```

```tsx
// inside the provider, at init
posthog.init(key, {
  api_host: "/ingest",
  bootstrap: { distinctID: distinctId, featureFlags: bootstrapFlags },
});
```

The hooks now return the right value on their very first render, and the SDK
refreshes them in the background.

## Rendering, caching and flags

A page that reads a flag per visitor cannot be a static page. Either accept that
it renders dynamically, or move the decision to the edge and vary the cache key
on the bucket, never leave it static and hope. A cached HTML response
containing one variant served to everyone is the failure mode that makes an
experiment report a clean, confident, completely fabricated result.

If the flagged surface is small, the cheapest correct answer is often to keep
the page static and render only the flagged component dynamically inside a
`<Suspense>` boundary.

## Cleaning up

A flag that has been at 100% for a month is not a flag, it is a dead branch and
a permanent network call. When you remove one:

1. Delete the branch that will never run, not just the condition.
2. Remove the flag from the bootstrap object.
3. Archive the flag in PostHog rather than deleting it, so historical
   experiment data keeps its meaning.

## Verifying

- Disable JavaScript and load the page. You should get the correct variant in
  the HTML: proof the decision happened on the server.
- Reload ten times. The variant must not change. If it does, your distinct id is
  not stable.
- Throttle to Slow 3G and watch the first paint. No swap, no blank region.
- Check your experiment's exposure events: one `$feature_flag_called` per person
  per variant, not a stream of alternating values.

---

Agentic Boilerplate: A Next.js repo your agent already knows. $99 once. Lifetime access and updates.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
