How to add feature flags and experiments to a SvelteKit app

Render an Amplitude feature flag's variant in SvelteKit's first HTML and track its exposure from the browser. About 30 minutes, all copy-paste.

  • SvelteKit 2.10+
  • Svelte 5
  • Unified SDK 1.x
  • Experiment Node SDK 1.x
  • 30 min

Chris Tam · Last updated 13 October 2026

SvelteKit renders every page on the server first, so a feature flag that only resolves in the browser shows the old version first. Evaluating the flag on the server puts the right variant in the first HTML.

In this tutorial, you'll evaluate a flag in a server load, match the server's device ID to the browser's, send exposures from the browser, and fetch browser-only flags after sign-in. It builds on the SvelteKit product analytics page.

What you'll build

A /projects/new page that reads the new-project-flow flag in its server load and renders a template gallery for users in on straight into the server HTML, with no swap after hydration. The browser sends one [Experiment] Exposure per visit, bucketed on the same device ID as the server, even on a first visit. After someone creates a project, a browser-fetched project-checklist flag decides whether they see a checklist, and Project Created with its template property serves as the metric.

Prerequisites

  • The SvelteKit app from How to add product analytics to a SvelteKit app, with @amplitude/unified, src/hooks.client.ts, the identity $effect in src/routes/+layout.svelte, src/routes/+layout.server.ts, and src/lib/components/SignOutButton.svelte in place.
  • Your auth puts the signed-in user on event.locals.user in src/hooks.server.ts, with an id and a plan, as that page assumes.
  • A long-running Node.js server in production, such as a build from @sveltejs/adapter-node. The local evaluation client keeps flag rules in memory; a function that cold-starts on each request downloads them every time and often renders defaults (step 3).
  • Access to Experiment in the same Amplitude project your events go to, so you can create deployments and flags.

No Amplitude account yet? Feature flags, JSON payloads, and local evaluation are available on all plans, so a free account covers the steps on this page. Running a flag as an experiment and analyzing the results depends on your plan; compare plans. Sign up for free to get your project API key.

1. Create the deployments and flags

A deployment serves a group of flags through its own key. A client deployment key is publicly viewable and belongs in browser code; a server deployment key is secret and stays in server code. Both are separate from your project API key. SvelteKit runs code in both places, so this page uses one of each (deployment docs).

Create the two deployments:

  1. Go to Experiment > Deployments, then click Create Deployment.
  2. Choose the Amplitude project your analytics events go to, or exposures won't line up with users.
  3. Name it sveltekit-server, choose Server-side as the type, click Create a Deployment, and copy the key. Server keys start with server-.
  4. Repeat for a second deployment named web, with Client-side as the type. Client keys start with client-.

Then create the flag the server renders:

  1. Go to Experiment > Feature Flags and click Create A Feature Flag.
  2. Choose the same project and name the flag New project flow. Experiment generates the key new-project-flow, which your code reads.
  3. Set Evaluation Mode to Local, set the bucketing unit to the user, and click Create.
  4. Choose the sveltekit-server deployment from the Deployment drop-down.
  5. Add a variant named on. In its Payload field, paste the JSON below, then click Apply.
  6. In the All Other Users section, set bucketed by to Device ID, then set the rollout to 50% so you can see both branches. Save, then set the flag to Active.

Payload for the on variant:

Variant payload (JSON)
{
  "defaultTemplate": "kanban",
  "showTemplateGallery": true
}

Then create the flag the browser fetches: name it Project checklist (key project-checklist), leave the evaluation mode on Remote, choose the web deployment, add a variant named on with no payload, set the All Other Users rollout to 50%, save, and set it to Active.

Why. Local evaluation downloads the flag rules once and evaluates them in your server process, with no network call per request. It can't resolve the Amplitude ID, which is why the docs for local flags bucket by device ID. That makes steps 4 and 5 essential: if the server and the browser disagree on the device ID, they disagree on the variant. Because the flag buckets on the device, a person who signs in on a second device can land in the other variant; Experiment's analysis flags users exposed to more than one variant. The checklist flag stays remote because it only renders after a click, so the browser has time to fetch it, and remote evaluation adds Amplitude ID resolution and the user properties and cohorts Amplitude already holds.

Don't name a variant off; Experiment reserves it for users who fall outside the rollout. If new-project-flow already exists as a remote flag from another Build page, create a new local flag: changing the evaluation mode changes bucketing, so the docs say to deactivate a flag before you migrate it. Payloads are visible in the page source, so keep them to configuration.

2. Install the server SDK and add the keys

Server-side flags use the Experiment Node.js SDK, separate from the @amplitude/unified package, which already bundles Feature Experiment for the browser.

Terminal
npm install @amplitude/experiment-node-server

Add both deployment keys next to the project API key from the product analytics page:

.env
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
PUBLIC_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key
AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-key

SvelteKit only lets browser code import variables that start with PUBLIC_. The project API key is public and its scope is limited to the minimum needed to ingest data, and a client deployment key is public by design, so both carry the prefix; never put the server- key in a PUBLIC_ variable, because SvelteKit bakes those into the browser bundle. This page reads the server key through $env/dynamic/private, which SvelteKit refuses to import into browser code and reads at runtime. Keep .env out of version control and set the server key in your host's secret settings for production.

3. Start one local evaluation client per server process

This module owns the local evaluation client, under src/lib/server/, which browser code can never import.

src/lib/server/experiment.ts
import { Experiment } from '@amplitude/experiment-node-server';
import { env } from '$env/dynamic/private';

type LocalClient = ReturnType<typeof Experiment.initializeLocal>;
export type ServerVariants = ReturnType<LocalClient['evaluateV2']>;
export type ExperimentUser = {
  user_id?: string;
  device_id?: string;
  user_properties?: Record<string, string>;
};

let client: LocalClient | undefined;
let ready: Promise<void> | undefined;

// Creates the client once per process and starts downloading flag rules.
export function startFlags(): Promise<void> {
  if (!client || !ready) {
    const key = env.AMPLITUDE_SERVER_DEPLOYMENT_KEY;
    if (!key) return Promise.reject(new Error('AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set'));

    const localClient = Experiment.initializeLocal(key);
    client = localClient;
    // start() downloads the flag rules, then polls for updates.
    ready = localClient.start().catch((error) => {
      // Stop polling for the dropped client; the next request retries.
      localClient.stop();
      if (client === localClient) {
        client = undefined;
        ready = undefined;
      }
      throw error;
    });
  }
  return ready;
}

export function stopFlags() {
  client?.stop();
  client = undefined;
  ready = undefined;
}

