Use feature flags with server-side rendering in Next.js

Evaluate an Amplitude feature flag on the server with the Experiment Node server SDK, give the server the same device ID the browser uses, and render the variant in the first HTML, either from a Server Component or from a client component bootstrapped with initialVariants. Exposures go out only after the variant is on screen.

  • Next.js 13+
  • App Router
  • Experiment Node SDK 1.x
  • Experiment JS SDK 1.x
  • Local evaluation
  • 25 min

Chris Tam · Last updated 25 September 2026

When a feature flag resolves after the page loads, users see the old version flash first. Evaluating it on the server puts the right variant in the first HTML.

In this tutorial, you'll evaluate flags per request, match the server's user to the browser's, render the variant from a Server Component or a client component, and send exposures only when seen. It builds on Add feature flags and experiments to a Next.js app.

What you'll build

A /pricing page that evaluates pricing-page-v2 per request with local evaluation and has the on or current table in the server HTML. Path A renders it from a Server Component and sends one $exposure event from the browser. Path B renders an interactive PricingTable client component that hydrates without a mismatch and sends one exposure through the Experiment SDK after it mounts. Either way, by the end you'll see [Experiment] Exposure in the user's event stream, on the same device ID and user ID the server evaluated, with no loading state on the page.

Prerequisites

  • The Install tutorial finished, with pricing-page-v2 Active and exposures arriving from the browser.
  • A server-side session helper that returns the signed-in user's ID in Server Components. The code imports it as getSession from @/lib/auth; swap in your own.
  • Permission to edit the flag and create deployments in the same Amplitude project.

No Amplitude account yet? A free account is enough for this tutorial; nothing here needs a paid plan. Sign up for free to get your project API key.

1. Move the flag to local evaluation on a server deployment

Server SDKs evaluate in one of two modes. Remote evaluation calls Amplitude's evaluation servers for each user, with Amplitude ID resolution, user enrichment, and sticky bucketing. Local evaluation downloads the flag configurations once and evaluates in your process, with no network call per user. Server rendering puts evaluation on the critical path of every page, so this tutorial uses local evaluation; the remote alternative is at the end of step 3.

Create a server deployment:

  1. Go to Experiment > Deployments, then click Create Deployment.
  2. Choose the same project as your web deployment, name it after the service, for example nextjs-server, and choose Server-side.
  3. Click Create a Deployment and copy the key. Server deployment keys are secret.

Then move the flag:

  1. Deactivate pricing-page-v2.
  2. In the Advanced Settings section of the Configure tab, change Evaluation Mode to Local.
  3. Change bucketed by from Amplitude ID to Device ID in each targeted user segment and in the All Other Users section.
  4. Add the nextjs-server deployment to the flag, then reactivate it.

Why. Migrating from remote to local changes bucketing, so some users switch variants. Do it before an experiment collects data, or create a new local flag instead. Bucketing by device ID is also why step 4 matters: if the server and the browser disagree on the device ID, they disagree on the variant.

Local evaluation doesn't support Amplitude ID resolution, user enrichment, or sticky bucketing. Cohort targeting works in the Node SDK when you add cohortSyncConfig with your analytics API key and secret key, and local evaluation cohorts target user IDs only. If a flag needs historical user properties, keep it remote and use the alternative in step 3.

2. Install the server SDK and keep its key server-only

Install the Experiment Node.js SDK and the server-only package:

Terminal
npm install @amplitude/experiment-node-server server-only

Add the server deployment key below the Install tutorial's two keys:

.env.local
AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-key

No NEXT_PUBLIC_ prefix. Next.js only inlines NEXT_PUBLIC_ variables into browser bundles, so this key stays on the server as long as nothing client-side imports the module that reads it. The server-only package enforces that: importing it from a client component fails the build.

3. Start one client per server process

The local evaluation client downloads flag configurations on start() and polls for updates every 30 seconds by default. Create it once per process and evaluate against it on every request. Both paths below call into this file.

lib/experiment-server.ts
import 'server-only';
import { cache } from 'react';
import { Experiment } from '@amplitude/experiment-node-server';
import { getExperimentUser } from '@/lib/experiment-user';

type LocalClient = ReturnType<typeof Experiment.initializeLocal>;
type Variants = ReturnType<LocalClient['evaluateV2']>;

// Next.js re-evaluates modules on every edit in development. Keeping the
// client on globalThis stops each reload from starting another poller.
const globalForExperiment = globalThis as typeof globalThis & {
  __ampExperimentClient?: LocalClient;
  __ampExperimentReady?: Promise<void>;
};

function getClient() {
  if (!globalForExperiment.__ampExperimentClient) {
    const key = process.env.AMPLITUDE_SERVER_DEPLOYMENT_KEY;
    if (!key) throw new Error('AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set');

    const client = Experiment.initializeLocal(key);
    globalForExperiment.__ampExperimentClient = client;
    globalForExperiment.__ampExperimentReady = client.start().catch((error) => {
      // Let the next request try again instead of failing forever.
      globalForExperiment.__ampExperimentClient = undefined;
      throw error;
    });
  }
  return {
    client: globalForExperiment.__ampExperimentClient,
    ready: globalForExperiment.__ampExperimentReady!,
  };
}

// Evaluates the given flags for the user making this request.
export async function evaluateForCurrentUser(flagKeys: string[]): Promise<Variants> {
  const user = await getExperimentUser();
  if (!user.user_id && !user.device_id) return {};

  try {
    const { client, ready } = getClient();
    await ready;
    return client.evaluateV2(user, flagKeys);
  } catch (error) {
    console.error('Experiment evaluation failed; rendering the default.', error);
    return {};
  }
}

