Chris Tam · Last updated 9 October 2026
Nuxt renders pages on the server, so a feature flag that resolves in the browser shows the old version first. Evaluating it on the server puts the right variant in the first HTML.
In this tutorial, you'll evaluate flags in a Nitro route, match the server's device ID to the browser's, pass variants through the Nuxt payload, and track exposures in the browser. It builds on the Nuxt product analytics page.
What you'll build
A Nuxt app whose /projects/new page reads the new-project-flow flag on the server, renders a template gallery with a preselected template for users in the on variant straight into the server HTML, hydrates without a mismatch or a loading state, and sends one [Experiment] Exposure from the browser after the page mounts. The server and the browser bucket the visitor on the same device ID, including on a first visit. Project Created keeps firing with its template property, so it can serve as the experiment's metric. By the end you'll see the exposure in that user's event stream in Amplitude.
Prerequisites
- The Nuxt app from How to add product analytics to a Nuxt app, with
@amplitude/unified,app/plugins/amplitude.client.ts, theuseAmplitudeIdentitycomposable, andapp/utils/signOut.tsin place. - Server rendering on, which is the Nuxt default (
ssr: true), and a Nitro deployment that runs on Node.js. A long-running server keeps flag rules in memory; serverless deployments download them again on each cold start, and a render that waits more than about a second for them shows the defaults (step 2). - A way for your server routes to read the signed-in user. The code reads
event.context.user, which your auth sets; swap in your own lookup. - Access to Experiment in the same Amplitude project your events go to, so you can create a deployment and a flag.
- This page uses Nuxt 4's default layout, with app code under
app/. On a Nuxt 3 project without anapp/folder, drop theapp/prefix from those paths.nuxt.config.ts,.env, andserver/stay at the project root either way.
No Amplitude account yet? Which Experiment features you can use, including running this 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 a server deployment and a local flag
Experiment has its own keys. A deployment serves a group of flags, and each deployment has a deployment key. A client deployment key starts with client- and is public. A server deployment key starts with server- and is secret, so it belongs only in server code. Your project API key is a third, separate key that sends analytics events. This page evaluates on the server, so it needs a server deployment. The deployment docs cover each type.
Create the deployment:
- Go to Experiment > Deployments, then click Create Deployment.
- Choose the same Amplitude project your analytics events go to. The deployment key has to live in the project you send events to, or exposures won't line up with users.
- Name it after the service, for example
nuxt-server. - Choose Server-side as the type, click Create a Deployment, and copy the key it generates.
Then create the flag:
- Go to Experiment > Feature Flags and click Create A Feature Flag.
- Choose the same project and name the flag
New project flow. Experiment generates the flag keynew-project-flowfrom the name; that key is what your code reads. - Set Evaluation Mode to Local, set the bucketing unit to the user, and click Create.
- Choose the
nuxt-serverdeployment from the Deployment drop-down. - Add a variant named
on. In its Payload field, paste the JSON below, then click Apply. - 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:
{
"defaultTemplate": "kanban",
"showTemplateGallery": true
}Why. Server rendering puts flag evaluation in front of every page, and local evaluation downloads the flag rules once and evaluates in your Nitro process, with no network call per request. It can't resolve the Amplitude ID the way remote evaluation does on Amplitude's servers, which is why the docs for local flags bucket by device ID. That makes step 3 essential: if the server and the browser disagree on the device ID, they disagree on the variant.
Don't name a variant off; Experiment reserves it for users who fall outside the rollout. If you already created new-project-flow as a remote flag for the Vue or React page, create a new local flag instead of switching it: changing the evaluation mode changes bucketing, so the docs say to deactivate a flag before you migrate it. Local evaluation also skips user enrichment and sticky bucketing; if a flag needs those, use the remote alternative at the end of step 2.
2. Install the server SDK and start one client per process
Server-side flags use the Experiment Node.js SDK, a separate package from the unified SDK the browser already runs.
npm install @amplitude/experiment-node-serverAdd the server deployment key at the top level of runtimeConfig, outside public. Nuxt keeps top-level runtime config on the server and only sends public values to the browser.
export default defineNuxtConfig({
runtimeConfig: {
// Server only. Filled from NUXT_AMPLITUDE_SERVER_DEPLOYMENT_KEY.
amplitudeServerDeploymentKey: '',
public: {
// Filled from NUXT_PUBLIC_AMPLITUDE_API_KEY.
amplitudeApiKey: '',
},
},
});NUXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
NUXT_AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-keyNo PUBLIC in the deployment key's variable name. Keep .env out of version control and set both values in your host's secret settings for production.
Create one module in server/utils/ that owns the local evaluation client. Nitro imports everything in server/utils/ into your server routes automatically.
import { Experiment } from '@amplitude/experiment-node-server';
type LocalClient = ReturnType<typeof Experiment.initializeLocal>;
export type ServerVariants = ReturnType<LocalClient['evaluateV2']>;
export type ExperimentUser = { user_id?: string; device_id?: string };
let client: LocalClient | undefined;
let ready: Promise<void> | undefined;
function getClient() {
if (!client) {
const key = useRuntimeConfig().amplitudeServerDeploymentKey;
if (!key) throw new Error('NUXT_AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set');
const localClient = Experiment.initializeLocal(key);
client = localClient;
// start() downloads the flag rules and polls for updates.
ready = localClient.start().catch((error) => {
// Don't leave a poller running for a client we drop, and let the
// next request try again instead of failing forever.
localClient.stop();
client = undefined;
throw error;
});
}
return { client: client!, ready: ready! };
}
// 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 { client, ready } = getClient();
// Wait at most 1 second for the first download, so a cold start or an
// outage renders defaults instead of holding the page.
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('flag rules not loaded in time')), 1000);
});
try {
await Promise.race([ready, timeout]);
} finally {
clearTimeout(timer);
}
return client.evaluateV2(user, flagKeys);
} catch (error) {
console.error('[flags] evaluation failed; rendering defaults.', error);
return {};
}
}Why. The client is created once per server process and the result is computed per request. Flag rules are the same for everyone and refresh on their own every 30 seconds by default, so keeping them in memory is the point of local evaluation. A variant belongs to one visitor, so it's evaluated on each request; the docs measure a single Node.js evaluation at about 0.025 ms. evaluateV2 only reads rules already loaded, which is why every call waits for start() first, but never more than a second: after a deploy or a cold start, a render that would wait longer shows the defaults instead, and the next request evaluates once the rules arrive. If the first download fails, the code calls stop() on that client before dropping it, so a failed client never keeps polling. For updates generally in under a second, pass { streamUpdates: true } to initializeLocal; the SDK falls back to polling if the stream fails. With streaming on, the first connection can take up to 1.5 seconds (the default streamFlagConnTimeoutMillis), which is longer than the 1-second wait, so expect defaults on the first render after a cold start.
Remote evaluation instead
If a flag needs Amplitude ID resolution, user enrichment, or sticky bucketing, keep it in Remote mode on the nuxt-server deployment and swap the client. The Node.js SDK's defaults (a 10-second timeout and 8 retries) are too high for a server render, so set them down as the docs do:
const client = Experiment.initializeRemote(key, {
fetchTimeoutMillis: 500,
fetchRetries: 1,
fetchRetryBackoffMinMillis: 0,
fetchRetryTimeoutMillis: 500,
});
// per request, instead of evaluateV2 (no start() needed):
const variants = await client.fetchV2(user, { flagKeys });Remote evaluation adds a network round trip to each render, averaging about 36 ms on a CDN cache hit and about 194 ms on a miss globally, and it tracks an [Experiment] Assignment event on requests that miss the cache.
3. Evaluate per request 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 route creates a device ID, evaluates with it, and returns it for the browser SDK to adopt in step 4.
import { randomUUID } from 'node:crypto';
import { AmplitudeCookie } from '@amplitude/experiment-node-server';
// Only flags your pages render. Every key here ends up in the page
// payload, where anyone can read it in the page source.
const CLIENT_FLAG_KEYS = ['new-project-flow'];
export default defineEventHandler(async (event) => {
const apiKey = useRuntimeConfig(event).public.amplitudeApiKey;
let deviceId: string | undefined;
if (apiKey) {
// `true` selects the Browser SDK 2 cookie format, which unified uses.
const cookie = getCookie(event, AmplitudeCookie.cookieName(apiKey, true));
if (cookie) {
try {
deviceId = AmplitudeCookie.parse(cookie, true).device_id ?? undefined;
} catch {
deviceId = undefined;
}
}
}
// First visit: no cookie yet. The browser SDK adopts this ID (step 4).
const newDeviceId = !deviceId;
deviceId ??= randomUUID();
// Your auth: replace with however your server reads the signed-in user.
const rawUserId = (event.context.user as { id?: string } | undefined)?.id;
// The same "user-" prefix the browser sets, so both sides send one ID.
const userId = rawUserId ? `user-${rawUserId}` : undefined;
const variants = await evaluateFlags(
{ device_id: deviceId, user_id: userId },
CLIENT_FLAG_KEYS,
);
// Plain objects only, and nothing for users outside the rollout.
const flags: Record<string, { value: string; payload?: unknown }> = {};
for (const [flagKey, variant] of Object.entries(variants)) {
if (variant?.value && variant.value !== 'off') {
flags[flagKey] = { value: variant.value, payload: variant.payload };
}
}
// One visitor's variants; never cache them for anyone else.
setResponseHeader(event, 'cache-control', 'private, no-store');
return { deviceId, newDeviceId, userId, flags };
});Why. The cookie name is AMP_ plus part of your project API key, and AmplitudeCookie.cookieName builds it for you, so the route never hardcodes it. The docs describe the class for exactly this case: keeping the device ID consistent between server-rendered and client-side code before the browser has generated one. CLIENT_FLAG_KEYS limits evaluation to the flags pages render, and the docs warn against putting anything sensitive in payloads, since they're visible in network traffic and logs, and here in the page source too. The docs' AmplitudeCookie example also sets the cookie from the server, but this route runs as an internal sub-request whose Set-Cookie never reaches the browser, so the ID travels in the payload instead and the browser SDK writes its own cookie (step 4).
Local evaluation only sees the properties you pass. If a targeting rule reads a user property such as plan, add user_properties: { plan } to the user object the route passes to evaluateFlags, and add user_properties?: Record<string, unknown> to ExperimentUser in server/utils/experiment.ts.
4. Hand the result to the browser in the Nuxt payload
A server-only plugin calls the route during each server render and stores the result with useState. Nuxt serializes useState values into the page payload, so the browser starts with the exact object the server rendered from.
import { setResponseHeader } from 'h3';
import type { AmplitudeFlags } from '~/composables/useVariant';
// Paths you serve from a cache (swr, isr, or cache route rules, or a CDN).
// Their HTML is shared, so it must never carry one visitor's device ID.
const CACHED_PATH_PREFIXES: string[] = [];
export default defineNuxtPlugin(async () => {
const state = useState<AmplitudeFlags | null>('amplitude-flags', () => null);
// Prerendered HTML is shared by every visitor. Don't freeze one
// visitor's device ID and variants into it.
if (import.meta.prerender) return;
const path = useRequestURL().pathname;
if (CACHED_PATH_PREFIXES.some((prefix) => path.startsWith(prefix))) return;
// From here on, this page's HTML carries one visitor's device ID and
// variants, so no cache may store it.
const event = useRequestEvent();
if (event) setResponseHeader(event, 'cache-control', 'private, no-store');
try {
// useRequestFetch forwards this request's cookies, so the route sees
// the AMP_ cookie and your session.
state.value = await useRequestFetch()('/api/flags');
} catch (error) {
console.error('[flags] could not load flags; rendering defaults.', error);
}
});Then have the browser SDK adopt the server's identity. This file replaces app/plugins/amplitude.client.ts from the product analytics page. What changed: it reads the payload, passes deviceId and userId to initAll, and provides a promise that resolves once init has run.
import * as amplitude from '@amplitude/unified';
import type { AmplitudeFlags } from '~/composables/useVariant';
export default defineNuxtPlugin(() => {
const apiKey = useRuntimeConfig().public.amplitudeApiKey;
if (!apiKey) {
console.warn('NUXT_PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.');
return { provide: { amplitudeReady: Promise.resolve(false) } };
}
// What the server evaluated with, from the page payload.
const server = useState<AmplitudeFlags | null>('amplitude-flags').value;
// Adopt the server's device ID only if this browser has no Amplitude
// cookie yet, so a stale or shared payload can't replace an existing ID.
// Browser SDK 2 names it AMP_ plus the first 10 characters of the key.
const hasAmpCookie = document.cookie
.split('; ')
.some((c) => c.startsWith(`AMP_${apiKey.slice(0, 10)}=`));
const adoptServerDeviceId = Boolean(server?.newDeviceId) && !hasAmpCookie;
// initAll is typed void; wrapping it keeps the .then() safe either way.
const ready = Promise.resolve(
amplitude.initAll(apiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: true,
sessions: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
// First visit only: adopt the device ID the server bucketed.
// Later visits read it from the AMP_ cookie.
...(adoptServerDeviceId ? { deviceId: server!.deviceId } : {}),
// The user the server evaluated, set before the first event.
...(server?.userId ? { userId: server.userId } : {}),
},
sessionReplay: {
// Every session while you build; about 1% in production.
sampleRate: import.meta.dev ? 1 : 0.01,
},
}),
).then(() => true);
return { provide: { amplitudeReady: ready } };
});Why. deviceId in the init config sets the ID the SDK uses, so it's passed only on the first visit, when neither the server nor the browser has an Amplitude cookie. Checking the cookie in the browser too means a stale or shared payload can never replace a device ID the browser already has. From then on the SDK writes and reads its own cookie, and the server reads the same one. Passing userId at init, rather than waiting for useAmplitudeIdentity to run, means the first exposure already carries the user the server evaluated. Keep useAmplitudeIdentity in app/app.vue as it is; it still sets plan.
The server plugin runs on every server-rendered page, not only on pages that read flags, so every page's HTML can carry a new visitor's device ID. That's why it marks the page private, no-store itself: the same header on /api/flags only applies to that internal request, never to the page HTML. List any path you deliberately cache (an swr or isr route rule, or a CDN-cached marketing page) in CACHED_PATH_PREFIXES; those pages render without flags and the browser keeps its own device ID. A visitor who enters on one of those pages and navigates to a flagged page in the browser sees the defaults and sends no exposure until the next full load.
Calling /api/flags through useRequestFetch keeps the secret key and the Node.js SDK inside Nitro. A plain $fetch from a server plugin doesn't forward the visitor's cookies, so the route would see every visitor as new. The server-side rendering docs also show bootstrapping the browser Experiment SDK with initialVariants; this page renders straight from the payload instead, so nothing in the browser needs the SDK's variant store.
5. Render the variant and track the exposure after mount
useVariant reads the variant from the payload during render, on the server and in the browser alike, and sends the exposure once the component has mounted.
import { computed, onMounted } from 'vue';
import * as amplitude from '@amplitude/unified';
export type ServerVariant = { value: string; payload?: unknown };
export type AmplitudeFlags = {
deviceId: string;
newDeviceId: boolean;
userId?: string;
flags: Record<string, ServerVariant>;
};
export function useVariant(flagKey: string, { trackOnMount = true } = {}) {
const state = useState<AmplitudeFlags | null>('amplitude-flags', () => null);
const nuxtApp = useNuxtApp();
const variant = computed(() => state.value?.flags[flagKey]);
function trackExposure() {
const value = variant.value?.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 (!value) return;
void nuxtApp.$amplitudeReady.then((ok) => {
if (ok) amplitude.track('$exposure', { flag_key: flagKey, variant: value });
});
}
// onMounted never runs on the server, and runs after the browser has
// shown the variant.
if (trackOnMount) onMounted(trackExposure);
return { variant, trackExposure };
}The page reads the flag in <script setup>. The variant is already in the payload, so the form's first render, on the server and in the browser, has the payload's settings:
<script setup lang="ts">
import { ref } from 'vue';
import * as amplitude from '@amplitude/unified';
const TEMPLATES = ['blank', 'kanban', 'roadmap'];
type NewProjectPayload = {
defaultTemplate?: unknown;
showTemplateGallery?: unknown;
};
const { variant } = useVariant('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 = (variant.value?.value === 'on' ? variant.value.payload : undefined) as
| NewProjectPayload
| undefined;
const defaultTemplate =
typeof config?.defaultTemplate === 'string' && TEMPLATES.includes(config.defaultTemplate)
? config.defaultTemplate
: 'blank';
const showGallery = config?.showTemplateGallery === true;
const template = ref(defaultTemplate);
async function handleCreate() {
// ... your create logic
amplitude.track('Project Created', { template: template.value });
}
</script>
<template>
<div>
<fieldset v-if="showGallery">
<legend>Start from a template</legend>
<label v-for="t in TEMPLATES" :key="t">
<input v-model="template" type="radio" name="template" :value="t" />
{{ t }}
</label>
</fieldset>
<select v-else v-model="template">
<option v-for="t in TEMPLATES" :key="t" :value="t">{{ t }}</option>
</select>
<button @click="handleCreate">Create project</button>
</div>
</template>Why. The server render and the browser's hydration read the same payload object, so the HTML and the hydrated page match and nothing swaps after load. The server could track the exposure itself, with exposureConfig: { apiKey } on initializeLocal and { tracksExposure: true } on evaluateV2. Don't, for a page like this: the docs point out that server-side local evaluation can't tell when the client renders the experience, and a server render also happens for crawlers, uptime checks, and responses that never finish loading. $exposure with flag_key and variant is the documented exposure event; Amplitude renames it to [Experiment] Exposure and sets the [Experiment] new-project-flow user property, which experiment analysis reads.
Call useVariant('new-project-flow') only in the page that shows the variant. In app/app.vue or a layout it would send exposures for visitors who never opened /projects/new, which dilutes the experiment. If the flagged UI mounts hidden, such as a closed modal, call useVariant('new-project-flow', { trackOnMount: false }) and run the trackExposure() it returns when the modal opens.
MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing flags and events before it writes code, so it reads new-project-flow instead of inventing newProjectFlow. Set up the MCP server.
6. Reload the app on sign-in and sign-out
The server evaluates flags once per page load, and client-side navigation keeps the payload from the first one. When the user changes, a full reload makes the server evaluate again for the new identity. This file replaces app/utils/signOut.ts from the product analytics page:
import * as amplitude from '@amplitude/unified';
export async function signOut() {
// ... your sign-out logic
// Clears the user ID and issues a new device ID for the next person.
amplitude.reset();
// A full load, so the server evaluates the signed-out visitor.
reloadNuxtApp({ path: '/' });
}Call afterSignIn once your sign-in succeeds, instead of navigateTo:
export function afterSignIn(redirectTo = '/') {
// The current payload was evaluated for an anonymous visitor. A full
// load evaluates again with the signed-in user's ID.
reloadNuxtApp({ path: redirectTo });
}Why. The docs say to evaluate again when the signed-in identity changes. With device ID bucketing, a visitor usually keeps the same variant across sign-in, but a targeting rule on the user ID or a passed user property can change it, and a stale payload would render one variant while the user now belongs to another. reset() stays in the sign-out handler only: a null user also describes every anonymous visitor on every load, and resetting there would hand each of them a new device ID and a new bucket.
Verify it worked
Run npm run build && npm run preview so you're testing a production render. While you verify, raise the flag's rollout to 100% so your test user lands in on. Open /projects/new in a private window, sign in as a test user, then check in this order:
- Network tab. In DevTools, select the document request for
/projects/newand open Response. It containsStart from a template, with no loading state, and searching fornew-project-flowfinds the flag in the serialized payload. Set throttling to a slow preset and reload: the gallery arrives with the HTML, nothing swaps after load, and the console shows no hydration mismatch warning. Then filter forapi2.amplitude.com. You should see aPOSTto/2/httpapireturning200after the page renders. - 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.
- 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] Exposurewith[Experiment] Flag Keyset tonew-project-flowand[Experiment] Variantset toon. Create a project andProject Createdappears withtemplate: kanban. - Console. Add
logLevel: amplitude.Types.LogLevel.Debugto theanalyticsblock and reload. You see one$exposureevent withflag_keynew-project-flowandvariantonafter the page renders. Note thedevice_idit carries. - Device ID match. Open
/api/flagsin a new tab of the same private window.deviceIdmatches thedevice_idfrom the console, andnewDeviceIdisfalse, because the server now reads the browser's cookie. The user profile from item 3 carries the same device ID. - Server logs. To watch the flag rules load, temporarily pass
{ logLevel: LogLevel.Debug }toinitializeLocal, importingLogLevelfrom@amplitude/experiment-node-server, and watch the terminal. If you see[flags] evaluation failedinstead, go to Troubleshooting.
Set the rollout back to 50% when you're done.
Troubleshooting
The server never returns a variant
The flag isn't in Local mode, isn't on the nuxt-server deployment, or isn't Active, or the rollout doesn't cover your test user. A key that starts with client- in NUXT_AMPLITUDE_SERVER_DEPLOYMENT_KEY also returns nothing; local evaluation needs the server- key. Check the terminal for the [flags] evaluation failed error.
The terminal says NUXT_AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set
The variable name must match the camelCase key amplitudeServerDeploymentKey under runtimeConfig, with no PUBLIC. The dev server only reads .env at startup, so restart it. In production, set it in the server's environment.
Every visitor gets the same variant, or the same device ID
Some page's HTML is cached or prerendered, so one visitor's payload is served to everyone. The flags plugin runs on every server-rendered page, not only the ones that read flags. Add every path you cache (prerender, swr, isr, or cache route rules, or a CDN) to CACHED_PATH_PREFIXES in app/plugins/experiment.server.ts, and check that other pages' responses carry cache-control: private, no-store.
Signed-in users are evaluated as anonymous
The route doesn't see the session. Call it with useRequestFetch(), not $fetch, so the visitor's cookies are forwarded, and make sure the auth that sets event.context.user also runs for /api/flags. Then end sign-in with afterSignIn so the server evaluates again.
Hydration mismatch warning on the flagged page
Something reads the flag from a source other than the payload during render, usually amplitude.experiment()?.variant() in <script setup>. Read it with useVariant, whose variant is the same object on the server and in the browser.
The exposure lands on a different device ID than the server used
The browser SDK didn't adopt the server's ID on the first visit. Confirm app/plugins/amplitude.client.ts passes deviceId when newDeviceId is true and the browser has no AMP_ cookie for your key, that the page isn't on a path in CACHED_PATH_PREFIXES, and that nothing calls setDeviceId or reset() on load. If the server can't parse an existing AMP_ cookie, the browser keeps its own ID and the first render's variant can differ. This pattern also assumes the browser SDK's default identityStorage: 'cookie'; with localStorage identity, the server never sees a device ID.
The exposure lands on a user the server didn't evaluate
The server saw no session, but the browser SDK still holds a user ID from a session that ended without signOut(), so the exposure goes to that old user while the server evaluated an anonymous visitor. Run signOut() whenever your app ends a session, including when a session expires on its own.
Build fails with "Cannot find module 'h3'"
app/plugins/experiment.server.ts imports setResponseHeader from h3, which ships with Nuxt. With pnpm's strict mode and no hoisting, your app can't resolve it on its own; run pnpm add h3 to add it as a direct dependency.
Two exposures per visit to /projects/new
useVariant('new-project-flow') is called in more than one component on the page, or also in a layout. Call it once, in the page that renders the variant.
Full code reference
Every file this tutorial adds or changes, in one block, the version to hand to an agent. app/app.vue and app/composables/useAmplitudeIdentity.ts from the product analytics page stay as they are. 10 files.
// -- nuxt.config.ts --
export default defineNuxtConfig({
runtimeConfig: {
amplitudeServerDeploymentKey: '',
public: {
amplitudeApiKey: '',
},
},
});
// -- .env --
NUXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
NUXT_AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-key
// -- server/utils/experiment.ts --
import { Experiment } from '@amplitude/experiment-node-server';
type LocalClient = ReturnType<typeof Experiment.initializeLocal>;
export type ServerVariants = ReturnType<LocalClient['evaluateV2']>;
export type ExperimentUser = { user_id?: string; device_id?: string };
let client: LocalClient | undefined;
let ready: Promise<void> | undefined;
function getClient() {
if (!client) {
const key = useRuntimeConfig().amplitudeServerDeploymentKey;
if (!key) throw new Error('NUXT_AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set');
const localClient = Experiment.initializeLocal(key);
client = localClient;
ready = localClient.start().catch((error) => {
localClient.stop();
client = undefined;
throw error;
});
}
return { client: client!, ready: ready! };
}
export async function evaluateFlags(
user: ExperimentUser,
flagKeys: string[],
): Promise<ServerVariants> {
if (!user.user_id && !user.device_id) return {};
try {
const { client, ready } = getClient();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('flag rules not loaded in time')), 1000);
});
try {
await Promise.race([ready, timeout]);
} finally {
clearTimeout(timer);
}
return client.evaluateV2(user, flagKeys);
} catch (error) {
console.error('[flags] evaluation failed; rendering defaults.', error);
return {};
}
}
// -- server/api/flags.get.ts --
import { randomUUID } from 'node:crypto';
import { AmplitudeCookie } from '@amplitude/experiment-node-server';
const CLIENT_FLAG_KEYS = ['new-project-flow'];
export default defineEventHandler(async (event) => {
const apiKey = useRuntimeConfig(event).public.amplitudeApiKey;
let deviceId: string | undefined;
if (apiKey) {
const cookie = getCookie(event, AmplitudeCookie.cookieName(apiKey, true));
if (cookie) {
try {
deviceId = AmplitudeCookie.parse(cookie, true).device_id ?? undefined;
} catch {
deviceId = undefined;
}
}
}
const newDeviceId = !deviceId;
deviceId ??= randomUUID();
const rawUserId = (event.context.user as { id?: string } | undefined)?.id;
const userId = rawUserId ? `user-${rawUserId}` : undefined;
const variants = await evaluateFlags(
{ device_id: deviceId, user_id: userId },
CLIENT_FLAG_KEYS,
);
const flags: Record<string, { value: string; payload?: unknown }> = {};
for (const [flagKey, variant] of Object.entries(variants)) {
if (variant?.value && variant.value !== 'off') {
flags[flagKey] = { value: variant.value, payload: variant.payload };
}
}
setResponseHeader(event, 'cache-control', 'private, no-store');
return { deviceId, newDeviceId, userId, flags };
});
// -- app/plugins/experiment.server.ts --
import { setResponseHeader } from 'h3';
import type { AmplitudeFlags } from '~/composables/useVariant';
const CACHED_PATH_PREFIXES: string[] = [];
export default defineNuxtPlugin(async () => {
const state = useState<AmplitudeFlags | null>('amplitude-flags', () => null);
if (import.meta.prerender) return;
const path = useRequestURL().pathname;
if (CACHED_PATH_PREFIXES.some((prefix) => path.startsWith(prefix))) return;
const event = useRequestEvent();
if (event) setResponseHeader(event, 'cache-control', 'private, no-store');
try {
state.value = await useRequestFetch()('/api/flags');
} catch (error) {
console.error('[flags] could not load flags; rendering defaults.', error);
}
});
// -- app/plugins/amplitude.client.ts --
import * as amplitude from '@amplitude/unified';
import type { AmplitudeFlags } from '~/composables/useVariant';
export default defineNuxtPlugin(() => {
const apiKey = useRuntimeConfig().public.amplitudeApiKey;
if (!apiKey) {
console.warn('NUXT_PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.');
return { provide: { amplitudeReady: Promise.resolve(false) } };
}
const server = useState<AmplitudeFlags | null>('amplitude-flags').value;
const hasAmpCookie = document.cookie
.split('; ')
.some((c) => c.startsWith(`AMP_${apiKey.slice(0, 10)}=`));
const adoptServerDeviceId = Boolean(server?.newDeviceId) && !hasAmpCookie;
const ready = Promise.resolve(
amplitude.initAll(apiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: true,
sessions: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
...(adoptServerDeviceId ? { deviceId: server!.deviceId } : {}),
...(server?.userId ? { userId: server.userId } : {}),
},
sessionReplay: {
sampleRate: import.meta.dev ? 1 : 0.01,
},
}),
).then(() => true);
return { provide: { amplitudeReady: ready } };
});
// -- app/composables/useVariant.ts --
import { computed, onMounted } from 'vue';
import * as amplitude from '@amplitude/unified';
export type ServerVariant = { value: string; payload?: unknown };
export type AmplitudeFlags = {
deviceId: string;
newDeviceId: boolean;
userId?: string;
flags: Record<string, ServerVariant>;
};
export function useVariant(flagKey: string, { trackOnMount = true } = {}) {
const state = useState<AmplitudeFlags | null>('amplitude-flags', () => null);
const nuxtApp = useNuxtApp();
const variant = computed(() => state.value?.flags[flagKey]);
function trackExposure() {
const value = variant.value?.value;
if (!value) return;
void nuxtApp.$amplitudeReady.then((ok) => {
if (ok) amplitude.track('$exposure', { flag_key: flagKey, variant: value });
});
}
if (trackOnMount) onMounted(trackExposure);
return { variant, trackExposure };
}
// -- app/pages/projects/new.vue --
<script setup lang="ts">
import { ref } from 'vue';
import * as amplitude from '@amplitude/unified';
const TEMPLATES = ['blank', 'kanban', 'roadmap'];
type NewProjectPayload = {
defaultTemplate?: unknown;
showTemplateGallery?: unknown;
};
const { variant } = useVariant('new-project-flow');
const config = (variant.value?.value === 'on' ? variant.value.payload : undefined) as
| NewProjectPayload
| undefined;
const defaultTemplate =
typeof config?.defaultTemplate === 'string' && TEMPLATES.includes(config.defaultTemplate)
? config.defaultTemplate
: 'blank';
const showGallery = config?.showTemplateGallery === true;
const template = ref(defaultTemplate);
async function handleCreate() {
// ... your create logic
amplitude.track('Project Created', { template: template.value });
}
</script>
<template>
<div>
<fieldset v-if="showGallery">
<legend>Start from a template</legend>
<label v-for="t in TEMPLATES" :key="t">
<input v-model="template" type="radio" name="template" :value="t" />
{{ t }}
</label>
</fieldset>
<select v-else v-model="template">
<option v-for="t in TEMPLATES" :key="t" :value="t">{{ t }}</option>
</select>
<button @click="handleCreate">Create project</button>
</div>
</template>
// -- app/utils/signOut.ts --
import * as amplitude from '@amplitude/unified';
export async function signOut() {
// ... your sign-out logic
amplitude.reset();
reloadNuxtApp({ path: '/' });
}
// -- app/utils/afterSignIn.ts --
export function afterSignIn(redirectTo = '/') {
reloadNuxtApp({ path: redirectTo });
}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.
Add server-evaluated Amplitude feature flags to this Nuxt app by following
https://amplitude.com/build/nuxt-feature-flags exactly. It builds on
https://amplitude.com/build/nuxt-product-analytics; don't reinstall unified
or add a second initAll. Install @amplitude/experiment-node-server. Put the
server deployment key in runtimeConfig.amplitudeServerDeploymentKey (top
level, never under public), filled from NUXT_AMPLITUDE_SERVER_DEPLOYMENT_KEY,
and only read it in server/utils/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. In
server/api/flags.get.ts, read the device ID from the AMP_ cookie with
AmplitudeCookie.cookieName(apiKey, true) and parse(value, true), generate a
UUID only when there's no cookie, build the user ID with the same "user-"
prefix as the browser, evaluate only CLIENT_FLAG_KEYS, and return plain
{ value, payload } objects with cache-control private, no-store. Call the
route from app/plugins/experiment.server.ts with useRequestFetch, skip it
when prerendering, and store the result in useState('amplitude-flags'). The
flags plugin runs on every server-rendered page: mark that HTML private,
no-store, and skip paths listed in CACHED_PATH_PREFIXES. In
app/plugins/amplitude.client.ts, pass analytics.deviceId only when
newDeviceId is true and the browser has no AMP_ cookie for the API key, and
pass analytics.userId from the payload. Read variants with useVariant in the
page that renders them, never with experiment().variant(), and track
$exposure from onMounted (or from trackExposure when hidden UI opens) only
when there's a variant. Don't track exposures on the server. End sign-in and
sign-out with reloadNuxtApp, and call reset() only from the sign-out
handler. Don't invent flag keys. Report every file you changed. On Nuxt 3
without an app/ folder, drop the app/ prefix from every path; server/ stays
at the root either way.What this unlocks
- Feature Experiment can run
new-project-flowas an A/B test withProject Createdas the primary metric, using exposures that only count people who saw the gallery render. - Session replay, on at 1% in production from the product analytics page, can filter to users with the
[Experiment] new-project-flowuser property, and each replay shows the server-rendered variant from the first frame. - Heatmaps on
/projects/neware built from those replays, and with no swap after load, clicks on the gallery never mix with clicks on a loading state. - Guides and surveys, bundled in the same package, can target the same user property to ask
onusers whether the gallery helped.
One integration; everything else reads from the same events.
Next steps
Reference docs
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.