async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error(`flag rules not loaded within ${ms} ms`)), ms);
  });
  try {
    return await Promise.race([promise, timeout]);
  } finally {
    clearTimeout(timer);
  }
}

// Evaluates the given flags for one user in memory. Returns {} on any
// failure, so pages render their defaults instead of erroring.
export async function evaluateFlags(
  user: ExperimentUser,
  flagKeys: string[],
): Promise<ServerVariants> {
  if (!user.user_id && !user.device_id) return {};

  try {
    const started = startFlags();
    const current = client;
    // A cold start or outage renders defaults instead of holding the page.
    await withTimeout(started, 1000);
    return current ? current.evaluateV2(user, flagKeys) : {};
  } catch (error) {
    console.error('[flags] evaluation failed; rendering defaults.', error);
    return {};
  }
}

Add an init export to your existing src/hooks.server.ts; your handle stays as it is.

src/hooks.server.ts
import type { ServerInit } from '@sveltejs/kit';
import { building } from '$app/environment';
import { startFlags, stopFlags } from '$lib/server/experiment';

export const init: ServerInit = () => {
  // Prerendering at build time serves no visitors and has no runtime env.
  if (building) return;

  // Don't hold up the server; early requests wait up to 1 s in evaluateFlags.
  startFlags().catch((error) => {
    console.warn('[flags] could not load flag rules; pages render defaults.', error);
  });

  // adapter-node emits this on SIGINT or SIGTERM. Stop polling cleanly.
  process.once('sveltekit:shutdown', stopFlags);
};

// Your existing handle (the auth that sets event.locals.user) stays below.

Why. Flag rules are the same for everyone and refresh every 30 seconds by default, so the client lives once per process and each request only evaluates, in about 0.025 ms per the docs. evaluateV2 only reads rules that are already loaded, so every call waits for start(), but never more than a second: after a deploy or cold start, a slower render shows the defaults and later requests catch up. The init export (SvelteKit 2.10+) runs once before the first request, which makes it the place to start the download.

The Node.js local evaluation page covers streaming updates (whose first connection can take up to 1.5 seconds, longer than this wait), cohort targeting, and server-side exposure tracking, and the Node.js feature flags page covers remote evaluation on a server.

4. Evaluate in the page's server load with the browser's device ID

The browser SDK stores its device ID in the AMP_ cookie for your API key. The Node.js SDK's AmplitudeCookie class finds and parses that cookie on the server. On a first visit the cookie doesn't exist yet, so the server creates a device ID, evaluates with it, and hands it to the browser in a short-lived cookie of its own for step 5.

The handoff cookie's name is shared by server and browser code, so it lives in its own module:

src/lib/amplitude-cookies.ts
// Set by the server when it evaluates flags for a visitor who has no
// AMP_ cookie yet. Not the SDK's own cookie.
export const DEVICE_ID_HANDOFF_COOKIE = 'amp_server_device_id';

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export function isDeviceId(value: string | undefined): value is string {
  return value !== undefined && UUID.test(value);
}

loadFlags is the one function a page's server load calls:

src/lib/server/flags.ts
import { randomUUID } from 'node:crypto';
import type { RequestEvent } from '@sveltejs/kit';
import { AmplitudeCookie } from '@amplitude/experiment-node-server';
import { building } from '$app/environment';
import { PUBLIC_AMPLITUDE_API_KEY } from '$env/static/public';
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId } from '$lib/amplitude-cookies';
import type { ServerFlags } from '$lib/flags.svelte';
import { evaluateFlags } from './experiment';

function readDeviceId(event: RequestEvent): string | undefined {
  if (!PUBLIC_AMPLITUDE_API_KEY) return undefined;
  // cookieName throws on a malformed key: render defaults, not an error.
  try {
    // `true` selects the Browser SDK 2 cookie format, which unified uses.
    const cookie = event.cookies.get(AmplitudeCookie.cookieName(PUBLIC_AMPLITUDE_API_KEY, true));
    return cookie ? (AmplitudeCookie.parse(cookie, true).device_id ?? undefined) : undefined;
  } catch {
    return undefined;
  }
}

export async function loadFlags(
  event: RequestEvent,
  flagKeys: string[],
): Promise<ServerFlags | null> {
  // Prerendered HTML is shared by every visitor. Never freeze one
  // visitor's device ID and variants into it.
  if (building) return null;

  // This response (HTML and __data.json) carries one visitor's device ID
  // and variants. No cache may store it.
  event.setHeaders({ 'cache-control': 'private, no-store' });

  // 1. The browser SDK's own cookie.
  // 2. An ID this server minted earlier that the browser hasn't adopted yet.
  // 3. A new ID, handed to the browser for step 5.
  let deviceId = readDeviceId(event);
  if (!deviceId) {
    const handoff = event.cookies.get(DEVICE_ID_HANDOFF_COOKIE);
    if (isDeviceId(handoff)) deviceId = handoff;
  }
  if (!deviceId) {
    deviceId = randomUUID();
    event.cookies.set(DEVICE_ID_HANDOFF_COOKIE, deviceId, {
      path: '/',
      httpOnly: false, // the client hook reads it
      sameSite: 'lax',
      maxAge: 60 * 60,
    });
  }

  // The same "user-" prefix the browser sets, so both sides send one ID.
  const user = event.locals.user;
  const userId = user ? `user-${user.id}` : undefined;

  const variants = await evaluateFlags(
    {
      device_id: deviceId,
      user_id: userId,
      // Local evaluation only sees the properties you pass in.
      ...(user ? { user_properties: { plan: user.plan ?? 'unknown' } } : {}),
    },
    flagKeys,
  );

  // Plain objects only, and nothing for users outside the rollout.
  const flags: ServerFlags['flags'] = {};
  for (const [flagKey, variant] of Object.entries(variants)) {
    if (variant?.value && variant.value !== 'off') {
      flags[flagKey] = { value: variant.value, payload: variant.payload };
    }
  }

  return { deviceId, userId, flags };
}

Call it from the server load of the page that renders the flag:

src/routes/projects/new/+page.server.ts
import type { PageServerLoad } from './$types';
import { loadFlags } from '$lib/server/flags';

// Only flags this page renders. Every key here ends up in the page's
// serialized data, where anyone can read it in the page source.
const FLAG_KEYS = ['new-project-flow'];

export const load: PageServerLoad = async (event) => {
  return { amplitude: await loadFlags(event, FLAG_KEYS) };
};

Why. AmplitudeCookie.cookieName builds the AMP_ cookie name from your API key, so the code never hardcodes it. The docs' example for this case writes the SDK's own AMP_ cookie from the server; this page sets a separate handoff cookie and lets the browser SDK write AMP_ itself in step 5, so the server never has to match the SDK's cookie format and domain. Reusing the handoff ID covers a second request that arrives before the browser JavaScript runs, so one visitor never gets two IDs.