// One flag, memoized for the length of a single request, so a page and the
// components inside it that ask for the same flag share one evaluation.
export const getVariant = cache(async (flagKey: string) => {
  const variants = await evaluateForCurrentUser([flagKey]);
  return variants[flagKey];
});

Why. The client is cached for the life of the process; the result never is. Flag configurations are the same for everyone and refresh on their own, so caching them is the point of local evaluation. A variant belongs to one user, so it's computed per request (the Node SDK measures a single evaluation at about 0.025 ms) and React's cache() only shares it within one render. Serverless instances run start() again on each cold start.

For updates in under a second instead of every 30 seconds, pass { streamUpdates: true } to initializeLocal. The SDK falls back to polling if the stream fails.

Remote evaluation instead

If the flag needs Amplitude ID resolution, user enrichment, sticky bucketing, or cohort targeting on anonymous visitors (local evaluation cohorts target user IDs only), keep it in Remote mode on the server deployment and swap the client. The Node SDK's defaults (a 10-second timeout and 8 retries) are too high for a request path, so set them down as the docs recommend:

lib/experiment-server.ts (remote alternative for getClient and evaluation)
const client = Experiment.initializeRemote(key, {
  fetchTimeoutMillis: 500,
  fetchRetries: 1,
  fetchRetryBackoffMinMillis: 0,
  fetchRetryTimeoutMillis: 500,
});

// per request, instead of evaluateV2:
const variants = await client.fetchV2(user, { flagKeys });

Remote evaluation adds a network round trip to each render, about 36 ms on a CDN cache hit and about 194 ms on a miss, averaged globally, and it tracks [Experiment] Assignment automatically on cache misses.

4. Give the server the browser's device ID

The browser SDK stores its device ID in a cookie named AMP_ plus the first ten characters of your API key. The Node SDK's AmplitudeCookie class reads that cookie on the server. On a first visit it doesn't exist yet, and a Server Component can't set cookies, so middleware creates a device ID, hands it to both the server render and the browser, and the browser SDK adopts it.

lib/amplitude-device.ts
// A short-lived cookie that carries a server-generated device ID to the
// browser SDK on a visitor's first request, before the AMP_ cookie exists.
export const HANDOFF_COOKIE = 'amp_ssr_device_id';

export function takeHandoffDeviceId(): string | undefined {
  if (typeof document === 'undefined') return undefined;
  const entry = document.cookie
    .split('; ')
    .find((part) => part.startsWith(`${HANDOFF_COOKIE}=`));
  if (!entry) return undefined;
  // Delete it once adopted, so a later reset() isn't overridden on reload.
  document.cookie = `${HANDOFF_COOKIE}=; Max-Age=0; Path=/`;
  return decodeURIComponent(entry.slice(HANDOFF_COOKIE.length + 1));
}
middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { HANDOFF_COOKIE } from '@/lib/amplitude-device';

const AMP_COOKIE = `AMP_${(process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY ?? '').slice(0, 10)}`;

export function middleware(request: NextRequest) {
  if (request.cookies.has(AMP_COOKIE) || request.cookies.has(HANDOFF_COOKIE)) {
    return NextResponse.next();
  }

  const deviceId = crypto.randomUUID();

  // Visible to the Server Components rendering this same request.
  request.cookies.set(HANDOFF_COOKIE, deviceId);
  const response = NextResponse.next({ request: { headers: request.headers } });

  // Visible to the browser, where lib/amplitude.ts adopts it.
  response.cookies.set(HANDOFF_COOKIE, deviceId, {
    path: '/',
    maxAge: 60 * 60,
    sameSite: 'lax',
  });
  return response;
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
lib/experiment-user.ts
import 'server-only';
import { cache } from 'react';
import { cookies } from 'next/headers';
import { AmplitudeCookie } from '@amplitude/experiment-node-server';
import { HANDOFF_COOKIE } from '@/lib/amplitude-device';
import { toAmplitudeUserId } from '@/lib/amplitude-ids';
import { getSession } from '@/lib/auth'; // your auth helper

export type ExperimentUser = { user_id?: string; device_id?: string };

export const getExperimentUser = cache(async (): Promise<ExperimentUser> => {
  const cookieStore = await cookies();

  // `true` selects the Browser SDK 2 cookie format, which unified uses.
  const ampName = AmplitudeCookie.cookieName(process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY!, true);
  const ampValue = cookieStore.get(ampName)?.value;

  let deviceId: string | undefined;
  if (ampValue) {
    try {
      deviceId = AmplitudeCookie.parse(ampValue, true).device_id ?? undefined;
    } catch {
      deviceId = undefined;
    }
  }
  deviceId ??= cookieStore.get(HANDOFF_COOKIE)?.value;

  const session = await getSession();
  return {
    device_id: deviceId,
    // The same function the browser uses, so both sides send one ID.
    user_id: session?.user?.id ? toAmplitudeUserId(session.user.id) : undefined,
  };
});

Finally, have the browser SDK adopt the handoff ID. In lib/amplitude.ts from the Install, replace initAmplitude with this version and add the import at the top; everything else in that file stays the same. (Path B later replaces the whole file with a version that keeps this handoff.)

lib/amplitude.ts (replace initAmplitude and add this import)
import { takeHandoffDeviceId } from '@/lib/amplitude-device';

export function initAmplitude() {
  if (started || typeof window === 'undefined') return;
  started = true;

  const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) {
    console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
    notifyFlagsChanged();
    return;
  }

  const deviceId = takeHandoffDeviceId();

  ready = Promise.resolve(amplitude.initAll(apiKey, {
    analytics: {
      autocapture: true,
      // Only on a first visit; otherwise the SDK keeps the ID in its cookie.
      ...(deviceId ? { deviceId } : {}),
    },
    experiment: {
      deploymentKey: process.env.NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
      fetchTimeoutMillis: 3000,
    },
  }));

  void refetchFlags();
}

