Chris Tam · Last updated 14 October 2026
A feature flag lets you ship an Angular feature switched off, turn it on for some users, and roll it back without a redeploy. Run it as an experiment and you learn whether it moved your metric.
In this tutorial, you'll read a flag from a signal-based service, send an exposure only when the variant renders, and render the variant on the server with Angular SSR. It builds on the Angular product analytics page.
What you'll build
An Angular app where /projects/new reads the new-project-flow flag through a FlagsService, shows a template gallery to users in the on variant, and sends an [Experiment] Exposure once the form renders. Flags refetch on sign-in and clear on sign-out. With Angular SSR, an optional step evaluates a flag in server.ts with the browser's device ID and renders the gallery in the first HTML. Project Created keeps its template property, so it can serve as the experiment's metric.
Prerequisites
- The Angular app from How to add product analytics to an Angular app, with
@amplitude/unified, both environment files,src/app/amplitude.ts,AuthService,AmplitudeIdentityService, and theHomeandNewProjectroutes in place. - Code uses the Angular 20 file names (
app.ts,App); on Angular 19 useapp.component.tsandAppComponent. - Access to Experiment in the same Amplitude project your events go to, so you can create deployments and flags.
- For step 6 only: Angular SSR (
@angular/ssr) on Angular's server routing engine,AngularNodeAppEngine, with the Expresssrc/server.tsthatng new --ssrgenerates, running as a long-running Node.js server in production.
No Amplitude account yet? Check which plans include Experiment on the pricing page. Sign up for free to get your project API key.
1. Create a client deployment and a flag
Experiment has its own keys. A deployment serves a group of flags through a deployment key: a client- key is publicly viewable and belongs in browser code, and a server- key is secret and stays on your server. Both are separate from your project API key. The deployment docs cover each type.
Create the client deployment:
- Go to Experiment > Deployments, then click Create Deployment.
- Choose the Amplitude project your analytics events go to, or exposures won't line up with users.
- Name it
web, choose Client-side as the type, click Create a Deployment, and copy the key.
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 keynew-project-flowfrom the name; that key is what your code reads. - Leave the evaluation mode on Remote, set the bucketing unit to the user, and click Create.
- Choose the
webdeployment 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 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
}Your code reads defaultTemplate and showTemplateGallery, so you can change the preselected template later without a build. Don't name a variant off; Experiment reserves it for users outside the rollout. Payloads are visible in network traffic, so keep them to configuration.
2. Add the deployment key
@amplitude/unified already bundles Feature Experiment (@amplitude/experiment-js-client), so there's nothing new to install for the browser. Add the client deployment key next to the project API key in both environment files:
export const environment = {
production: true,
amplitudeApiKey: 'your-project-api-key',
amplitudeDeploymentKey: 'your-client-deployment-key',
};export const environment = {
production: false,
amplitudeApiKey: 'your-project-api-key',
amplitudeDeploymentKey: 'your-client-deployment-key',
};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 can live in committed environment files. Never put a server- key here: Angular compiles these files into the browser bundle. Step 6 reads the server key from the process environment.
3. Initialize with Experiment and expose a ready promise
experiment() and getDeviceId() return undefined until initAll has finished. Flags need both, so first add a small module that holds a promise for the rest of the app. The Angular guides and surveys page uses the same file; if you already have it, keep yours.
// 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): void {
if (!init) {
markReady(false);
return;
}
init.then(
() => markReady(true),
(error: unknown) => {
console.warn('[amplitude] initAll failed', error);
markReady(false);
},
);
}Then update src/app/amplitude.ts: both branches report to setAmplitudeReady, and there's a new experiment block. Edit the file in place and keep the lines its first comment names. The debugMode and masking selectors come from the session replay page; they're harmless if you haven't done it. src/main.ts doesn't change.
// Keep lines other Angular pages added here: pathOnly page views
// (/build/angular-web-analytics), masking (/build/angular-session-replay),
// and the webExperiment.plugin() block (/build/angular-web-experiment).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';
import { setAmplitudeReady } from './amplitude-ready';
export function initAmplitude(): void {
if (!environment.amplitudeApiKey) {
console.warn('amplitudeApiKey is not set; analytics is off.');
setAmplitudeReady(null);
return;
}
// Flags wait for this promise before they read from the SDK.
setAmplitudeReady(
amplitude.initAll(environment.amplitudeApiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: true,
sessions: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
},
sessionReplay: {
// Every session while you build; about 1% in production.
sampleRate: environment.production ? 0.01 : 1,
debugMode: !environment.production,
// From /build/angular-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 from step 1.
deploymentKey: environment.amplitudeDeploymentKey,
// Default is 10 seconds. Fall back to the current UI sooner.
fetchTimeoutMillis: 3000,
// Only the component that shows a variant sends its exposure (step 5).
automaticExposureTracking: false,
},
}),
);
}Why. setAmplitudeReady runs on every path, including the missing-key branch, so code that waits on amplitudeReady never hangs. automaticExposureTracking: false means variant() never tracks on its own, so reading a flag in a guard or a service never counts someone as exposed. It applies to every flag in the app: read flags through injectFlag (step 4), or call experiment()?.exposure(flagKey) wherever you show a variant. Set sampleRate to 0 to turn session replay off. Unified also initializes guides and surveys by default; if you don't use them, the Unified SDK docs describe engagement: { skip: true } to stop that SDK loading.
4. Fetch flags after identity, clear them on sign-out
Remote evaluation assigns variants to a user, so fetch again when the user changes. This service owns the fetch and a version signal that goes up each time a fetch settles, so components know when to read.
import {
Injectable,
PLATFORM_ID,
Signal,
afterNextRender,
effect,
inject,
signal,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import * as amplitude from '@amplitude/unified';
import { amplitudeReady } from './amplitude-ready';
export type FlagState = { value: string | null; payload: unknown };
type FetchUser = { user_properties?: Record<string, string> };
@Injectable({ providedIn: 'root' })
export class FlagsService {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly settled = signal(0);
private generation = 0;
// Goes up by one each time a fetch settles. 0 means the first fetch
// hasn't settled yet.
readonly version = this.settled.asReadonly();
async refetch(user?: FetchUser): Promise<void> {
if (!this.isBrowser) return;
const generation = ++this.generation;
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 updates components, so a slow anonymous
// fetch can't overwrite the signed-in result.
if (generation === this.generation) this.settled.update((v) => v + 1);
}
}
}
// Reads a browser-fetched flag in the component that shows it and sends
// an exposure once it renders. Call it from a field initializer.
export function injectFlag(flagKey: string): Signal<FlagState | undefined> {
const flags = inject(FlagsService);
const state = signal<FlagState | undefined>(undefined);
// afterNextRender never runs on the server.
const rendered = signal(false);
afterNextRender(() => rendered.set(true));
let lastExposure: string | undefined;
effect(() => {
if (!rendered() || flags.version() === 0) return;
const experiment = amplitude.experiment();
const variant = experiment?.variant(flagKey);
const value = variant?.value ?? null;
state.set({ value, payload: variant?.payload });
// 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;
// Skips a repeat exposure when a fetch settles without changing the
// identity or the variant.
const key = `${amplitude.getUserId() ?? ''}|${amplitude.getDeviceId() ?? ''}|${value}`;
if (key === lastExposure) return;
lastExposure = key;
experiment?.exposure(flagKey);
});
return state.asReadonly();
}Fetch from the identity effect, right after the user ID is set. This replaces src/app/amplitude-identity.service.ts from the product analytics page; what changed is the FlagsService injection and the two refetch calls.
import { Injectable, PLATFORM_ID, computed, effect, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import * as amplitude from '@amplitude/unified';
import { AuthService } from './auth.service';
import { FlagsService } from './flags.service';
@Injectable({ providedIn: 'root' })
export class AmplitudeIdentityService {
private readonly auth = inject(AuthService);
private readonly flags = inject(FlagsService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
// Only the ID and plan matter. A new user object with the same
// values doesn't trigger another Identify or fetch.
private readonly identity = computed(
() => {
const user = this.auth.user();
return user ? { id: user.id, plan: user.plan ?? 'unknown' } : null;
},
{ equal: (a, b) => a?.id === b?.id && a?.plan === b?.plan },
);
constructor() {
// Angular runs effects during server rendering too.
if (!this.isBrowser) return;
effect(() => {
const identity = this.identity();
if (!identity) {
// Anonymous or signed out: keep the device ID and fetch for it.
// Reset only from your sign-out method.
void this.flags.refetch();
return;
}
// Amplitude drops IDs shorter than 5 characters. Use the same
// string your backend sends as user_id.
amplitude.setUserId(`user-${identity.id}`);
const identify = new amplitude.Identify();
identify.set('plan', identity.plan);
amplitude.identify(identify);
// Pass plan with the request, so targeting sees it on this evaluation.
void this.flags.refetch({ user_properties: { plan: identity.plan } });
});
}
}Then clear the previous user's variants on sign-out and fetch for the new device. Update src/app/auth.service.ts: inject FlagsService and add the clear() and refetch() lines after reset(). If you use your own auth, make the same two additions to your sign-out method. If you followed the guides and surveys page, keep its getEngagement import and the closeAll() line before reset().
// Keep lines other Angular pages added here: the getEngagement import and
// getEngagement()?.gs.closeAll() before reset() (/build/angular-guides-surveys).
import { Injectable, inject, signal } from '@angular/core';
import * as amplitude from '@amplitude/unified';
import { FlagsService } from './flags.service';
export interface User {
id: string;
plan?: string;
}
// Stand-in for your auth. Replace the bodies with your own logic.
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly flags = inject(FlagsService);
readonly user = signal<User | null>(null);
signIn(user: User): void {
// ... your sign-in logic
this.user.set(user);
}
async signOut(): Promise<void> {
// ... your sign-out logic
this.user.set(null);
// Clears the user ID and issues a new device ID for the next person.
amplitude.reset();
// Drops the previous user's variants.
amplitude.experiment()?.clear();
// Fetches for the new device, even if the identity effect already ran.
void this.flags.refetch();
}
}Why. The docs recommend fetching again when the user changes and passing new user properties with the request, because waiting for Amplitude to sync them can race the evaluation. The anonymous branch makes the first fetch on every page load. The refetch() after clear() gives the new device variants whatever order your auth library updates the user signal in.
5. Read the variant and send the exposure
The template list and the payload validation live in one module, so the page and step 6 share them:
export const TEMPLATES: string[] = ['blank', 'kanban', 'roadmap'];
export type NewProjectConfig = { defaultTemplate: string; showGallery: boolean };
type NewProjectPayload = { defaultTemplate?: unknown; showTemplateGallery?: unknown };
// Only the "on" variant carries the new settings. Validate the payload
// and keep the current defaults for anything missing or unexpected.
export function newProjectConfig(
variant: { value: string | null; payload?: unknown } | undefined,
): NewProjectConfig {
const payload = (variant?.value === 'on' ? variant.payload : undefined) as
| NewProjectPayload
| undefined;
const candidate = payload?.defaultTemplate;
return {
defaultTemplate:
typeof candidate === 'string' && TEMPLATES.includes(candidate) ? candidate : 'blank',
showGallery: payload?.showTemplateGallery === true,
};
}
export function sameConfig(a?: NewProjectConfig, b?: NewProjectConfig): boolean {
return a?.defaultTemplate === b?.defaultTemplate && a?.showGallery === b?.showGallery;
}The form moves into its own component, which takes the config as an input and tracks Project Created as before:
import { Component, input, linkedSignal } from '@angular/core';
import * as amplitude from '@amplitude/unified';
import { TEMPLATES, type NewProjectConfig } from './new-project-config';
@Component({
selector: 'app-new-project-form',
template: `
@if (config().showGallery) {
<fieldset>
<legend>Start from a template</legend>
@for (t of templates; track t) {
<label>
<input
type="radio"
name="template"
[value]="t"
[checked]="template() === t"
(change)="template.set(t)"
/>
{{ t }}
</label>
}
</fieldset>
} @else {
<select #picker (change)="template.set(picker.value)">
@for (t of templates; track t) {
<option [value]="t" [selected]="template() === t">{{ t }}</option>
}
</select>
}
<button type="button" (click)="create()">Create project</button>
`,
})
export class NewProjectForm {
readonly config = input.required<NewProjectConfig>();
protected readonly templates = TEMPLATES;
// Starts at the variant's default and resets if the config changes.
protected readonly template = linkedSignal(() => this.config().defaultTemplate);
protected create(): void {
// ... your create logic
amplitude.track('Project Created', { template: this.template() });
}
}The page reads the flag and shows the form once the first fetch settles. This replaces src/app/projects/new-project.ts from the product analytics page; src/app/app.routes.ts doesn't change.
import { Component, computed } from '@angular/core';
import { injectFlag } from '../flags.service';
import { newProjectConfig, sameConfig } from './new-project-config';
import { NewProjectForm } from './new-project-form';
@Component({
selector: 'app-new-project',
imports: [NewProjectForm],
template: `
@if (config(); as cfg) {
<app-new-project-form [config]="cfg" />
} @else {
<div aria-busy="true" style="min-height: 320px"></div>
}
`,
})
export class NewProject {
private readonly flag = injectFlag('new-project-flow');
// undefined until the first fetch settles. A later fetch with the same
// settings keeps the user's template choice.
protected readonly config = computed(
() => {
const flag = this.flag();
return flag ? newProjectConfig(flag) : undefined;
},
{ equal: sameConfig },
);
}Why. injectFlag reads variant() only after the component has rendered in the browser, then calls exposure(), so only someone who reached /projects/new counts as exposed. A later fetch adds an exposure only when the identity or the variant changed. The loading block holds the form's height for at most the 3-second fetchTimeoutMillis; on a timeout, the form renders the defaults.
Call injectFlag only in the component that shows the variant, from a field initializer or the constructor. In App or a layout it sends exposures for visitors who never opened /projects/new.
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.
6. With Angular SSR, evaluate on the server for first paint
Skip this step if your app doesn't use @angular/ssr. With it, steps 1 to 5 already work, but the server renders the loading block and the variant arrives after a browser fetch. This step evaluates a flag in src/server.ts and renders the gallery in the first HTML. The exposure still goes out from the browser.
This step targets Angular's server routing engine, AngularNodeAppEngine. It's the default in Angular 20, and Angular 19 apps use it when they were created with server routing (they have src/app/app.routes.server.ts). If your src/server.ts imports CommonEngine instead, this step doesn't cover your app. Steps 1 to 5 work unchanged for you, with browser-evaluated flags.
6a. Create a server deployment and a local flag
- Go to Experiment > Deployments and create a second deployment named
angular-serverin the same project, with Server-side as the type. Copy its key, which starts withserver-. - Go to Experiment > Feature Flags, click Create A Feature Flag, and name it
New project flow SSR. Experiment generates the keynew-project-flow-ssr. - Set Evaluation Mode to Local, set the bucketing unit to the user, and click Create.
- Choose the
angular-serverdeployment, add a variant namedonwith the same payload as step 1, and click Apply. - In the All Other Users section, set bucketed by to Device ID and the rollout to 50%. Save, then set the flag to Active.
The bucketing unit stays the user for analysis; bucketed by sets the ID the hash uses, and local evaluation needs Device ID.
Why. Local evaluation downloads the flag rules once and evaluates them in your Node.js process, with no network call per request. It can't resolve the Amplitude ID, so the docs for local flags bucket by device ID, which is why the server and the browser must agree on it (6c and 6d). This is a new flag rather than a switch of new-project-flow: the docs warn that migrating a flag to local evaluation changes its bucketing.
6b. Start one local evaluation client per server process
Install the Experiment Node.js SDK for the server:
npm install @amplitude/experiment-node-serverServer code lives in src/server/, which only src/server.ts imports, so it never reaches the browser bundle. It reads the server key from the process environment at runtime:
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;
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 (!ready) {
const key = process.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. If it
// fails, the next request starts over.
ready = localClient.start().catch((error: unknown) => {
localClient.stop();
client = undefined;
ready = undefined;
throw error;
});
}
return 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 {};
let timer: ReturnType<typeof setTimeout> | undefined;
try {
// A cold start or outage renders defaults instead of holding the page.
await Promise.race([
startFlags(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('flag rules not loaded within 1 s')), 1000);
}),
]);
return client ? client.evaluateV2(user, flagKeys) : {};
} catch (error) {
console.error('[flags] evaluation failed; rendering defaults.', error);
return {};
} finally {
clearTimeout(timer);
}
}Set the key in the environment of the process that runs the server, never in src/environments/:
export AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-keyWhy. Flag rules are the same for everyone and refresh every 30 seconds by default, so one client per process is enough and each request only evaluates in memory. evaluateV2 only reads rules that are already loaded, so every call waits for start(), but never more than a second. The Node.js local evaluation page covers streaming updates, cohort targeting and server-side exposure tracking, and the Node.js feature flags page covers remote evaluation on a server.
6c. Evaluate per request with the browser's device ID
The browser SDK stores its device ID in the AMP_ cookie, and the Node.js SDK's AmplitudeCookie class finds and parses it. On a first visit there's no cookie 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:
// 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);
}wantsServerFlags decides which page requests get evaluated, and loadFlags evaluates them:
import { randomUUID } from 'node:crypto';
import type { Request, Response } from 'express';
import { AmplitudeCookie } from '@amplitude/experiment-node-server';
import { environment } from '../environments/environment';
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId } from '../app/amplitude-cookies';
import type { ServerFlags } from '../app/server-flags';
import { evaluateFlags } from './experiment';
// Server-rendered flags only. Their variants are readable in the page source.
const FLAG_KEYS = ['new-project-flow-ssr'];
// Pages evaluated per visitor and sent as private. Each one also needs
// RenderMode.Server in src/app/app.routes.server.ts.
const FLAG_PATHS = new Set(['/projects/new']);
export function wantsServerFlags(req: Request): boolean {
// originalUrl, not req.path: a handler mounted on '/**' sees req.path as '/'.
const path = new URL(req.originalUrl, 'http://localhost').pathname;
return Boolean(req.headers.accept?.includes('text/html')) && FLAG_PATHS.has(path);
}
// Your auth: replace with however your server reads the signed-in user.
type ServerUser = { id: string; plan?: string };
function readCookie(req: Request, name: string): string | undefined {
const prefix = `${name}=`;
const match = req.headers.cookie?.split(/;\s*/).find((c) => c.startsWith(prefix));
if (!match) return undefined;
try {
return decodeURIComponent(match.slice(prefix.length));
} catch {
return undefined;
}
}
function readDeviceId(req: Request): string | undefined {
if (!environment.amplitudeApiKey) 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 = readCookie(req, AmplitudeCookie.cookieName(environment.amplitudeApiKey, true));
return cookie ? (AmplitudeCookie.parse(cookie, true).device_id ?? undefined) : undefined;
} catch {
return undefined;
}
}
export async function loadFlags(req: Request, res: Response): Promise<ServerFlags> {
// One visitor's device ID and variants: no cache may store this page.
res.setHeader('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 in 6d.
let deviceId = readDeviceId(req);
if (!deviceId) {
const handoff = readCookie(req, DEVICE_ID_HANDOFF_COOKIE);
if (isDeviceId(handoff)) deviceId = handoff;
}
if (!deviceId) {
deviceId = randomUUID();
res.cookie(DEVICE_ID_HANDOFF_COOKIE, deviceId, {
path: '/',
httpOnly: false, // src/app/amplitude.ts reads it
sameSite: 'lax',
maxAge: 60 * 60 * 1000, // one hour; Express takes milliseconds
});
}
// Set by your auth middleware, if any. Same "user-" prefix as the browser.
const user = (req as Request & { user?: ServerUser }).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' } } : {}),
},
FLAG_KEYS,
);
// 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 };
}Then call it from src/server.ts and pass the result to Angular as the request context. Edit your generated file in place; don't replace it. Below is the Angular 20 file with three additions marked // Added: the imports, the loadFlags call with the second argument to handle, and startFlags() at startup. Angular 19 generates a slightly different file (a '/**' path on the last app.use, a .then() chain); keep its lines and make the same three additions. The browserDistFolder and listen lines here compile on both versions.
// Keep lines other Angular pages added here: the header middleware
// (/build/angular-web-experiment) and the withCampaign import and /old-landing
// redirect (/build/angular-web-analytics), both above the Angular handler.
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startFlags } from './server/experiment'; // Added
import { loadFlags, wantsServerFlags } from './server/flags'; // Added
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: false,
redirect: false,
}),
);
app.use(async (req, res, next) => {
try {
// Added: flagged pages only, each evaluated for this visitor. Angular
// reads the second handle() argument through the REQUEST_CONTEXT token.
const amplitudeFlags = wantsServerFlags(req) ? await loadFlags(req, res) : null;
const response = await angularApp.handle(req, { amplitudeFlags });
if (response) {
await writeResponseToNodeResponse(response, res);
} else {
next();
}
} catch (error) {
next(error);
}
});
if (isMainModule(import.meta.url)) {
// Added: start downloading flag rules now; early requests wait up to 1 s.
startFlags().catch((error: unknown) => {
console.warn('[flags] could not load flag rules; pages render defaults.', error);
});
const port = process.env['PORT'] || 4000;
app.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
export const reqHandler = createNodeRequestHandler(app);The route has to render per request, not at build time. Add projects/new with RenderMode.Server above the catch-all in src/app/app.routes.server.ts:
// Keep lines other Angular pages added here: the plans, offer/:code and
// account routes (/build/angular-web-analytics) and projects/:projectId
// (/build/angular-heatmaps). Merge into your existing array.
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// Rendered per request, so the server can evaluate flags for each visitor.
{ path: 'projects/new', renderMode: RenderMode.Server },
{ path: '**', renderMode: RenderMode.Prerender },
];Why. Reusing the handoff ID covers a second request that arrives before the browser JavaScript runs, so one visitor never gets two IDs. cache-control: private, no-store goes on every page loadFlags evaluates, because even an anonymous response carries a device ID that a shared cache would hand to everyone. Prerendered HTML is shared the same way, which is why the flagged route renders per request.
6d. Adopt the server's device ID and render from TransferState
Update src/app/amplitude.ts again. What changed from step 3: it reads the handoff cookie and passes it as analytics.deviceId when the browser has no AMP_ cookie yet. Without SSR the handoff cookie never exists, so this file behaves exactly like step 3's.
// Keep lines other Angular pages added here: pathOnly page views
// (/build/angular-web-analytics), masking (/build/angular-session-replay),
// and the webExperiment.plugin() block (/build/angular-web-experiment).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';
import { setAmplitudeReady } from './amplitude-ready';
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId } from './amplitude-cookies';
function readCookie(name: string): string | undefined {
const prefix = `${name}=`;
const match = document.cookie.split('; ').find((c) => c.startsWith(prefix));
// A malformed cookie must not stop the app from starting.
try {
return match ? decodeURIComponent(match.slice(prefix.length)) : undefined;
} catch {
return undefined;
}
}
export function initAmplitude(): void {
if (!environment.amplitudeApiKey) {
console.warn('amplitudeApiKey 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_${environment.amplitudeApiKey.slice(0, 10)}`) !== undefined;
const serverDeviceId = readCookie(DEVICE_ID_HANDOFF_COOKIE);
const adoptServerDeviceId = !hasAmpCookie && isDeviceId(serverDeviceId);
setAmplitudeReady(
amplitude.initAll(environment.amplitudeApiKey, {
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.
...(adoptServerDeviceId ? { deviceId: serverDeviceId } : {}),
},
sessionReplay: {
sampleRate: environment.production ? 0.01 : 1,
debugMode: !environment.production,
// From /build/angular-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: environment.amplitudeDeploymentKey,
fetchTimeoutMillis: 3000,
automaticExposureTracking: false,
},
}),
);
}ServerFlagsService moves the server's result into TransferState and reads it back in the browser. injectServerFlag returns the variant during render and sends the exposure after the component appears:
import {
Injectable,
REQUEST_CONTEXT,
TransferState,
afterNextRender,
inject,
makeStateKey,
} from '@angular/core';
import * as amplitude from '@amplitude/unified';
import { amplitudeReady } from './amplitude-ready';
export type FlagVariant = { value: string; payload?: unknown };
export type ServerFlags = {
deviceId: string;
userId?: string;
flags: Record<string, FlagVariant>;
};
const SERVER_FLAGS = makeStateKey<ServerFlags | null>('amplitude-server-flags');
@Injectable({ providedIn: 'root' })
export class ServerFlagsService {
// What the server evaluated for this page load, or null without SSR.
readonly state: ServerFlags | null;
constructor() {
const transferState = inject(TransferState);
// Server: the object src/server.ts passed to handle(). Browser: none.
const context = inject(REQUEST_CONTEXT, { optional: true }) as {
amplitudeFlags?: ServerFlags | null;
} | null;
if (context?.amplitudeFlags) {
transferState.set(SERVER_FLAGS, context.amplitudeFlags);
}
this.state = transferState.get(SERVER_FLAGS, null);
}
}
// One exposure per flag per page load; client-side navigations don't resend it.
const sentServerExposures = new Set<string>();
// Reads a server-evaluated flag during render and sends one exposure after
// it renders, if the browser's identity still matches the server's.
export function injectServerFlag(flagKey: string): FlagVariant | undefined {
const state = inject(ServerFlagsService).state;
const variant = state?.flags[flagKey];
// afterNextRender never runs on the server.
afterNextRender(() => {
const value = variant?.value;
// No variant means the user isn't in the rollout: send nothing.
if (!state || !value) return;
// getDeviceId() is undefined until initAll settles.
void amplitudeReady.then((ok) => {
if (!ok) return;
const browserDeviceId = amplitude.getDeviceId();
const browserUserId = amplitude.getUserId();
// After a sign-out or a different sign-in in this tab, the variant on
// screen isn't this visitor's any more: send nothing.
const sameDevice = browserDeviceId === state.deviceId;
const sameUser = !state.userId || !browserUserId || browserUserId === state.userId;
if (!sameDevice || !sameUser) return;
if (sentServerExposures.has(flagKey)) return;
sentServerExposures.add(flagKey);
amplitude.track({
event_type: '$exposure',
event_properties: { flag_key: flagKey, variant: value },
// The user the server evaluated, if the browser hasn't set one yet.
...(state.userId && !browserUserId ? { user_id: state.userId } : {}),
});
});
});
return variant;
}Create the service from the root component, so the transfer happens on whichever evaluated page the visitor enters on. Add two lines to your existing src/app/app.ts (on Angular 19, app.component.ts): the import and the inject(ServerFlagsService) call in the constructor. Leave the rest of the file as it is.
// Keep lines other Angular pages added here: nav links (/build/angular-session-replay,
// /build/angular-heatmaps), and EngagementBridgeService and <app-feedback-button />
// (/build/angular-guides-surveys).
import { ServerFlagsService } from './server-flags';
constructor() {
inject(AmplitudeIdentityService);
// Copies the server's flags into TransferState on every server render
// that evaluated them, and reads them back in the browser.
inject(ServerFlagsService);
}Finally, have the page read the server flag. This version of src/app/projects/new-project.ts replaces step 5's and has no loading block:
import { Component } from '@angular/core';
import { injectServerFlag } from '../server-flags';
import { newProjectConfig } from './new-project-config';
import { NewProjectForm } from './new-project-form';
@Component({
selector: 'app-new-project',
imports: [NewProjectForm],
template: `<app-new-project-form [config]="config" />`,
})
export class NewProject {
// Evaluated on the server, so the first HTML already has the variant.
protected readonly config = newProjectConfig(injectServerFlag('new-project-flow-ssr'));
}Why. The Set-Cookie from 6c arrives with the page, so the handoff cookie is readable when initAll runs in main.ts. deviceId is passed only when the browser has no AMP_ cookie, so a stale handoff never replaces an existing device ID. TransferState serializes the server's object into the page, so the server render and hydration read the same variant. ServerFlagsService is created per request on the server, so one visitor's variants never leak into another's render. Keep exposure in the browser: server renders also happen for crawlers and for responses that never finish loading.
Server variants are evaluated once per full page load. A visitor who enters on a page outside FLAG_PATHS, such as a prerendered Home, sees the defaults on /projects/new until the next full load.
Verify it worked
Raise the rollouts of the flags you created to 100% while you verify, so your test device lands in on. Set them back to 50% when you're done.
Browser flags (steps 1 to 5)
Run these after step 5. After step 6, /projects/new reads the server flag instead, and the next section covers it. Run ng serve, open http://localhost:4200, click Sign in as test user, go to New project, and create a project. Check in this order:
- Network tab. Filter for
lab.amplitude.com. You should see one or two requests toapi.lab.amplitude.comafter the app loads, and another after you sign in; the response includesnew-project-flow. Then filter forapi2.amplitude.comand look for aPOSTto/2/httpapireturning200. - 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
user-42. Within ten seconds to a minute, the event stream shows[Experiment] Exposurewith[Experiment] Flag Keyset tonew-project-flowand[Experiment] Variantset toon, thenProject Createdwithtemplate: kanban. You may also see an[Experiment] Assignmentevent from the remote fetch. Amplitude renames$exposureto[Experiment] Exposureon ingestion. - Console. Add
logLevel: amplitude.Types.LogLevel.Debugto theanalyticsblock while you verify, then openhttp://localhost:4200in a private window. Home logs no$exposure. On New project, once the form renders, the console shows a$exposurewithflag_keynew-project-flowandvarianton. Signing in or out with the page open adds one for the new identity.
Server-evaluated flags (SSR)
Run npm run build, then start the server with AMPLITUDE_SERVER_DEPLOYMENT_KEY set, using the serve:ssr: script ng new --ssr added to package.json. Open http://localhost:4000/projects/new in a private window. These checks use the Network tab and work in a production build:
- Document response. Select the document request for
/projects/new. Its headers includecache-control: private, no-storeand, on the first load, aset-cookieforamp_server_device_id. The response containsStart from a template, noaria-busyblock, and anng-statescript withnew-project-flow-ssr. The console shows no hydration warning. - Exposure and device ID. Filter for
api2.amplitude.com, open the/2/httpapirequest, then use the Instrumentation Explorer Chrome extension (the SDK compresses larger request bodies) to find the$exposureevent whoseevent_properties.flag_keyisnew-project-flow-ssr. Itsdevice_idequals theamp_server_device_idvalue under Application > Cookies. Reload, and the document response no longer sets the handoff cookie, because the server now reads the browser'sAMP_cookie. - Client-side navigation. Go to Home, then back to New project. No new
/2/httpapirequest carries a$exposurefornew-project-flow-ssr; the next full reload sends one. Theapi.lab.amplitude.comrequests still go out, because the browser keeps fetching flags for components that useinjectFlag.
Troubleshooting
The variant is always empty, for every user
Confirm new-project-flow is Active with the web deployment selected, in the same project as your API key, and that amplitudeDeploymentKey holds the client- key.
The page stays on the loading block
No fetch ever settled, because nothing called FlagsService.refetch. Check that the App constructor still injects AmplitudeIdentityService and that it's the step 4 version with the anonymous refetch() branch.
No request to api.lab.amplitude.com
amplitudeReady resolved to false. Check the console for the missing-key warning or [amplitude] initAll failed, and confirm both setAmplitudeReady calls are in src/app/amplitude.ts. If initAll rejected, flags stop too.
Exposures for visitors who never opened /projects/new
injectFlag('new-project-flow') runs outside the component that shows the variant, such as in App or a layout, or other code tracks $exposure for the same flag. Call injectFlag only in the component that shows the variant.
Anonymous visitors switch variants between visits
Something calls reset() while the user is signed out, which issues a new device ID. Reset only in signOut(). Private windows and cleared cookies also produce a new device ID and bucket.
With SSR, the server renders the defaults
Check the server log for [flags] evaluation failed. Confirm that new-project-flow-ssr is Local, on the angular-server deployment, and Active; that AMPLITUDE_SERVER_DEPLOYMENT_KEY holds the server- key; and that projects/new is RenderMode.Server. A response without cache-control: private, no-store means server.ts never evaluated the page; with CommonEngine, step 6 doesn't apply. To watch the rules load, temporarily pass { logLevel: LogLevel.Debug } to initializeLocal, importing LogLevel from @amplitude/experiment-node-server.
With SSR, wrong or shared variants
If every visitor gets the same variant or device ID, a cache downstream is storing evaluated pages despite cache-control: private, no-store. If a hydration warning appears on the flagged page, a component reads new-project-flow-ssr through injectFlag or variant() instead of injectServerFlag, so the browser's answer can differ from the HTML.
With SSR, no exposure for new-project-flow-ssr
amplitudeReady resolved to false, or the page's ng-state script has no new-project-flow-ssr because the flag doesn't cover this device or the visitor entered on another page. After a sign-out or a different sign-in in the tab, injectServerFlag skips the exposure until the next full load, by design. On a first load, confirm src/app/amplitude.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. With identityStorage: 'localStorage' instead of the default cookie, the server mints a new ID on every request.
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 two blocks, the version to hand to an agent. Edit shared files in place and keep the lines each file's first comment names. Unchanged from earlier pages; keep your versions: src/main.ts, src/app/app.routes.ts, src/app/home.ts, and src/app/app.ts (step 6 adds two lines to it).
First, client-side flags, for every app (10 files):
// -- src/environments/environment.ts --
export const environment = {
production: true,
amplitudeApiKey: 'your-project-api-key',
amplitudeDeploymentKey: 'your-client-deployment-key',
};
// -- src/environments/environment.development.ts --
export const environment = {
production: false,
amplitudeApiKey: 'your-project-api-key',
amplitudeDeploymentKey: 'your-client-deployment-key',
};
// -- src/app/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): void {
if (!init) {
markReady(false);
return;
}
init.then(
() => markReady(true),
(error: unknown) => {
console.warn('[amplitude] initAll failed', error);
markReady(false);
},
);
}
// -- src/app/amplitude.ts --
// Keep lines other Angular pages added here: pathOnly page views
// (/build/angular-web-analytics), masking (/build/angular-session-replay),
// and the webExperiment.plugin() block (/build/angular-web-experiment).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';
import { setAmplitudeReady } from './amplitude-ready';
export function initAmplitude(): void {
if (!environment.amplitudeApiKey) {
console.warn('amplitudeApiKey is not set; analytics is off.');
setAmplitudeReady(null);
return;
}
setAmplitudeReady(
amplitude.initAll(environment.amplitudeApiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: true,
sessions: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
},
sessionReplay: {
sampleRate: environment.production ? 0.01 : 1,
debugMode: !environment.production,
// From /build/angular-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: environment.amplitudeDeploymentKey,
fetchTimeoutMillis: 3000,
automaticExposureTracking: false,
},
}),
);
}
// -- src/app/flags.service.ts --
import {
Injectable,
PLATFORM_ID,
Signal,
afterNextRender,
effect,
inject,
signal,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import * as amplitude from '@amplitude/unified';
import { amplitudeReady } from './amplitude-ready';
export type FlagState = { value: string | null; payload: unknown };
type FetchUser = { user_properties?: Record<string, string> };
@Injectable({ providedIn: 'root' })
export class FlagsService {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly settled = signal(0);
private generation = 0;
readonly version = this.settled.asReadonly();
async refetch(user?: FetchUser): Promise<void> {
if (!this.isBrowser) return;
const generation = ++this.generation;
try {
if (await amplitudeReady) {
await amplitude.experiment()?.fetch(user);
}
} finally {
if (generation === this.generation) this.settled.update((v) => v + 1);
}
}
}
export function injectFlag(flagKey: string): Signal<FlagState | undefined> {
const flags = inject(FlagsService);
const state = signal<FlagState | undefined>(undefined);
const rendered = signal(false);
afterNextRender(() => rendered.set(true));
let lastExposure: string | undefined;
effect(() => {
if (!rendered() || flags.version() === 0) return;
const experiment = amplitude.experiment();
const variant = experiment?.variant(flagKey);
const value = variant?.value ?? null;
state.set({ value, payload: variant?.payload });
if (!value) return;
const key = `${amplitude.getUserId() ?? ''}|${amplitude.getDeviceId() ?? ''}|${value}`;
if (key === lastExposure) return;
lastExposure = key;
experiment?.exposure(flagKey);
});
return state.asReadonly();
}
// -- src/app/amplitude-identity.service.ts --
import { Injectable, PLATFORM_ID, computed, effect, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import * as amplitude from '@amplitude/unified';
import { AuthService } from './auth.service';
import { FlagsService } from './flags.service';
@Injectable({ providedIn: 'root' })
export class AmplitudeIdentityService {
private readonly auth = inject(AuthService);
private readonly flags = inject(FlagsService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly identity = computed(
() => {
const user = this.auth.user();
return user ? { id: user.id, plan: user.plan ?? 'unknown' } : null;
},
{ equal: (a, b) => a?.id === b?.id && a?.plan === b?.plan },
);
constructor() {
if (!this.isBrowser) return;
effect(() => {
const identity = this.identity();
if (!identity) {
void this.flags.refetch();
return;
}
// Amplitude drops IDs shorter than 5 characters.
amplitude.setUserId(`user-${identity.id}`);
const identify = new amplitude.Identify();
identify.set('plan', identity.plan);
amplitude.identify(identify);
void this.flags.refetch({ user_properties: { plan: identity.plan } });
});
}
}
// -- src/app/auth.service.ts --
// Keep lines other Angular pages added here: the getEngagement import and
// getEngagement()?.gs.closeAll() before reset() (/build/angular-guides-surveys).
import { Injectable, inject, signal } from '@angular/core';
import * as amplitude from '@amplitude/unified';
import { FlagsService } from './flags.service';
export interface User {
id: string;
plan?: string;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly flags = inject(FlagsService);
readonly user = signal<User | null>(null);
signIn(user: User): void {
// ... your sign-in logic
this.user.set(user);
}
async signOut(): Promise<void> {
// ... your sign-out logic
this.user.set(null);
amplitude.reset();
amplitude.experiment()?.clear();
void this.flags.refetch();
}
}
// -- src/app/projects/new-project-config.ts --
export const TEMPLATES: string[] = ['blank', 'kanban', 'roadmap'];
export type NewProjectConfig = { defaultTemplate: string; showGallery: boolean };
type NewProjectPayload = { defaultTemplate?: unknown; showTemplateGallery?: unknown };
export function newProjectConfig(
variant: { value: string | null; payload?: unknown } | undefined,
): NewProjectConfig {
const payload = (variant?.value === 'on' ? variant.payload : undefined) as
| NewProjectPayload
| undefined;
const candidate = payload?.defaultTemplate;
return {
defaultTemplate:
typeof candidate === 'string' && TEMPLATES.includes(candidate) ? candidate : 'blank',
showGallery: payload?.showTemplateGallery === true,
};
}
export function sameConfig(a?: NewProjectConfig, b?: NewProjectConfig): boolean {
return a?.defaultTemplate === b?.defaultTemplate && a?.showGallery === b?.showGallery;
}
// -- src/app/projects/new-project-form.ts --
import { Component, input, linkedSignal } from '@angular/core';
import * as amplitude from '@amplitude/unified';
import { TEMPLATES, type NewProjectConfig } from './new-project-config';
@Component({
selector: 'app-new-project-form',
template: `
@if (config().showGallery) {
<fieldset>
<legend>Start from a template</legend>
@for (t of templates; track t) {
<label>
<input
type="radio"
name="template"
[value]="t"
[checked]="template() === t"
(change)="template.set(t)"
/>
{{ t }}
</label>
}
</fieldset>
} @else {
<select #picker (change)="template.set(picker.value)">
@for (t of templates; track t) {
<option [value]="t" [selected]="template() === t">{{ t }}</option>
}
</select>
}
<button type="button" (click)="create()">Create project</button>
`,
})
export class NewProjectForm {
readonly config = input.required<NewProjectConfig>();
protected readonly templates = TEMPLATES;
protected readonly template = linkedSignal(() => this.config().defaultTemplate);
protected create(): void {
// ... your create logic
amplitude.track('Project Created', { template: this.template() });
}
}
// -- src/app/projects/new-project.ts --
import { Component, computed } from '@angular/core';
import { injectFlag } from '../flags.service';
import { newProjectConfig, sameConfig } from './new-project-config';
import { NewProjectForm } from './new-project-form';
@Component({
selector: 'app-new-project',
imports: [NewProjectForm],
template: `
@if (config(); as cfg) {
<app-new-project-form [config]="cfg" />
} @else {
<div aria-busy="true" style="min-height: 320px"></div>
}
`,
})
export class NewProject {
private readonly flag = injectFlag('new-project-flow');
protected readonly config = computed(
() => {
const flag = this.flag();
return flag ? newProjectConfig(flag) : undefined;
},
{ equal: sameConfig },
);
}Then the Angular SSR additions from step 6, for apps on AngularNodeAppEngine (8 files, plus two lines in src/app/app.ts). src/app/amplitude.ts and src/app/projects/new-project.ts here replace the versions above. src/server.ts is the Angular 20 file: on Angular 19, edit your generated file and make only the three // Added changes.
// -- src/app/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/app/amplitude.ts --
// Keep lines other Angular pages added here: pathOnly page views
// (/build/angular-web-analytics), masking (/build/angular-session-replay),
// and the webExperiment.plugin() block (/build/angular-web-experiment).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';
import { setAmplitudeReady } from './amplitude-ready';
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId } from './amplitude-cookies';
function readCookie(name: string): string | undefined {
const prefix = `${name}=`;
const match = document.cookie.split('; ').find((c) => c.startsWith(prefix));
try {
return match ? decodeURIComponent(match.slice(prefix.length)) : undefined;
} catch {
return undefined;
}
}
export function initAmplitude(): void {
if (!environment.amplitudeApiKey) {
console.warn('amplitudeApiKey is not set; analytics is off.');
setAmplitudeReady(null);
return;
}
const hasAmpCookie =
readCookie(`AMP_${environment.amplitudeApiKey.slice(0, 10)}`) !== undefined;
const serverDeviceId = readCookie(DEVICE_ID_HANDOFF_COOKIE);
const adoptServerDeviceId = !hasAmpCookie && isDeviceId(serverDeviceId);
setAmplitudeReady(
amplitude.initAll(environment.amplitudeApiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: true,
sessions: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
...(adoptServerDeviceId ? { deviceId: serverDeviceId } : {}),
},
sessionReplay: {
sampleRate: environment.production ? 0.01 : 1,
debugMode: !environment.production,
// From /build/angular-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: environment.amplitudeDeploymentKey,
fetchTimeoutMillis: 3000,
automaticExposureTracking: false,
},
}),
);
}
// -- src/app/server-flags.ts --
import {
Injectable,
REQUEST_CONTEXT,
TransferState,
afterNextRender,
inject,
makeStateKey,
} from '@angular/core';
import * as amplitude from '@amplitude/unified';
import { amplitudeReady } from './amplitude-ready';
export type FlagVariant = { value: string; payload?: unknown };
export type ServerFlags = {
deviceId: string;
userId?: string;
flags: Record<string, FlagVariant>;
};
const SERVER_FLAGS = makeStateKey<ServerFlags | null>('amplitude-server-flags');
@Injectable({ providedIn: 'root' })
export class ServerFlagsService {
readonly state: ServerFlags | null;
constructor() {
const transferState = inject(TransferState);
const context = inject(REQUEST_CONTEXT, { optional: true }) as {
amplitudeFlags?: ServerFlags | null;
} | null;
if (context?.amplitudeFlags) {
transferState.set(SERVER_FLAGS, context.amplitudeFlags);
}
this.state = transferState.get(SERVER_FLAGS, null);
}
}
// One exposure per flag per page load; client-side navigations don't resend it.
const sentServerExposures = new Set<string>();
export function injectServerFlag(flagKey: string): FlagVariant | undefined {
const state = inject(ServerFlagsService).state;
const variant = state?.flags[flagKey];
afterNextRender(() => {
const value = variant?.value;
if (!state || !value) return;
void amplitudeReady.then((ok) => {
if (!ok) return;
const browserDeviceId = amplitude.getDeviceId();
const browserUserId = amplitude.getUserId();
const sameDevice = browserDeviceId === state.deviceId;
const sameUser = !state.userId || !browserUserId || browserUserId === state.userId;
if (!sameDevice || !sameUser) return;
if (sentServerExposures.has(flagKey)) return;
sentServerExposures.add(flagKey);
amplitude.track({
event_type: '$exposure',
event_properties: { flag_key: flagKey, variant: value },
...(state.userId && !browserUserId ? { user_id: state.userId } : {}),
});
});
});
return variant;
}
// -- src/app/app.ts (add two lines to your root component) --
// Angular 19: src/app/app.component.ts, class AppComponent.
// Keep lines other Angular pages added here: nav links (/build/angular-session-replay,
// /build/angular-heatmaps), and EngagementBridgeService and <app-feedback-button />
// (/build/angular-guides-surveys).
import { ServerFlagsService } from './server-flags';
constructor() {
inject(AmplitudeIdentityService);
inject(ServerFlagsService);
}
// -- src/app/projects/new-project.ts --
import { Component } from '@angular/core';
import { injectServerFlag } from '../server-flags';
import { newProjectConfig } from './new-project-config';
import { NewProjectForm } from './new-project-form';
@Component({
selector: 'app-new-project',
imports: [NewProjectForm],
template: `<app-new-project-form [config]="config" />`,
})
export class NewProject {
protected readonly config = newProjectConfig(injectServerFlag('new-project-flow-ssr'));
}
// -- src/server/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;
user_properties?: Record<string, string>;
};
let client: LocalClient | undefined;
let ready: Promise<void> | undefined;
export function startFlags(): Promise<void> {
if (!ready) {
const key = process.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: unknown) => {
localClient.stop();
client = undefined;
ready = undefined;
throw error;
});
}
return ready;
}
export async function evaluateFlags(
user: ExperimentUser,
flagKeys: string[],
): Promise<ServerVariants> {
if (!user.user_id && !user.device_id) return {};
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
startFlags(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('flag rules not loaded within 1 s')), 1000);
}),
]);
return client ? client.evaluateV2(user, flagKeys) : {};
} catch (error) {
console.error('[flags] evaluation failed; rendering defaults.', error);
return {};
} finally {
clearTimeout(timer);
}
}
// -- src/server/flags.ts --
import { randomUUID } from 'node:crypto';
import type { Request, Response } from 'express';
import { AmplitudeCookie } from '@amplitude/experiment-node-server';
import { environment } from '../environments/environment';
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId } from '../app/amplitude-cookies';
import type { ServerFlags } from '../app/server-flags';
import { evaluateFlags } from './experiment';
const FLAG_KEYS = ['new-project-flow-ssr'];
const FLAG_PATHS = new Set(['/projects/new']);
export function wantsServerFlags(req: Request): boolean {
// originalUrl, not req.path: a handler mounted on '/**' sees req.path as '/'.
const path = new URL(req.originalUrl, 'http://localhost').pathname;
return Boolean(req.headers.accept?.includes('text/html')) && FLAG_PATHS.has(path);
}
type ServerUser = { id: string; plan?: string };
function readCookie(req: Request, name: string): string | undefined {
const prefix = `${name}=`;
const match = req.headers.cookie?.split(/;\s*/).find((c) => c.startsWith(prefix));
if (!match) return undefined;
try {
return decodeURIComponent(match.slice(prefix.length));
} catch {
return undefined;
}
}
function readDeviceId(req: Request): string | undefined {
if (!environment.amplitudeApiKey) return undefined;
try {
const cookie = readCookie(req, AmplitudeCookie.cookieName(environment.amplitudeApiKey, true));
return cookie ? (AmplitudeCookie.parse(cookie, true).device_id ?? undefined) : undefined;
} catch {
return undefined;
}
}
export async function loadFlags(req: Request, res: Response): Promise<ServerFlags> {
res.setHeader('cache-control', 'private, no-store');
let deviceId = readDeviceId(req);
if (!deviceId) {
const handoff = readCookie(req, DEVICE_ID_HANDOFF_COOKIE);
if (isDeviceId(handoff)) deviceId = handoff;
}
if (!deviceId) {
deviceId = randomUUID();
res.cookie(DEVICE_ID_HANDOFF_COOKIE, deviceId, {
path: '/',
httpOnly: false,
sameSite: 'lax',
maxAge: 60 * 60 * 1000,
});
}
const user = (req as Request & { user?: ServerUser }).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' } } : {}),
},
FLAG_KEYS,
);
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/server.ts (Angular 20; on Angular 19 make only the // Added changes) --
// Keep lines other Angular pages added here: the header middleware
// (/build/angular-web-experiment) and the withCampaign import and /old-landing
// redirect (/build/angular-web-analytics), both above the Angular handler.
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startFlags } from './server/experiment'; // Added
import { loadFlags, wantsServerFlags } from './server/flags'; // Added
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: false,
redirect: false,
}),
);
app.use(async (req, res, next) => {
try {
// Added
const amplitudeFlags = wantsServerFlags(req) ? await loadFlags(req, res) : null;
const response = await angularApp.handle(req, { amplitudeFlags });
if (response) {
await writeResponseToNodeResponse(response, res);
} else {
next();
}
} catch (error) {
next(error);
}
});
if (isMainModule(import.meta.url)) {
// Added
startFlags().catch((error: unknown) => {
console.warn('[flags] could not load flag rules; pages render defaults.', error);
});
const port = process.env['PORT'] || 4000;
app.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
export const reqHandler = createNodeRequestHandler(app);
// -- src/app/app.routes.server.ts --
// Keep lines other Angular pages added here: the plans, offer/:code and
// account routes (/build/angular-web-analytics) and projects/:projectId
// (/build/angular-heatmaps). Merge into your existing array.
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: 'projects/new', renderMode: RenderMode.Server },
{ path: '**', renderMode: RenderMode.Prerender },
];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.
First complete https://amplitude.com/build/angular-product-analytics (or
confirm the app already has its setup). Then follow
https://amplitude.com/build/angular-feature-flags exactly. Use the root
component file the app already has (app.ts or app.component.ts).
Merge into existing shared files (environment files, amplitude.ts,
auth.service.ts, app.ts, server.ts, app.routes.server.ts) and never
delete lines another Amplitude Build page added; each file's first
comment names them. Don't add a second initAll.
- Add amplitudeDeploymentKey (a client- key, never a server- key) to both
environment files and leave the sample value for me.
- Pass initAll(...) to setAmplitudeReady, add the experiment block with
automaticExposureTracking: false, and read every flag you show through
injectFlag, in the component that shows it.
- In the sign-out method: reset(), then experiment()?.clear(), then
FlagsService.refetch(). Never reset when the user is null.
- If the app uses @angular/ssr: if src/server.ts uses CommonEngine, skip
step 6 and tell me. Otherwise do step 6: read the server key from
process.env.AMPLITUDE_SERVER_DEPLOYMENT_KEY, edit the generated
src/server.ts in place (only the three // Added changes), render
projects/new with RenderMode.Server, and add only the two
ServerFlagsService lines to the root component.
- Never send exposures from the server, don't hardcode keys, and don't
invent flag keys. Report every file you changed.What this unlocks
- Feature Experiment can run
new-project-flowas an A/B test withProject Createdas the primary metric, counting only people whose browser rendered the form. - Session replay can filter to users with the
[Experiment] new-project-flowuser property, so you watch the gallery and the dropdown side by side. - Heatmaps on
/projects/new, built from those replays, show where each variant's users click before they create a project. - Guides and surveys, bundled in the same package, can ask users in the
onvariant 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.