setHeaders runs inside loadFlags, so no page can evaluate flags without marking its response private. Even an anonymous response carries the visitor's device ID and, on a first visit, a Set-Cookie; a shared cache that stored it would hand one visitor's ID and variant to everyone. Only pages whose server load calls loadFlags carry the header and the handoff cookie; other pages keep whatever caching rules you already have.

5. Adopt the server's device ID in the client hook

The SDK's initAll returns a promise, and experiment() and getDeviceId() return undefined until it settles. Steps 6 and 7 need both, so first add a small module that holds that promise for the rest of the app. The SvelteKit guides and surveys page uses the same file; if you already have it, keep yours.

src/lib/amplitude-ready.ts
// Resolves to true once initAll has finished in the browser, or false
// if analytics is off. Nothing awaits it on the server.
let markReady: (ok: boolean) => void = () => {};
export const amplitudeReady = new Promise<boolean>((resolve) => {
  markReady = resolve;
});

export function setAmplitudeReady(init: Promise<unknown> | null) {
  if (!init) {
    markReady(false);
    return;
  }
  init.then(
    () => markReady(true),
    (error) => {
      console.warn('[amplitude] initAll failed', error);
      markReady(false);
    },
  );
}

Then replace src/hooks.client.ts. If you added options on other SvelteKit pages, such as pageViews: { trackHistoryChanges: 'pathOnly' } from the web analytics page, keep them, and only change the parts this step names. If you added the setAmplitudeReady(...) wrapper on the SvelteKit guides and surveys page, keep it around initAll. What changed: it reads the handoff cookie and passes it as analytics.deviceId when the browser has no AMP_ cookie yet, it hands the initAll promise to setAmplitudeReady, and it adds the experiment block that step 7 uses.

src/hooks.client.ts
import type { ClientInit } from '@sveltejs/kit';
import { dev } from '$app/environment';
import {
  PUBLIC_AMPLITUDE_API_KEY,
  PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
} from '$env/static/public';
import * as amplitude from '@amplitude/unified';
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId } from '$lib/amplitude-cookies';
import { setAmplitudeReady } from '$lib/amplitude-ready';

function readCookie(name: string): string | undefined {
  const prefix = `${name}=`;
  const match = document.cookie.split('; ').find((c) => c.startsWith(prefix));
  return match ? decodeURIComponent(match.slice(prefix.length)) : undefined;
}

export const init: ClientInit = () => {
  if (!PUBLIC_AMPLITUDE_API_KEY) {
    console.warn('PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.');
    setAmplitudeReady(null);
    return;
  }

  // Browser SDK 2 names its cookie AMP_ plus the first 10 characters of
  // the key. If it exists, the SDK already has a device ID: keep it.
  const hasAmpCookie =
    readCookie(`AMP_${PUBLIC_AMPLITUDE_API_KEY.slice(0, 10)}`) !== undefined;
  const serverDeviceId = readCookie(DEVICE_ID_HANDOFF_COOKIE);
  const adoptServerDeviceId = !hasAmpCookie && isDeviceId(serverDeviceId);

  // Steps 6 and 7 wait for this promise before they read from the SDK.
  setAmplitudeReady(
    amplitude.initAll(PUBLIC_AMPLITUDE_API_KEY, {
      analytics: {
        autocapture: {
          attribution: true,
          pageViews: true,
          sessions: true,
          formInteractions: true,
          fileDownloads: true,
          elementInteractions: true,
        },
        // First visit only: the device ID the server bucketed this visitor
        // with. Later visits read it from the AMP_ cookie the SDK writes.
        ...(adoptServerDeviceId ? { deviceId: serverDeviceId } : {}),
      },
      sessionReplay: {
        // Every session while you build; about 1% in production.
        sampleRate: dev ? 1 : 0.01,
        debugMode: dev,
        // From /build/sveltekit-session-replay. Keep your masking rules when you paste this file.
        privacyConfig: {
          maskSelector: ['[data-amp-mask]'],
          blockSelector: ['[data-amp-block]'],
          unmaskSelector: ['[data-amp-unmask]'],
        },
      },
      experiment: {
        // The client deployment key, for the browser flags in step 7.
        deploymentKey: PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
        // Default is 10 seconds. Fall back to the current UI sooner.
        fetchTimeoutMillis: 3000,
      },
    }),
  );
};

Why. The client hook's init runs once, before hydration, and the Set-Cookie from step 4 arrived with the page, so the handoff cookie is readable. deviceId is passed only when the browser has no AMP_ cookie, so a stale handoff can never replace a device ID the browser already has. From then on the SDK writes its own cookie and every server load reads the same ID: the server mints it, initAll adopts it, and the exposure in step 6 carries it. setAmplitudeReady runs on every path through init, including the missing-key branch, so code that waits on amplitudeReady never hangs; it gets false when analytics is off or initAll fails.

The sessionReplay block is unchanged from the session replay page; set sampleRate to 0 to turn session replay off.

6. Render the variant and send one exposure

src/lib/flags.svelte.ts holds the flag helpers; the .svelte.ts extension lets a plain module use runes. This first part reads a server-evaluated variant during render and sends the exposure after mount, once amplitudeReady resolves. Step 7 adds the browser half; the Full code reference has the complete file.

src/lib/flags.svelte.ts (server-evaluated flags)
import { dev } from '$app/environment';
import * as amplitude from '@amplitude/unified';
import { amplitudeReady } from '$lib/amplitude-ready';

export type FlagVariant = { value: string; payload?: unknown };
export type ServerFlags = {
  deviceId: string;
  userId?: string;
  flags: Record<string, FlagVariant>;
};

export function serverFlag(getState: () => ServerFlags | null | undefined, flagKey: string) {
  const variant = $derived(getState()?.flags[flagKey]);
  let lastExposure: string | undefined;

  // Browser only, after the variant is on screen.
  $effect(() => {
    const state = getState();
    const value = state?.flags[flagKey]?.value;
    // No variant means the user isn't in the rollout. An exposure without
    // a variant would remove them from the experiment, so send nothing.
    if (!state || !value) return;

    // Once per visit and identity, even when the page's data reloads.
    const key = `${state.userId ?? ''}|${state.deviceId}|${value}`;
    if (key === lastExposure) return;
    lastExposure = key;

    // getDeviceId() is undefined until initAll settles.
    void amplitudeReady.then((ok) => {
      if (!ok) return;
      const browserDeviceId = amplitude.getDeviceId();
      if (dev && browserDeviceId !== state.deviceId) {
        console.warn(
          `[flags] the browser's device ID ${browserDeviceId} differs from the one the server evaluated, ${state.deviceId}.`,
        );
      }

      amplitude.track({
        event_type: '$exposure',
        event_properties: { flag_key: flagKey, variant: value },
        // The user the server evaluated, even if the layout's setUserId
        // hasn't run yet.
        ...(state.userId ? { user_id: state.userId } : {}),
      });
    });
  });

  return {
    get variant() {
      return variant;
    },
  };
}