Why. deviceId in the init config takes precedence over the value the SDK would generate, so the first page view, the exposure, and the server's evaluation all carry one device ID. The middleware runs on the Edge runtime and doesn't import the Node SDK; it only needs the documented cookie name. await cookies() works in Next.js 15, where cookies() is async, and in 13 and 14, where awaiting the plain return value is harmless.

5. Choose where the variant renders

The server can now evaluate pricing-page-v2 for the same user the browser tracks. What's left is getting that variant onto the page and sending one exposure when the user sees it. Pick the path that fits the component that shows the variant.

Use Path A (step 6) when the flagged UI is static and can live in a Server Component. The page calls getVariant, branches on it, and a small client component sends $exposure after render. Only the routes that read the flag become dynamic.

Use Path B (step 7) when the flagged UI is interactive, typically because it has state or event handlers, and has to be a client component that shows the variant at first paint. The root layout evaluates the flags and passes them to the Experiment JavaScript SDK as initialVariants, and the component sends its exposure through the SDK after it mounts. The costs: reading cookies in the root layout makes every route under it dynamic, and sign-in and sign-out end with a full page load.

You can use both in one app on different flags; Server Components can keep using getVariant while client components read bootstrapped flags. Give each flag one exposure path, since running both sends two exposures per view.

6. Path A: render the variant in a Server Component

Evaluate per request in the page

Call getVariant in the page and branch on it. Reading cookies makes the route dynamic, so Next.js renders it for each request, which is what a per-user value needs.

app/pricing/page.tsx
import { getVariant } from '@/lib/experiment-server';
import { getExperimentUser } from '@/lib/experiment-user';
import { ExposureTracker } from '@/components/ExposureTracker';

export default async function PricingPage() {
  const [variant, user] = await Promise.all([
    getVariant('pricing-page-v2'),
    getExperimentUser(),
  ]);
  const showNewTable = variant?.value === 'on';

  return (
    <main>
      <h1>Pricing</h1>
      {showNewTable ? (
        <section>{/* the new pricing table */}New pricing</section>
      ) : (
        <section>{/* the current pricing table */}Current pricing</section>
      )}
      <ExposureTracker
        flagKey="pricing-page-v2"
        variant={variant?.value}
        userId={user.user_id}
      />
    </main>
  );
}

This page replaces the Install's client-side PricingTable on /pricing. Don't render both: the client component would call variant() and send a second exposure from a remote evaluation.

Don't add export const dynamic = 'force-static' or export const revalidate to this route, and don't wrap getVariant in unstable_cache. Each of those stores the first visitor's variant and serves it to everyone after. Run npm run build and check that /pricing is listed as dynamic.

Send the exposure from the browser, after render

The Node SDK can track exposures itself: set exposureConfig: { apiKey } on initializeLocal and pass { tracksExposure: true } as the third argument, evaluateV2(user, flagKeys, options). Don't, for a page like this. That exposure fires when the server evaluates, which includes a <Link prefetch> rendering the page before the user clicks, crawlers, and responses that never finish loading. Send it from the browser once the variant has rendered.

components/ExposureTracker.tsx
'use client';

import { useEffect } from 'react';
import { amplitude, initAmplitude } from '@/lib/amplitude';

type Props = { flagKey: string; variant?: string; userId?: string };

export function ExposureTracker({ flagKey, variant, userId }: Props) {
  useEffect(() => {
    // An exposure with no variant removes the user from the experiment,
    // so users outside the rollout send nothing.
    if (!variant) return;

    initAmplitude();
    // The server evaluated with this user ID; make sure the event carries it
    // even if your identify component hasn't run yet.
    if (userId) amplitude.setUserId(userId);
    amplitude.track('$exposure', { flag_key: flagKey, variant });
  }, [flagKey, variant, userId]);

  return null;
}

Why. $exposure with flag_key and variant is the event the Experiment SDKs send. Amplitude renames it to [Experiment] Exposure and sets the [Experiment] pricing-page-v2 user property, which is what experiment analysis reads. Use this event rather than one you name yourself: docs warn that custom exposure events can be ingested before the user property is set, so they don't count in analysis. The effect runs after React commits the branch the user sees, and it carries the same device ID and user ID the server used, because step 4 aligned them.

A flag has one variant, on, and users outside the rollout get none, so only on users send exposures, which is right for a rollout. For an A/B test, create an Experiment instead of a flag: it comes with control and treatment, both real variants, and both groups send exposures.

7. Path B: bootstrap a client component

Evaluate client flags once, in the root layout

The browser SDK takes initialVariants once, when it initializes, and the Install tutorial initializes it in the AnalyticsProvider in app/layout.tsx. So the variants have to be evaluated there too. Layouts don't re-render on client-side navigation, which suits a value that's read once. See bootstrapping in the Experiment JavaScript SDK docs.

lib/flags.ts
import 'server-only';
import { cache } from 'react';
import { evaluateForCurrentUser } from '@/lib/experiment-server';
import type { BootstrappedVariants } from '@/lib/amplitude';

