---
title: "Use feature flags with server-side rendering in Next.js"
description: "Evaluate Amplitude feature flags on the server in Next.js and render the right variant in the first HTML, in Server or client components."
canonical: "https://amplitude.com/en-us/build/nextjs-feature-flags-ssr"
---

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

Evaluate Amplitude feature flags on the server in Next.js and render the right variant in the first HTML, in Server or client components.

Source: https://amplitude.com/en-us/build/nextjs-feature-flags-ssr

---

# 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](https://amplitude.com/feature-management) 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](/build/nextjs-feature-flags).

## 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](https://app.amplitude.com/signup) 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**](https://amplitude.com/docs/feature-experiment/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.&#x20;**&#x4D;igrating 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](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js) and the `server-only` package:

Terminal

```sh
npm install @amplitude/experiment-node-server server-only
```

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

.env.local

```sh
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

```typescript
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.&#x20;**&#x54;he 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)

```typescript
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

```typescript
// 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

```typescript
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

```typescript
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)

```typescript
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.&#x20;**`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

```typescript
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

```typescript
'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.&#x20;**`$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](https://amplitude.com/docs/sdks/experiment-sdks/experiment-javascript#bootstrapping) in the Experiment JavaScript SDK docs.

lib/flags.ts

```typescript
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

```typescript
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.&#x20;**&#x52;eading 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

```sh
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

```typescript
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.&#x20;**&#x50;assing `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

```typescript
'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.&#x20;**&#x43;alling `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

```typescript
'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

```typescript
'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

```typescript
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

```typescript
'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.&#x20;**`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

```typescript
// -- .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

```text
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](https://amplitude.com/amplitude-experiment) 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](https://amplitude.com/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](https://amplitude.com/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](https://amplitude.com/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

[Web frameworksHow to add feature flags and experiments to a Next.js appThe client-side setup, deployments, and keys this page builds on.](/build/nextjs-feature-flags)[Web frameworksHow to add product analytics to a Next.js appTrack Plan Upgraded from a server action so the experiment has a metric.](/build/nextjs-product-analytics)[Use caseTrack events from React Server ComponentsSend events from server actions and route handlers with the right IDs and no duplicates.](/build/nextjs-server-components-tracking)

Reference docs

[Experiment Node.js SDK →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js)[Local evaluation →](https://amplitude.com/docs/feature-experiment/local-evaluation)[Server-side rendering →](https://amplitude.com/docs/feature-experiment/advanced-techniques/server-side-rendering)[Experiment JavaScript SDK: bootstrapping →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-javascript#bootstrapping)[Experiment event tracking →](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

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

For agents

[View raw .md](/build/nextjs-feature-flags-ssr.md)[llms.txt](/llms.txt)

## 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.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