The template list lives in its own module, so the page and the form share it:

src/lib/templates.ts
export const TEMPLATES: string[] = ['blank', 'kanban', 'roadmap'];

The page reads the flag from its data, so the form's first render, on the server and in the browser, has the payload's settings:

src/routes/projects/new/+page.svelte
<script lang="ts">
  import { serverFlag } from '$lib/flags.svelte';
  import { TEMPLATES } from '$lib/templates';
  import NewProjectForm from '$lib/components/NewProjectForm.svelte';

  type NewProjectPayload = {
    defaultTemplate?: unknown;
    showTemplateGallery?: unknown;
  };

  let { data } = $props();

  const flag = serverFlag(() => data.amplitude, 'new-project-flow');

  // Only the "on" variant carries the new settings. Validate the payload
  // and keep the current defaults for anything missing or unexpected.
  const config = $derived(
    (flag.variant?.value === 'on' ? flag.variant.payload : undefined) as
      | NewProjectPayload
      | undefined,
  );
  const defaultTemplate = $derived.by(() => {
    const candidate = config?.defaultTemplate;
    return typeof candidate === 'string' && TEMPLATES.includes(candidate) ? candidate : 'blank';
  });
  const showGallery = $derived(config?.showTemplateGallery === true);
</script>

<!-- Recreates the form if a reload of the page's data changes the variant. -->
{#key `${defaultTemplate}|${showGallery}`}
  <NewProjectForm {defaultTemplate} {showGallery} />
{/key}
src/lib/components/NewProjectForm.svelte
<script lang="ts">
  import { untrack } from 'svelte';
  import * as amplitude from '@amplitude/unified';
  import { TEMPLATES } from '$lib/templates';
  import ProjectChecklist from '$lib/components/ProjectChecklist.svelte';

  let { defaultTemplate, showGallery }: { defaultTemplate: string; showGallery: boolean } =
    $props();

  // Seeded once, when the form is created.
  let template = $state(untrack(() => defaultTemplate));
  let created = $state(false);

  async function handleCreate() {
    // ... your create logic

    amplitude.track('Project Created', { template });
    created = true;
  }
</script>

{#if showGallery}
  <fieldset>
    <legend>Start from a template</legend>
    {#each TEMPLATES as t (t)}
      <label>
        <input type="radio" name="template" value={t} bind:group={template} />
        {t}
      </label>
    {/each}
  </fieldset>
{:else}
  <select bind:value={template}>
    {#each TEMPLATES as t (t)}
      <option value={t}>{t}</option>
    {/each}
  </select>
{/if}
<button onclick={handleCreate}>Create project</button>

{#if created}
  <ProjectChecklist />
{/if}

Why. The server render and hydration read the same data object, so nothing swaps after load. The server could track the exposure itself (exposureConfig on initializeLocal, { tracksExposure: true } on evaluateV2). Don't, for a page like this: server renders also happen for crawlers, uptime checks, and responses that never finish loading, and the event tracking docs reserve server-side heuristics for cases where client-side exposure tracking isn't possible. Amplitude renames $exposure to [Experiment] Exposure and sets the [Experiment] new-project-flow user property, which experiment analysis reads.

The exposure passes the server's userId as user_id, because the layout's identity effect and this page's effect both run after mount in no guaranteed order. The device ID needs no override after step 5, and the development-only warning tells you if they ever differ. Both wait for amplitudeReady: on a first visit the page mounts before initAll settles, and without the wait the check would skip exactly the visit it exists for.

Call serverFlag for new-project-flow only in the page that shows the variant. In the root layout it would send exposures for visitors who never opened /projects/new, which dilutes the experiment. For flagged UI that mounts hidden, such as a closed dialog, call serverFlag in a child component that renders only when it opens.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's flags and events first, so it reads new-project-flow instead of inventing newProjectFlow. Set up the MCP server.

7. Fetch browser flags after identity, clear them on sign-out

Server-evaluated flags already follow sign-in and sign-out, because a successful form action reruns the server loads. Browser flags need their own fetch. This half of src/lib/flags.svelte.ts keeps a counter that goes up each time a fetch settles, and a browserFlag helper that reads the variant after mount:

src/lib/flags.svelte.ts (browser-fetched flags, same file)
type FetchUser = { user_properties?: Record<string, string> };

// Goes up by one each time a browser flag fetch settles. 0 means the
// first fetch hasn't settled yet.
let flagsVersion = $state(0);
let fetchGeneration = 0;

export async function refetchBrowserFlags(user?: FetchUser) {
  const generation = ++fetchGeneration;
  try {
    // experiment() is undefined until initAll has finished.
    if (await amplitudeReady) {
      // On failure or timeout the SDK returns and retries in the background.
      await amplitude.experiment()?.fetch(user);
    }
  } finally {
    // Only the newest fetch tells components to re-read, so a slow anonymous
    // fetch from page load doesn't re-render them after sign-in.
    if (generation === fetchGeneration) flagsVersion += 1;
  }
}

export function browserFlag(flagKey: string) {
  let flag = $state<{ value: string | null; payload: unknown } | undefined>();

  // Runs after mount and whenever a fetch settles. variant() tracks an
  // exposure, so only components that show the variant call browserFlag.
  $effect(() => {
    if (flagsVersion === 0) return;
    const variant = amplitude.experiment()?.variant(flagKey);
    flag = { value: variant?.value ?? null, payload: variant?.payload };
  });

  return {
    get value() {
      return flag?.value ?? null;
    },
    get payload() {
      return flag?.payload;
    },
  };
}

The checklist mounts only after someone creates a project:

src/lib/components/ProjectChecklist.svelte
<script lang="ts">
  import { browserFlag } from '$lib/flags.svelte';

  const checklist = browserFlag('project-checklist');
</script>

{#if checklist.value === 'on'}
  <aside>
    <h2>Next steps</h2>
    <ul>
      <li>Invite your team</li>
      <li>Import your existing tasks</li>
    </ul>
  </aside>
{/if}

Fetch from the root layout's identity effect, after the user ID is set. This replaces the script block in src/routes/+layout.svelte:

src/routes/+layout.svelte
<script lang="ts">
  import * as amplitude from '@amplitude/unified';
  import { refetchBrowserFlags } from '$lib/flags.svelte';

  let { data, children } = $props();

  // Layout data reloads after every form action. Only act when the
  // identity actually changes.
  let lastIdentity: string | undefined;

  $effect(() => {
    const id = data.user?.id;
    const plan = data.user?.plan ?? 'unknown';
    const identity = id ? `user-${id}|${plan}` : 'anonymous';
    if (identity === lastIdentity) return;
    lastIdentity = identity;

    if (!id) {
      // Anonymous: keep the device ID. Reset only from the sign-out handler.
      void refetchBrowserFlags();
      return;
    }

    // Amplitude drops IDs shorter than 5 characters. Same string as the server.
    amplitude.setUserId(`user-${id}`);
    const identify = new amplitude.Identify();
    identify.set('plan', plan);
    amplitude.identify(identify);
    // Pass plan with the request, so targeting sees it on this evaluation.
    void refetchBrowserFlags({ user_properties: { plan } });
  });
</script>

{@render children()}

Then clear the previous user's variants on sign-out. This replaces src/lib/components/SignOutButton.svelte:

src/lib/components/SignOutButton.svelte
<script lang="ts">
  import { enhance } from '$app/forms';
  import * as amplitude from '@amplitude/unified';
</script>

<form
  method="POST"
  action="/logout"
  use:enhance={() => {
    return async ({ result, update }) => {
      if (result.type === 'success' || result.type === 'redirect') {
        // Clears the user ID and issues a new device ID for the next person.
        amplitude.reset();
        // Drops the previous user's browser variants.
        amplitude.experiment()?.clear();
      }
      // Reruns the loads for the signed-out visitor.
      await update();
    };
  }}
>
  <button>Sign out</button>
</form>

Keep code other SvelteKit pages added to these two files, such as the guides and surveys onMount bridge and closeAll() before reset(); this step only adds refetchBrowserFlags and experiment()?.clear().

Why. The docs recommend fetching again when the user changes meaningfully, such as signing in, and passing new user properties with the request, because a property Amplitude only just received can race the evaluation. reset() stays in the sign-out handler only: a null user also describes every anonymous visitor, and resetting there would give each a new device ID, and a new bucket for the server flag, on every visit. reset() runs before update(), so the server loads that rerun read the new AMP_ cookie, and the server flag is evaluated for the device the next person will use.

browserFlag reads in $effect, so variant() and the exposure it tracks only run for a component that mounted in the browser. Don't use it for a flag the server renders, or the browser's answer can differ from the HTML.

Verify it worked

Run npm run build && npm run preview so you're testing a production render. While you verify, raise both flags' rollouts to 100% so your test user lands in on; the server picks up the local flag's new rollout within 30 seconds, or restart npm run preview. Open /projects/new (the route step 4 and step 6 create) in a private window, signed out, and check step 1 on that first load; then sign in through your existing sign-in page and continue:

  1. Network tab. Select the document request for /projects/new. Its response headers include cache-control: private, no-store, and on the first load in the private window, a set-cookie for amp_server_device_id. Its response contains Start from a template. Throttle to a slow preset and reload: the gallery arrives with the HTML, nothing swaps, and the console shows no hydration warning. Then filter for api2.amplitude.com and look for a POST to /2/httpapi returning 200 after the page renders, and for lab.amplitude.com to see one or two requests to api.lab.amplitude.com after load, and another after sign-in.
  2. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your uploads. It shows counts, not individual events.
  3. User lookup. Go to Users > User Profiles and search your user- prefixed test ID. Within ten seconds to a minute, the event stream shows [Experiment] Exposure with [Experiment] Flag Key set to new-project-flow and [Experiment] Variant set to on. Create a project: Project Created appears with template: kanban, followed by a second [Experiment] Exposure for project-checklist once the checklist renders. Amplitude renames $exposure to [Experiment] Exposure on ingestion.
  4. Device ID match. Add logLevel: amplitude.Types.LogLevel.Debug to the analytics block in src/hooks.client.ts, open a new private window, and load /projects/new directly, signed out. The console shows one $exposure event with flag_key new-project-flow and variant on once initAll finishes, and in DevTools under Application > Cookies, the amp_server_device_id value equals that event's device_id. Reload, and the document response no longer sets the handoff cookie, because the server now reads the browser's AMP_ cookie. The device ID check itself only runs in development: under npm run dev, no [flags] warning appears, including on a first visit in a fresh private window.

Set both rollouts back to 50% when you're done.

Troubleshooting

The server never returns a variant

The flag isn't in Local mode, on the sveltekit-server deployment, and Active, the rollout doesn't cover your test device, or AMPLITUDE_SERVER_DEPLOYMENT_KEY holds a client- key. Check the terminal for the [flags] evaluation failed error. To watch the rules load, temporarily pass { logLevel: LogLevel.Debug } to initializeLocal, importing LogLevel from @amplitude/experiment-node-server.

AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set, or PUBLIC_AMPLITUDE_DEPLOYMENT_KEY is not exported

The server key is read at runtime: restart the dev server or npm run preview after editing .env, and in production set it, without a PUBLIC_ prefix, where the Node.js server runs. The client key is read at build time, so it must be in .env locally and in your CI or host's build environment.

Every visitor gets the same variant, or the same device ID

A cache, possibly a CDN "cache everything" rule, is storing a page whose server load calls loadFlags. Check that the response carries cache-control: private, no-store and nothing downstream overrides it. A route with export const prerender = true gets null from loadFlags at build time, so it renders defaults and sends no exposure.

SvelteKit throws an error that the cache-control header is already set

setHeaders sets each header once per response. Your layout or page already sets cache-control; remove it from routes that call loadFlags, which sets it for them.

The console warns that the browser's device ID differs from the server's

The browser didn't adopt the server's ID. Confirm src/hooks.client.ts passes deviceId only when there's no AMP_ cookie and the handoff holds a UUID, and that nothing calls setDeviceId or reset() on load. The pattern assumes the default identityStorage: 'cookie'; with localStorage, the server mints a new ID on every request. A first visit that navigates from an unflagged page to a flagged one within the first second can also do this once; the next full load uses the browser's ID.

No exposure is sent, and no device ID warning appears

serverFlag sends both only after amplitudeReady resolves to true. It resolves to false when PUBLIC_AMPLITUDE_API_KEY is empty or initAll fails, and never resolves if src/hooks.client.ts doesn't call setAmplitudeReady. Check the console for the missing-key warning or [amplitude] initAll failed, and confirm both setAmplitudeReady calls are in the client hook. If initAll failed because a content blocker stopped the guides and surveys or session replay script, flags stop too; if you don't use guides and surveys, add engagement: { skip: true } to the initAll options so it never loads.

The exposure lands on a user the server didn't evaluate

The session ended without the sign-out button, so the server evaluated an anonymous visitor while the browser SDK still holds the old user ID. Run the same reset() and clear() whenever your app ends a session, including on expiry.

The checklist never appears, even at 100%

The browser flag isn't reaching the client deployment. Confirm project-checklist is Active and on the web deployment, and that PUBLIC_AMPLITUDE_DEPLOYMENT_KEY holds the key that starts with client-, not the server key. If no request to api.lab.amplitude.com goes out at all, amplitudeReady never resolved to true; see "No exposure is sent" above.

Anonymous visitors switch variants between visits

Something calls reset() while the user is signed out, which issues a new device ID. Reset only in the sign-out handler. Private windows and cleared cookies also produce a new device ID and bucket.

Hydration warning on the flagged page

Something reads the server flag during render from a source other than the page's data, usually amplitude.experiment()?.variant(). Read server-rendered flags with serverFlag.

Requests go out but nothing shows in the project

EU projects need serverZone: 'EU' at the top level of the initAll options and serverZone: 'eu' in the initializeLocal options, with keys from the EU project.

Full code reference

Every file this tutorial adds or changes, in one block. src/routes/+layout.server.ts and src/app.d.ts stay as they are on the product analytics page. 15 files.

15 files, 486 lines
// -- .env --
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
PUBLIC_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key
AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-key

// -- src/lib/server/experiment.ts --
import { Experiment } from '@amplitude/experiment-node-server';
import { env } from '$env/dynamic/private';

type LocalClient = ReturnType<typeof Experiment.initializeLocal>;
export type ServerVariants = ReturnType<LocalClient['evaluateV2']>;
export type ExperimentUser = {
  user_id?: string;
  device_id?: string;
  user_properties?: Record<string, string>;
};

let client: LocalClient | undefined;
let ready: Promise<void> | undefined;

export function startFlags(): Promise<void> {
  if (!client || !ready) {
    const key = env.AMPLITUDE_SERVER_DEPLOYMENT_KEY;
    if (!key) return Promise.reject(new Error('AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set'));

    const localClient = Experiment.initializeLocal(key);
    client = localClient;
    ready = localClient.start().catch((error) => {
      localClient.stop();
      if (client === localClient) {
        client = undefined;
        ready = undefined;
      }
      throw error;
    });
  }
  return ready;
}

export function stopFlags() {
  client?.stop();
  client = undefined;
  ready = undefined;
}

async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error(`flag rules not loaded within ${ms} ms`)), ms);
  });
  try {
    return await Promise.race([promise, timeout]);
  } finally {
    clearTimeout(timer);
  }
}

export async function evaluateFlags(
  user: ExperimentUser,
  flagKeys: string[],
): Promise<ServerVariants> {
  if (!user.user_id && !user.device_id) return {};

  try {
    const started = startFlags();
    const current = client;
    await withTimeout(started, 1000);
    return current ? current.evaluateV2(user, flagKeys) : {};
  } catch (error) {
    console.error('[flags] evaluation failed; rendering defaults.', error);
    return {};
  }
}

// -- src/hooks.server.ts --
import type { ServerInit } from '@sveltejs/kit';
import { building } from '$app/environment';
import { startFlags, stopFlags } from '$lib/server/experiment';

export const init: ServerInit = () => {
  if (building) return;

  startFlags().catch((error) => {
    console.warn('[flags] could not load flag rules; pages render defaults.', error);
  });

  process.once('sveltekit:shutdown', stopFlags);
};

// Your existing handle (the auth that sets event.locals.user) stays below.

// -- src/lib/amplitude-cookies.ts --
export const DEVICE_ID_HANDOFF_COOKIE = 'amp_server_device_id';

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export function isDeviceId(value: string | undefined): value is string {
  return value !== undefined && UUID.test(value);
}

// -- src/lib/server/flags.ts --
import { randomUUID } from 'node:crypto';
import type { RequestEvent } from '@sveltejs/kit';
import { AmplitudeCookie } from '@amplitude/experiment-node-server';
import { building } from '$app/environment';
import { PUBLIC_AMPLITUDE_API_KEY } from '$env/static/public';
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId } from '$lib/amplitude-cookies';
import type { ServerFlags } from '$lib/flags.svelte';
import { evaluateFlags } from './experiment';