// Only flags that client components render. Everything listed here is
// serialized into the page, where anyone can read it in the page source.
export const CLIENT_FLAG_KEYS = ['pricing-page-v2'];

export const getClientVariants = cache(async (): Promise<BootstrappedVariants> => {
  const variants = await evaluateForCurrentUser(CLIENT_FLAG_KEYS);
  const bootstrapped: BootstrappedVariants = {};
  for (const [flagKey, variant] of Object.entries(variants)) {
    // Plain objects only; they cross the server-to-client boundary as props.
    if (variant?.value) bootstrapped[flagKey] = { value: variant.value, payload: variant.payload };
  }
  return bootstrapped;
});
app/layout.tsx
import { AnalyticsProvider } from '@/components/AnalyticsProvider';
import { getClientVariants } from '@/lib/flags';
import { getExperimentUser } from '@/lib/experiment-user';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const [initialVariants, user] = await Promise.all([
    getClientVariants(),
    getExperimentUser(),
  ]);

  return (
    <html lang="en">
      <body>
        <AnalyticsProvider initialVariants={initialVariants} userId={user.user_id}>
          {children}
        </AnalyticsProvider>
      </body>
    </html>
  );
}

Why. Reading cookies in the root layout makes every route under it dynamic, including pages that never read a flag. That's the price of bootstrapping at init. If only a few pages need flags at first paint and the rest should stay static, use Path A on those pages instead.

Bootstrap the SDK with initialVariants

initialVariants seeds the SDK's store with the server's result. source: Source.InitialVariants makes the SDK prefer those values over variants it saved from an earlier visit, which it otherwise reads first. The experiment block of initAll accepts the Experiment JavaScript SDK's options.

Terminal
npm install @amplitude/experiment-js-client

This replaces the whole of lib/amplitude.ts, including the step 4 initAmplitude. What changed: initAmplitude takes initialVariants and userId, and the BootstrappedVariants type is new. The handoff device ID from step 4 stays.

lib/amplitude.ts
import * as amplitude from '@amplitude/unified';
import { Source } from '@amplitude/experiment-js-client';
import { takeHandoffDeviceId } from '@/lib/amplitude-device';

export type BootstrappedVariants = Record<string, { value?: string; payload?: unknown }>;

type InitOptions = {
  initialVariants?: BootstrappedVariants;
  userId?: string;
};

let started = false;
let flagsVersion = 0;
let fetchGeneration = 0;
let ready: Promise<unknown> = Promise.resolve();
const listeners = new Set<() => void>();

function notifyFlagsChanged() {
  flagsVersion += 1;
  listeners.forEach((listener) => listener());
}

async function refetchFlags() {
  const generation = ++fetchGeneration;
  try {
    // Still fetches flags you didn't bootstrap. Docs don't say whether a
    // later fetch replaces bootstrapped values, so render bootstrapped
    // flags from context (next section), not from the SDK.
    await ready; // initAll is typed void, but the Next.js guide awaits it
    await amplitude.experiment()?.fetch();
  } finally {
    if (generation === fetchGeneration) notifyFlagsChanged();
  }
}

export function initAmplitude({ initialVariants, userId }: InitOptions = {}) {
  if (started || typeof window === 'undefined') return;
  started = true;

  const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) {
    console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
    notifyFlagsChanged();
    return;
  }

  const deviceId = takeHandoffDeviceId();

  ready = Promise.resolve(amplitude.initAll(apiKey, {
    analytics: {
      autocapture: true,
      ...(deviceId ? { deviceId } : {}),
      // The user the server evaluated, set before the first event.
      ...(userId ? { userId } : {}),
    },
    experiment: {
      deploymentKey: process.env.NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
      fetchTimeoutMillis: 3000,
      ...(initialVariants
        ? { initialVariants, source: Source.InitialVariants }
        : {}),
    },
  }));

  void refetchFlags();
}

export function identifyUser(userId: string) {
  initAmplitude();
  amplitude.setUserId(userId);
  void refetchFlags();
}

export function resetUser() {
  initAmplitude();
  amplitude.reset();
  amplitude.experiment()?.clear();
  void refetchFlags();
}

export function subscribeFlags(listener: () => void) {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
}

export function getFlagsVersion() {
  return flagsVersion;
}

export function whenAmplitudeReady() {
  return ready;
}

export { amplitude };

Why. Passing userId at init, rather than waiting for the Install's IdentifyUser effect, means the first exposure already carries the user the server evaluated. Without it, an exposure can fire from a child component's effect before IdentifyUser runs, and lands on the device alone.

Render from the bootstrapped object, not from the SDK

Put the same initialVariants object in React context and read the variant from context during render. The server render and the browser's first render then read identical data, so the HTML and the hydrated tree match.

components/AnalyticsProvider.tsx
'use client';

import { createContext, useContext } from 'react';
import { initAmplitude, type BootstrappedVariants } from '@/lib/amplitude';

const VariantsContext = createContext<BootstrappedVariants>({});

type Props = {
  initialVariants: BootstrappedVariants;
  userId?: string;
  children: React.ReactNode;
};

export function AnalyticsProvider({ initialVariants, userId, children }: Props) {
  // Guarded: a no-op on the server and after the first call. Runs during
  // render so the SDK exists before any child effect asks for it.
  initAmplitude({ initialVariants, userId });

  return <VariantsContext.Provider value={initialVariants}>{children}</VariantsContext.Provider>;
}

export function useBootstrappedVariants() {
  return useContext(VariantsContext);
}