function readDeviceId(event: RequestEvent): string | undefined {
  if (!PUBLIC_AMPLITUDE_API_KEY) return undefined;
  try {
    const cookie = event.cookies.get(AmplitudeCookie.cookieName(PUBLIC_AMPLITUDE_API_KEY, true));
    return cookie ? (AmplitudeCookie.parse(cookie, true).device_id ?? undefined) : undefined;
  } catch {
    return undefined;
  }
}

export async function loadFlags(
  event: RequestEvent,
  flagKeys: string[],
): Promise<ServerFlags | null> {
  if (building) return null;

  event.setHeaders({ 'cache-control': 'private, no-store' });

  let deviceId = readDeviceId(event);
  if (!deviceId) {
    const handoff = event.cookies.get(DEVICE_ID_HANDOFF_COOKIE);
    if (isDeviceId(handoff)) deviceId = handoff;
  }
  if (!deviceId) {
    deviceId = randomUUID();
    event.cookies.set(DEVICE_ID_HANDOFF_COOKIE, deviceId, {
      path: '/',
      httpOnly: false,
      sameSite: 'lax',
      maxAge: 60 * 60,
    });
  }

  const user = event.locals.user;
  const userId = user ? `user-${user.id}` : undefined;

  const variants = await evaluateFlags(
    {
      device_id: deviceId,
      user_id: userId,
      ...(user ? { user_properties: { plan: user.plan ?? 'unknown' } } : {}),
    },
    flagKeys,
  );

  const flags: ServerFlags['flags'] = {};
  for (const [flagKey, variant] of Object.entries(variants)) {
    if (variant?.value && variant.value !== 'off') {
      flags[flagKey] = { value: variant.value, payload: variant.payload };
    }
  }

  return { deviceId, userId, flags };
}

// -- src/lib/amplitude-ready.ts --
let markReady: (ok: boolean) => void = () => {};
export const amplitudeReady = new Promise<boolean>((resolve) => {
  markReady = resolve;
});

export function setAmplitudeReady(init: Promise<unknown> | null) {
  if (!init) {
    markReady(false);
    return;
  }
  init.then(
    () => markReady(true),
    (error) => {
      console.warn('[amplitude] initAll failed', error);
      markReady(false);
    },
  );
}

// -- src/hooks.client.ts --
import type { ClientInit } from '@sveltejs/kit';
import { dev } from '$app/environment';
import {
  PUBLIC_AMPLITUDE_API_KEY,
  PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
} from '$env/static/public';
import * as amplitude from '@amplitude/unified';
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId } from '$lib/amplitude-cookies';
import { setAmplitudeReady } from '$lib/amplitude-ready';

function readCookie(name: string): string | undefined {
  const prefix = `${name}=`;
  const match = document.cookie.split('; ').find((c) => c.startsWith(prefix));
  return match ? decodeURIComponent(match.slice(prefix.length)) : undefined;
}

export const init: ClientInit = () => {
  if (!PUBLIC_AMPLITUDE_API_KEY) {
    console.warn('PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.');
    setAmplitudeReady(null);
    return;
  }

  const hasAmpCookie =
    readCookie(`AMP_${PUBLIC_AMPLITUDE_API_KEY.slice(0, 10)}`) !== undefined;
  const serverDeviceId = readCookie(DEVICE_ID_HANDOFF_COOKIE);
  const adoptServerDeviceId = !hasAmpCookie && isDeviceId(serverDeviceId);

  setAmplitudeReady(
    amplitude.initAll(PUBLIC_AMPLITUDE_API_KEY, {
      analytics: {
        autocapture: {
          attribution: true,
          pageViews: true,
          sessions: true,
          formInteractions: true,
          fileDownloads: true,
          elementInteractions: true,
        },
        ...(adoptServerDeviceId ? { deviceId: serverDeviceId } : {}),
      },
      sessionReplay: {
        sampleRate: dev ? 1 : 0.01,
        debugMode: dev,
        // From /build/sveltekit-session-replay. Keep your masking rules when you paste this file.
        privacyConfig: {
          maskSelector: ['[data-amp-mask]'],
          blockSelector: ['[data-amp-block]'],
          unmaskSelector: ['[data-amp-unmask]'],
        },
      },
      experiment: {
        deploymentKey: PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
        fetchTimeoutMillis: 3000,
      },
    }),
  );
};