Why. Calling amplitude.experiment()?.variant() during render fails three ways. On the server the SDK doesn't exist, so the HTML gets the default. In the browser it tracks an exposure on every render, twice under Strict Mode. And its value can differ from the HTML, which is a hydration error.

Fire the exposure after the variant mounts

Read the variant from context, and send the exposure through the SDK in an effect of the component that renders it. exposure() tracks the SDK's current variant for that key, which Source.InitialVariants keeps equal to what the server rendered.

hooks/useVariant.ts
'use client';

import { useEffect } from 'react';
import { amplitude, whenAmplitudeReady } from '@/lib/amplitude';
import { useBootstrappedVariants } from '@/components/AnalyticsProvider';

export function useVariant(flagKey: string) {
  const variant = useBootstrappedVariants()[flagKey];
  const value = variant?.value;

  useEffect(() => {
    // No variant means the user isn't in the rollout; send nothing.
    if (!value) return;
    void whenAmplitudeReady().then(() => amplitude.experiment()?.exposure(flagKey));
  }, [flagKey, value]);

  return variant;
}
components/PricingTable.tsx
'use client';

import { useState } from 'react';
import { useVariant } from '@/hooks/useVariant';

export function PricingTable() {
  const variant = useVariant('pricing-page-v2');
  const [annual, setAnnual] = useState(false);

  return (
    <section>
      <label>
        <input type="checkbox" checked={annual} onChange={(e) => setAnnual(e.target.checked)} />
        Bill annually
      </label>
      {variant?.value === 'on' ? (
        <div>{/* the new pricing table */}New pricing ({annual ? 'annual' : 'monthly'})</div>
      ) : (
        <div>{/* the current pricing table */}Current pricing ({annual ? 'annual' : 'monthly'})</div>
      )}
    </section>
  );
}
app/pricing/page.tsx
import { PricingTable } from '@/components/PricingTable';

export default function PricingPage() {
  return (
    <main>
      <h1>Pricing</h1>
      <PricingTable />
    </main>
  );
}

This app/pricing/page.tsx replaces the Path A version, including its ExposureTracker. One flag gets one exposure path; running both sends two exposures per view.

If the component mounts hidden, in a closed tab or below a long page, move the exposure() call to the moment it becomes visible, for example in an IntersectionObserver callback. The rule is the same as in the Install: the exposure goes where the user sees the variant, not where the code first reads it.

End sign-in and sign-out with a full page load

The SDK reads initialVariants once. After a sign-in, router.refresh() re-renders the layout and puts the new user's variants in context, but the SDK keeps the old ones in front, so exposure() reports the previous user's variant. A full page load re-evaluates on the server and initializes the SDK again with the right user.

lib/auth-navigation.ts
'use client';

import { resetUser } from '@/lib/amplitude';

// Call after your sign-in succeeds.
export function afterSignIn(redirectTo = '/') {
  window.location.assign(redirectTo);
}

// Call from your sign-out handler, after your auth clears the session.
export function afterSignOut(redirectTo = '/') {
  resetUser(); // new device ID, cleared variants
  window.location.assign(redirectTo);
}

Why. resetUser() gives the browser a new device ID and clears stored variants, and the full load means the server evaluates the signed-out visitor with the AMP_ cookie the SDK just rewrote. Keep the Install's IdentifyUser mounted; with userId already set at init, it only matters for sign-ins that don't navigate.

Verify it worked

For Path A, run npm run dev. For Path B, run npm run build && npm start so you're testing a production render. Open /pricing in a private window signed in as a test user, then check in this order:

  1. Page source. View the page source: it contains either New pricing or Current pricing, with no loading placeholder. On Path B, searching for pricing-page-v2 also finds the serialized initialVariants.
  2. Cookies. In DevTools, the Application tab shows amp_ssr_device_id on the first response; note its value. After the page loads it's gone, replaced by an AMP_ cookie.
  3. Server logs. To watch the flag configurations load, temporarily pass { logLevel: LogLevel.Debug } to initializeLocal, importing LogLevel from @amplitude/experiment-node-server, and watch the terminal.
  4. Throttled reload (Path B). In DevTools, set the Network tab to a slow preset and reload. The table appears in its final variant with the HTML, with no placeholder and no swap.
  5. Console. Add logLevel: amplitude.Types.LogLevel.Debug to the analytics block and you see one $exposure event with flag_key pricing-page-v2 after the page renders. On Path B there's also no hydration mismatch warning.
  6. Network tab. Filter for api2.amplitude.com and look for a POST to /2/httpapi returning 200 after the page renders. The bodies are gzip-compressed, which is why step 5 uses the console.
  7. User lookup. Go to Users > User Profiles and search by the device ID you noted or your user- prefixed user ID. Within ten seconds to a minute the event stream shows [Experiment] Exposure with [Experiment] Flag Key set to pricing-page-v2 and [Experiment] Variant set to on, and the profile carries both IDs. On Path B the exposure is attached to that user from the first event of the session.

Troubleshooting

Build error: "server-only" cannot be imported from a Client Component

A client component imports lib/experiment-server.ts, lib/experiment-user.ts, or lib/flags.ts. Pass the variant down as a prop instead; the server key must never reach a client bundle.

The server never returns a variant

The flag isn't in Local mode, isn't on the nextjs-server deployment, or isn't Active. A client deployment key in AMPLITUDE_SERVER_DEPLOYMENT_KEY also returns nothing; server-side local evaluation needs the server key. Check the terminal for a start() error.

The exposure lands on a different device ID than the server used

The browser SDK initialized before it saw the handoff cookie, or the middleware matcher skips this route. Confirm amp_ssr_device_id is set on the first response and that initAmplitude calls takeHandoffDeviceId as in step 4.

Anonymous visitors get a new device ID on every load

Something calls reset() on each page load. Only your sign-out handler should call resetUser().

Pages got slower after switching to remote

The Node SDK's default timeout is 10 seconds with 8 retries. Use the lower values in step 3, or move the flag back to local evaluation.

Every visitor gets the same variant (Path A)

The route is cached or static. Remove force-static, revalidate, and any unstable_cache around evaluation, then rebuild and confirm /pricing is dynamic.

Hydration error: text content does not match server-rendered HTML (Path B)

Something reads the flag from the SDK during render, usually a direct experiment()?.variant() call. Read bootstrapped flags with useVariant. The Install's useFlag doesn't cause this error, but it brings back the loading flash for a bootstrapped flag.

Returning visitors see one variant in the HTML and exposures report another (Path B)

source: Source.InitialVariants isn't set, so the SDK prefers variants saved in the browser from a previous visit. Add it next to initialVariants.

Source is undefined, or the import fails (Path B)

@amplitude/experiment-js-client isn't installed as a direct dependency. Install it; @amplitude/unified uses it internally.

After sign-in, exposures carry the previous user's variant (Path B)

The sign-in used router.push or router.refresh(). End it with afterSignIn so the page fully reloads.

The variant is in the server HTML but useVariant returns undefined (Path B)

The key isn't in CLIENT_FLAG_KEYS, or the user isn't in the rollout. Only listed keys are bootstrapped.

No page is static anymore (Path B)

That's the cost of bootstrapping in the root layout, which reads cookies. Bootstrap only if first-paint variants in client components are worth it; otherwise use Path A and evaluate per page on the server.

Full code reference

Every file this tutorial adds or changes, in one block: the shared setup first, then each path. Files from the Install tutorial that aren't listed stay as they are.

16 files, 425 lines
// -- .env.local (added) --
AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-key

// -- lib/experiment-server.ts --
import 'server-only';
import { cache } from 'react';
import { Experiment } from '@amplitude/experiment-node-server';
import { getExperimentUser } from '@/lib/experiment-user';

type LocalClient = ReturnType<typeof Experiment.initializeLocal>;
type Variants = ReturnType<LocalClient['evaluateV2']>;

const globalForExperiment = globalThis as typeof globalThis & {
  __ampExperimentClient?: LocalClient;
  __ampExperimentReady?: Promise<void>;
};

function getClient() {
  if (!globalForExperiment.__ampExperimentClient) {
    const key = process.env.AMPLITUDE_SERVER_DEPLOYMENT_KEY;
    if (!key) throw new Error('AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set');

    const client = Experiment.initializeLocal(key);
    globalForExperiment.__ampExperimentClient = client;
    globalForExperiment.__ampExperimentReady = client.start().catch((error) => {
      globalForExperiment.__ampExperimentClient = undefined;
      throw error;
    });
  }
  return {
    client: globalForExperiment.__ampExperimentClient,
    ready: globalForExperiment.__ampExperimentReady!,
  };
}

export async function evaluateForCurrentUser(flagKeys: string[]): Promise<Variants> {
  const user = await getExperimentUser();
  if (!user.user_id && !user.device_id) return {};

  try {
    const { client, ready } = getClient();
    await ready;
    return client.evaluateV2(user, flagKeys);
  } catch (error) {
    console.error('Experiment evaluation failed; rendering the default.', error);
    return {};
  }
}

export const getVariant = cache(async (flagKey: string) => {
  const variants = await evaluateForCurrentUser([flagKey]);
  return variants[flagKey];
});

// -- lib/amplitude-device.ts --
export const HANDOFF_COOKIE = 'amp_ssr_device_id';

export function takeHandoffDeviceId(): string | undefined {
  if (typeof document === 'undefined') return undefined;
  const entry = document.cookie
    .split('; ')
    .find((part) => part.startsWith(`${HANDOFF_COOKIE}=`));
  if (!entry) return undefined;
  document.cookie = `${HANDOFF_COOKIE}=; Max-Age=0; Path=/`;
  return decodeURIComponent(entry.slice(HANDOFF_COOKIE.length + 1));
}

// -- middleware.ts --
import { NextResponse, type NextRequest } from 'next/server';
import { HANDOFF_COOKIE } from '@/lib/amplitude-device';

const AMP_COOKIE = `AMP_${(process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY ?? '').slice(0, 10)}`;