// -- src/lib/flags.svelte.ts --
import { dev } from '$app/environment';
import * as amplitude from '@amplitude/unified';
import { amplitudeReady } from '$lib/amplitude-ready';

export type FlagVariant = { value: string; payload?: unknown };
export type ServerFlags = {
  deviceId: string;
  userId?: string;
  flags: Record<string, FlagVariant>;
};

export function serverFlag(getState: () => ServerFlags | null | undefined, flagKey: string) {
  const variant = $derived(getState()?.flags[flagKey]);
  let lastExposure: string | undefined;

  $effect(() => {
    const state = getState();
    const value = state?.flags[flagKey]?.value;
    if (!state || !value) return;

    const key = `${state.userId ?? ''}|${state.deviceId}|${value}`;
    if (key === lastExposure) return;
    lastExposure = key;

    void amplitudeReady.then((ok) => {
      if (!ok) return;
      const browserDeviceId = amplitude.getDeviceId();
      if (dev && browserDeviceId !== state.deviceId) {
        console.warn(
          `[flags] the browser's device ID ${browserDeviceId} differs from the one the server evaluated, ${state.deviceId}.`,
        );
      }

      amplitude.track({
        event_type: '$exposure',
        event_properties: { flag_key: flagKey, variant: value },
        ...(state.userId ? { user_id: state.userId } : {}),
      });
    });
  });

  return {
    get variant() {
      return variant;
    },
  };
}

type FetchUser = { user_properties?: Record<string, string> };

let flagsVersion = $state(0);
let fetchGeneration = 0;

export async function refetchBrowserFlags(user?: FetchUser) {
  const generation = ++fetchGeneration;
  try {
    if (await amplitudeReady) {
      await amplitude.experiment()?.fetch(user);
    }
  } finally {
    if (generation === fetchGeneration) flagsVersion += 1;
  }
}

export function browserFlag(flagKey: string) {
  let flag = $state<{ value: string | null; payload: unknown } | undefined>();

  $effect(() => {
    if (flagsVersion === 0) return;
    const variant = amplitude.experiment()?.variant(flagKey);
    flag = { value: variant?.value ?? null, payload: variant?.payload };
  });

  return {
    get value() {
      return flag?.value ?? null;
    },
    get payload() {
      return flag?.payload;
    },
  };
}

// -- src/lib/templates.ts --
export const TEMPLATES: string[] = ['blank', 'kanban', 'roadmap'];

// -- src/routes/projects/new/+page.server.ts --
import type { PageServerLoad } from './$types';
import { loadFlags } from '$lib/server/flags';

const FLAG_KEYS = ['new-project-flow'];

export const load: PageServerLoad = async (event) => {
  return { amplitude: await loadFlags(event, FLAG_KEYS) };
};

// -- src/routes/projects/new/+page.svelte --
<script lang="ts">
  import { serverFlag } from '$lib/flags.svelte';
  import { TEMPLATES } from '$lib/templates';
  import NewProjectForm from '$lib/components/NewProjectForm.svelte';

  type NewProjectPayload = {
    defaultTemplate?: unknown;
    showTemplateGallery?: unknown;
  };

  let { data } = $props();

  const flag = serverFlag(() => data.amplitude, 'new-project-flow');

  const config = $derived(
    (flag.variant?.value === 'on' ? flag.variant.payload : undefined) as
      | NewProjectPayload
      | undefined,
  );
  const defaultTemplate = $derived.by(() => {
    const candidate = config?.defaultTemplate;
    return typeof candidate === 'string' && TEMPLATES.includes(candidate) ? candidate : 'blank';
  });
  const showGallery = $derived(config?.showTemplateGallery === true);
</script>