export function middleware(request: NextRequest) {
  if (request.cookies.has(AMP_COOKIE) || request.cookies.has(HANDOFF_COOKIE)) {
    return NextResponse.next();
  }

  const deviceId = crypto.randomUUID();
  request.cookies.set(HANDOFF_COOKIE, deviceId);
  const response = NextResponse.next({ request: { headers: request.headers } });
  response.cookies.set(HANDOFF_COOKIE, deviceId, {
    path: '/',
    maxAge: 60 * 60,
    sameSite: 'lax',
  });
  return response;
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

// -- lib/experiment-user.ts --
import 'server-only';
import { cache } from 'react';
import { cookies } from 'next/headers';
import { AmplitudeCookie } from '@amplitude/experiment-node-server';
import { HANDOFF_COOKIE } from '@/lib/amplitude-device';
import { toAmplitudeUserId } from '@/lib/amplitude-ids';
import { getSession } from '@/lib/auth';

export type ExperimentUser = { user_id?: string; device_id?: string };

export const getExperimentUser = cache(async (): Promise<ExperimentUser> => {
  const cookieStore = await cookies();
  const ampName = AmplitudeCookie.cookieName(process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY!, true);
  const ampValue = cookieStore.get(ampName)?.value;

  let deviceId: string | undefined;
  if (ampValue) {
    try {
      deviceId = AmplitudeCookie.parse(ampValue, true).device_id ?? undefined;
    } catch {
      deviceId = undefined;
    }
  }
  deviceId ??= cookieStore.get(HANDOFF_COOKIE)?.value;

  const session = await getSession();
  return {
    device_id: deviceId,
    user_id: session?.user?.id ? toAmplitudeUserId(session.user.id) : undefined,
  };
});

// -- lib/amplitude.ts (Path A: replace initAmplitude, add the import; rest unchanged) --
import { takeHandoffDeviceId } from '@/lib/amplitude-device';

export function initAmplitude() {
  if (started || typeof window === 'undefined') return;
  started = true;

  const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) {
    console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
    notifyFlagsChanged();
    return;
  }

  const deviceId = takeHandoffDeviceId();

  ready = Promise.resolve(amplitude.initAll(apiKey, {
    analytics: {
      autocapture: true,
      ...(deviceId ? { deviceId } : {}),
    },
    experiment: {
      deploymentKey: process.env.NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
      fetchTimeoutMillis: 3000,
    },
  }));

  void refetchFlags();
}

// -- app/pricing/page.tsx (Path A) --
import { getVariant } from '@/lib/experiment-server';
import { getExperimentUser } from '@/lib/experiment-user';
import { ExposureTracker } from '@/components/ExposureTracker';

export default async function PricingPage() {
  const [variant, user] = await Promise.all([
    getVariant('pricing-page-v2'),
    getExperimentUser(),
  ]);
  const showNewTable = variant?.value === 'on';

  return (
    <main>
      <h1>Pricing</h1>
      {showNewTable ? <section>New pricing</section> : <section>Current pricing</section>}
      <ExposureTracker
        flagKey="pricing-page-v2"
        variant={variant?.value}
        userId={user.user_id}
      />
    </main>
  );
}

// -- components/ExposureTracker.tsx (Path A) --
'use client';

import { useEffect } from 'react';
import { amplitude, initAmplitude } from '@/lib/amplitude';

type Props = { flagKey: string; variant?: string; userId?: string };

export function ExposureTracker({ flagKey, variant, userId }: Props) {
  useEffect(() => {
    if (!variant) return;
    initAmplitude();
    if (userId) amplitude.setUserId(userId);
    amplitude.track('$exposure', { flag_key: flagKey, variant });
  }, [flagKey, variant, userId]);

  return null;
}

// -- lib/flags.ts (Path B) --
import 'server-only';
import { cache } from 'react';
import { evaluateForCurrentUser } from '@/lib/experiment-server';
import type { BootstrappedVariants } from '@/lib/amplitude';

export const CLIENT_FLAG_KEYS = ['pricing-page-v2'];

export const getClientVariants = cache(async (): Promise<BootstrappedVariants> => {
  const variants = await evaluateForCurrentUser(CLIENT_FLAG_KEYS);
  const bootstrapped: BootstrappedVariants = {};
  for (const [flagKey, variant] of Object.entries(variants)) {
    if (variant?.value) bootstrapped[flagKey] = { value: variant.value, payload: variant.payload };
  }
  return bootstrapped;
});

// -- app/layout.tsx (Path B) --
import { AnalyticsProvider } from '@/components/AnalyticsProvider';
import { getClientVariants } from '@/lib/flags';
import { getExperimentUser } from '@/lib/experiment-user';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const [initialVariants, user] = await Promise.all([
    getClientVariants(),
    getExperimentUser(),
  ]);

  return (
    <html lang="en">
      <body>
        <AnalyticsProvider initialVariants={initialVariants} userId={user.user_id}>
          {children}
        </AnalyticsProvider>
      </body>
    </html>
  );
}

// -- lib/amplitude.ts (Path B: replaces the whole file) --
import * as amplitude from '@amplitude/unified';
import { Source } from '@amplitude/experiment-js-client';
import { takeHandoffDeviceId } from '@/lib/amplitude-device';

export type BootstrappedVariants = Record<string, { value?: string; payload?: unknown }>;

type InitOptions = {
  initialVariants?: BootstrappedVariants;
  userId?: string;
};

let started = false;
let flagsVersion = 0;
let fetchGeneration = 0;
let ready: Promise<unknown> = Promise.resolve();
const listeners = new Set<() => void>();

function notifyFlagsChanged() {
  flagsVersion += 1;
  listeners.forEach((listener) => listener());
}

async function refetchFlags() {
  const generation = ++fetchGeneration;
  try {
    await ready; // initAll is typed void, but the Next.js guide awaits it
    await amplitude.experiment()?.fetch();
  } finally {
    if (generation === fetchGeneration) notifyFlagsChanged();
  }
}

export function initAmplitude({ initialVariants, userId }: InitOptions = {}) {
  if (started || typeof window === 'undefined') return;
  started = true;

  const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) {
    console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
    notifyFlagsChanged();
    return;
  }

  const deviceId = takeHandoffDeviceId();

  ready = Promise.resolve(amplitude.initAll(apiKey, {
    analytics: {
      autocapture: true,
      ...(deviceId ? { deviceId } : {}),
      ...(userId ? { userId } : {}),
    },
    experiment: {
      deploymentKey: process.env.NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
      fetchTimeoutMillis: 3000,
      ...(initialVariants
        ? { initialVariants, source: Source.InitialVariants }
        : {}),
    },
  }));

  void refetchFlags();
}

export function identifyUser(userId: string) {
  initAmplitude();
  amplitude.setUserId(userId);
  void refetchFlags();
}

export function resetUser() {
  initAmplitude();
  amplitude.reset();
  amplitude.experiment()?.clear();
  void refetchFlags();
}

export function subscribeFlags(listener: () => void) {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
}

export function getFlagsVersion() {
  return flagsVersion;
}

export function whenAmplitudeReady() {
  return ready;
}

export { amplitude };

// -- components/AnalyticsProvider.tsx (Path B) --
'use client';

import { createContext, useContext } from 'react';
import { initAmplitude, type BootstrappedVariants } from '@/lib/amplitude';

const VariantsContext = createContext<BootstrappedVariants>({});

type Props = {
  initialVariants: BootstrappedVariants;
  userId?: string;
  children: React.ReactNode;
};

export function AnalyticsProvider({ initialVariants, userId, children }: Props) {
  initAmplitude({ initialVariants, userId });
  return <VariantsContext.Provider value={initialVariants}>{children}</VariantsContext.Provider>;
}

export function useBootstrappedVariants() {
  return useContext(VariantsContext);
}

// -- hooks/useVariant.ts (Path B) --
'use client';

import { useEffect } from 'react';
import { amplitude, whenAmplitudeReady } from '@/lib/amplitude';
import { useBootstrappedVariants } from '@/components/AnalyticsProvider';

export function useVariant(flagKey: string) {
  const variant = useBootstrappedVariants()[flagKey];
  const value = variant?.value;

  useEffect(() => {
    if (!value) return;
    void whenAmplitudeReady().then(() => amplitude.experiment()?.exposure(flagKey));
  }, [flagKey, value]);

  return variant;
}

// -- components/PricingTable.tsx (Path B) --
'use client';

import { useState } from 'react';
import { useVariant } from '@/hooks/useVariant';

export function PricingTable() {
  const variant = useVariant('pricing-page-v2');
  const [annual, setAnnual] = useState(false);

  return (
    <section>
      <label>
        <input type="checkbox" checked={annual} onChange={(e) => setAnnual(e.target.checked)} />
        Bill annually
      </label>
      {variant?.value === 'on' ? (
        <div>New pricing ({annual ? 'annual' : 'monthly'})</div>
      ) : (
        <div>Current pricing ({annual ? 'annual' : 'monthly'})</div>
      )}
    </section>
  );
}

// -- app/pricing/page.tsx (Path B) --
import { PricingTable } from '@/components/PricingTable';

export default function PricingPage() {
  return (
    <main>
      <h1>Pricing</h1>
      <PricingTable />
    </main>
  );
}

// -- lib/auth-navigation.ts (Path B) --
'use client';

import { resetUser } from '@/lib/amplitude';

export function afterSignIn(redirectTo = '/') {
  window.location.assign(redirectTo);
}

export function afterSignOut(redirectTo = '/') {
  resetUser();
  window.location.assign(redirectTo);
}

Hand it to your agent

Prefer to have Cursor or Claude Code do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt
Evaluate Amplitude feature flags on the server in this Next.js App Router
app by following https://amplitude.com/build/nextjs-feature-flags-ssr
exactly. It builds on the Install tutorial; don't reinstall or re-init
unified. Do the shared steps 1 to 4: install
@amplitude/experiment-node-server and server-only, read
AMPLITUDE_SERVER_DEPLOYMENT_KEY (a server deployment key, never
NEXT_PUBLIC_) only in lib/experiment-server.ts, which imports server-only
and keeps one local evaluation client on globalThis, started once.
Evaluate per request; never cache variants with unstable_cache,
force-static, or revalidate. Read the device ID from the AMP_ cookie with
AmplitudeCookie (Browser SDK 2 format), and on first visits use the
middleware handoff cookie that lib/amplitude.ts adopts as deviceId. Build
user IDs with toAmplitudeUserId.
Then pick a path for each flagged component. If it has no state or event
handlers, use Path A: render it in a Server Component with getVariant, don't
set exposureConfig or tracksExposure on the server, and send $exposure from
ExposureTracker's effect after render, skipping it when there's no variant.
If it's interactive, use Path B: evaluate only CLIENT_FLAG_KEYS in the root
layout, pass plain { value, payload } objects and the server's user ID to
AnalyticsProvider, initialize unified once with experiment.initialVariants,
source: Source.InitialVariants (from @amplitude/experiment-js-client), and
analytics.userId, read variants with useVariant (never
experiment().variant() during render), call exposure() in an effect of the
component that shows the variant, and end sign-in and sign-out with a full
page load. Give each flag one exposure path. Don't invent flag keys. Tell
me which path you chose for each component and report every file you
changed.

What this unlocks

  • Experiment analysis compares Plan Upgraded between pricing-page-v2 on and everyone else, using exposures that only count people who saw the table, and it stops losing users who bounced during a flicker, since on users see the new table from the first frame.
  • Session replay, with a sample rate set, can filter to users with the [Experiment] pricing-page-v2 user property, and the replay shows the server-rendered variant from the first frame instead of a swap.
  • Heatmaps on /pricing split cleanly by variant, because the variant is in the HTML rather than swapped in after load, so clicks on a placeholder don't mix with clicks on the real table.
  • Guides and surveys can target the same user property, which the exposure sets from the first page view, to ask on users about the new table.

One integration; everything else reads from the same events.

Next steps

Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.

Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.