{#key `${defaultTemplate}|${showGallery}`}
  <NewProjectForm {defaultTemplate} {showGallery} />
{/key}

// -- src/lib/components/NewProjectForm.svelte --
<script lang="ts">
  import { untrack } from 'svelte';
  import * as amplitude from '@amplitude/unified';
  import { TEMPLATES } from '$lib/templates';
  import ProjectChecklist from '$lib/components/ProjectChecklist.svelte';

  let { defaultTemplate, showGallery }: { defaultTemplate: string; showGallery: boolean } =
    $props();

  let template = $state(untrack(() => defaultTemplate));
  let created = $state(false);

  async function handleCreate() {
    // ... your create logic
    amplitude.track('Project Created', { template });
    created = true;
  }
</script>

{#if showGallery}
  <fieldset>
    <legend>Start from a template</legend>
    {#each TEMPLATES as t (t)}
      <label>
        <input type="radio" name="template" value={t} bind:group={template} />
        {t}
      </label>
    {/each}
  </fieldset>
{:else}
  <select bind:value={template}>
    {#each TEMPLATES as t (t)}
      <option value={t}>{t}</option>
    {/each}
  </select>
{/if}
<button onclick={handleCreate}>Create project</button>

{#if created}
  <ProjectChecklist />
{/if}

// -- src/lib/components/ProjectChecklist.svelte --
<script lang="ts">
  import { browserFlag } from '$lib/flags.svelte';

  const checklist = browserFlag('project-checklist');
</script>

{#if checklist.value === 'on'}
  <aside>
    <h2>Next steps</h2>
    <ul>
      <li>Invite your team</li>
      <li>Import your existing tasks</li>
    </ul>
  </aside>
{/if}

// -- src/routes/+layout.svelte --
<script lang="ts">
  import * as amplitude from '@amplitude/unified';
  import { refetchBrowserFlags } from '$lib/flags.svelte';

  let { data, children } = $props();

  let lastIdentity: string | undefined;

  $effect(() => {
    const id = data.user?.id;
    const plan = data.user?.plan ?? 'unknown';
    const identity = id ? `user-${id}|${plan}` : 'anonymous';
    if (identity === lastIdentity) return;
    lastIdentity = identity;

    if (!id) {
      void refetchBrowserFlags();
      return;
    }

    // Amplitude drops IDs shorter than 5 characters.
    amplitude.setUserId(`user-${id}`);
    const identify = new amplitude.Identify();
    identify.set('plan', plan);
    amplitude.identify(identify);
    void refetchBrowserFlags({ user_properties: { plan } });
  });
</script>

{@render children()}

// -- src/lib/components/SignOutButton.svelte --
<script lang="ts">
  import { enhance } from '$app/forms';
  import * as amplitude from '@amplitude/unified';
</script>

<form
  method="POST"
  action="/logout"
  use:enhance={() => {
    return async ({ result, update }) => {
      if (result.type === 'success' || result.type === 'redirect') {
        amplitude.reset();
        amplitude.experiment()?.clear();
      }
      await update();
    };
  }}
>
  <button>Sign out</button>
</form>

Hand it to your agent

Prefer to have Cursor, Claude Code, or Codex CLI 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
First complete https://amplitude.com/build/sveltekit-product-analytics (or
confirm this app already has its setup: @amplitude/unified initialized in
src/hooks.client.ts, the identity $effect in src/routes/+layout.svelte fed
by src/routes/+layout.server.ts, event.locals.user with id and plan, and
SignOutButton.svelte posting to /logout). Then add feature flags by
following https://amplitude.com/build/sveltekit-feature-flags exactly;
don't reinstall unified or add a second initAll. Install
@amplitude/experiment-node-server. Read AMPLITUDE_SERVER_DEPLOYMENT_KEY
only from $env/dynamic/private in src/lib/server/experiment.ts, which keeps
one local evaluation client per process, calls stop() on a client whose
start() fails, and waits at most 1 second for start() before evaluateV2.
Start it from the init export of src/hooks.server.ts (skip when building)
and stop it with process.once('sveltekit:shutdown'), keeping the existing
handle. Put DEVICE_ID_HANDOFF_COOKIE = "amp_server_device_id" and a UUID
check in src/lib/amplitude-cookies.ts. In src/lib/server/flags.ts,
loadFlags returns null when building, sets cache-control private,
no-store, reads the device ID from the AMP_ cookie with
AmplitudeCookie.cookieName(apiKey, true) and parse(value, true) inside one
try, then from the handoff cookie, and only then mints a UUID and sets the
handoff cookie with path "/", sameSite lax, httpOnly false and a one-hour
maxAge. It evaluates with user_id "user-" plus the user's id and plan in
user_properties, drops variants whose value is missing or "off", and
returns { deviceId, userId, flags } with plain { value, payload } objects.
Create src/routes/projects/new/+page.server.ts, which calls loadFlags for
["new-project-flow"] only and returns it as data.amplitude, and
src/routes/projects/new/+page.svelte, which reads it with serverFlag,
validates the payload's defaultTemplate against TEMPLATES ["blank",
"kanban", "roadmap"] in src/lib/templates.ts and showTemplateGallery ===
true, and renders src/lib/components/NewProjectForm.svelte: a "Start from
a template" radio fieldset when the gallery is on, otherwise a select, and
a Create project button that tracks Project Created with { template } and
then shows src/lib/components/ProjectChecklist.svelte. Add
src/lib/amplitude-ready.ts exporting amplitudeReady and setAmplitudeReady,
which resolves false and logs "[amplitude] initAll failed" if the initAll
promise rejects. In src/hooks.client.ts call setAmplitudeReady(null) in
the missing-key branch and wrap initAll in setAmplitudeReady(...) (if a
setAmplitudeReady wrapper is already there, keep it around initAll); pass
analytics.deviceId from the handoff cookie only if it is a UUID and no
AMP_<first 10 chars of the API key> cookie exists; add experiment with
deploymentKey from PUBLIC_AMPLITUDE_DEPLOYMENT_KEY (a client- key) and
fetchTimeoutMillis 3000. In src/lib/flags.svelte.ts, serverFlag sends
$exposure from an $effect inside amplitudeReady.then, with
event_properties { flag_key, variant } and the server's user_id, only when
a variant exists, once per user ID, device ID and value, and logs a
"[flags]" warning in dev when getDeviceId() differs from the server's
device ID; never send exposures from the server. refetchBrowserFlags waits
for amplitudeReady before experiment()?.fetch(user). browserFlag reads
experiment()?.variant() in an $effect that reruns when a fetch settles,
never during render; ProjectChecklist shows its list when
project-checklist is "on". Call refetchBrowserFlags from the root layout's
identity effect, with user_properties { plan } after setUserId. In the
sign-out handler call reset() and experiment()?.clear() before update(),
and never reset when the user is null. Keep code other SvelteKit pages
added to the root layout and sign-out button. Keep the session replay
sample rate at 1 in development and 0.01 in production, keep debugMode and
the privacyConfig masking selectors, keep any other options already in
src/hooks.client.ts, and tell me how to turn replay off. Don't hardcode
keys and don't invent flag keys. Report every file you changed.

What this unlocks

  • Feature Experiment can run new-project-flow as an A/B test with Project Created as the primary metric, counting only people whose browser rendered the gallery.
  • Session replay can filter to users with the [Experiment] new-project-flow user property, and each replay shows the server-rendered variant from the first frame.
  • Heatmaps on /projects/new, built from those replays, never mix gallery clicks with clicks on a loading state.
  • Guides and surveys can target the [Experiment] project-checklist user property to ask whether the checklist helped.

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.