Chris Tam · Last updated 25 September 2026
Next.js is one of the most popular frameworks for building web apps. Session replay shows you what users actually saw and did, so a vague bug report becomes something you can watch.
In this tutorial, you'll add session replay to a Next.js App Router app, mask sensitive content, and confirm replays are arriving.
What you'll build
A Next.js app that records every session in development and 1% of sessions in production, masks form inputs by default plus any element you mark with amp-mask, blocks elements marked amp-block, and attaches [Amplitude] Session Replay ID to the session's events. By the end you'll open a replay of your own test session from the user's event stream in Amplitude.
Prerequisites
- A Next.js 13+ project using the App Router.
npx create-next-app@latestwith TypeScript and the default@/*import alias is fine. - Node.js 16.8 or later, the floor the Amplitude Next.js guide lists.
- An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
- Session replay available on your plan. Replay capture is included on all plans; the sessions you can capture each month come from your plan's quota.
No Amplitude account yet? A free account is enough for this tutorial; nothing here needs a paid plan. Sign up for free to get your project API key.
1. Install the SDK
Amplitude's Next.js guide recommends @amplitude/unified for new projects. It bundles Analytics, session replay, Feature Experiment, and guides and surveys behind one initAll call.
npm install @amplitude/unifiedNext.js only exposes env vars prefixed with NEXT_PUBLIC_ to browser code. A project API key is public and its scope is limited to the minimum needed to ingest data, so shipping it to the browser is expected, but keep it out of source anyway.
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-keyAlready on @amplitude/analytics-browser, for example to keep bundle size down? Use the plugin variant at the end of step 2 instead of switching packages.
2. Initialize once, on the client only
Put initialization in one module, behind two guards: typeof window so server rendering never touches the SDK, and a started flag so a second call does nothing.
import * as amplitude from '@amplitude/unified';
let started = false;
export async function initAmplitude() {
if (typeof window === 'undefined' || started) return;
const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
if (!apiKey) {
console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
return;
}
started = true;
const isProduction = process.env.NODE_ENV === 'production';
await amplitude.initAll(apiKey, {
sessionReplay: {
// Fraction of sessions to record: every session locally, 1% in production.
sampleRate: isProduction ? 0.01 : 1,
// Ignores the focus check so you get replays with DevTools open. Setup only.
debugMode: !isProduction,
},
});
}
export { amplitude };Why. A 'use client' file still executes on the server during prerendering, where window doesn't exist, so the typeof window check is what keeps next build from failing. The started flag matters because initializing the SDK more than once can leave you with mismatched device IDs or session IDs, and a replay that doesn't match its session's events is a replay you can't find.
Session replay only records when sessionReplay.sampleRate is above zero; the plugin's own default is 0. Amplitude recommends starting production low, around 0.01, and raising it over a few days, since capture stops for the month once your org's quota runs out. To turn recording off, set the rate to 0. A sample rate set in Settings > Organizational Settings > Session Replay Settings overrides the one in code, so check there too. For every option, see the session replay plugin configuration; the Unified SDK accepts all of them inside sessionReplay.
Unified also initializes guides and surveys by default. If you won't use them, the Unified SDK docs describe an engagement: { skip: true } option that stops that SDK from loading.
Plugin variant. If your lib/amplitude.ts initializes @amplitude/analytics-browser instead of Unified, add the session replay plugin:
npm install @amplitude/plugin-session-replay-browser --saveimport * as amplitude from '@amplitude/analytics-browser';
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser';
let started = false;
export async function initAmplitude() {
if (typeof window === 'undefined' || started) return;
const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
if (!apiKey) {
console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
return;
}
started = true;
const isProduction = process.env.NODE_ENV === 'production';
amplitude.add(
sessionReplayPlugin({
sampleRate: isProduction ? 0.01 : 1,
debugMode: !isProduction,
}),
);
await amplitude.init(apiKey, undefined, {
autocapture: true,
}).promise;
}
export { amplitude };3. Mount it in the root layout
Call initAmplitude from a client component's effect, and mount that component in the root layout. The root layout stays mounted across App Router navigations, so recording continues from page to page without reinitializing.
'use client';
import { useEffect, type ReactNode } from 'react';
import { initAmplitude } from '@/lib/amplitude';
export function AnalyticsProvider({ children }: { children: ReactNode }) {
useEffect(() => {
void initAmplitude();
}, []);
return <>{children}</>;
}import type { ReactNode } from 'react';
import { AnalyticsProvider } from '@/components/AnalyticsProvider';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<AnalyticsProvider>{children}</AnalyticsProvider>
</body>
</html>
);
}Why. React Strict Mode runs effects twice in development, and the started flag absorbs the second run. Don't move the provider into a page.tsx or a layout keyed on the pathname; that remounts it on navigation, which is the pattern that produces duplicate starts. The provider renders its children unchanged, so there's nothing for hydration to disagree about.
Session replay needs at least one Amplitude event in the session before a replay can play. Autocapture's session and page view events cover that with no extra code.
4. Mask what shouldn't leave the browser
Session replay masks every text input by default: typed characters arrive as asterisks. The project's masking level in Session Replay Settings defaults to Medium, which masks all form fields and text inputs and captures other text as-is. For anything else, add a class in your JSX:
export default async function AccountPage() {
const user = await getCurrentUser(); // your auth helper
return (
<main>
<h1>Account</h1>
{/* Rendered text: captured as asterisks */}
<p className="amp-mask">{user.email}</p>
{/* Whole element replaced by a same-size placeholder */}
<div className="amp-block">
<img src={user.idDocumentUrl} alt="" />
</div>
{/* Inputs are masked by default; opt this one back in */}
<input className="amp-unmask" name="search" placeholder="Search settings" />
</main>
);
}These classes work in Server Components because they're plain strings in the rendered HTML; no client code is involved. When you need a selector for masking overrides in Session Replay Settings or in privacyConfig, target a data attribute like [data-amp-mask] rather than a CSS Modules class. Next.js hashes module class names, so .styles_email__x7f2a matches today and silently stops matching after a rebuild.
Why. Masking covers text and inputs, not HTML attributes. alt, title, placeholder, aria-label, value, and data-* attributes stay visible in replays, so don't put an email address in a title. When the remote settings and the SDK disagree about an element, the remote setting wins. Change the level (Light, Medium, Conservative) at Settings > Organizational Settings > Session Replay Settings, per project.
MCP. Working in Cursor or Claude Code? Amplitude's MCP server includes get_session_replays, so once replays land your agent can pull the sessions where users hit an error or rage-clicked, instead of you scrubbing through them by hand. Set up the MCP server.
5. Allow Amplitude in your CSP
Skip this step if your app doesn't send a Content-Security-Policy header. If it does, and you set it in next.config.js headers() or in middleware, add Amplitude's hosts to the directives you already have:
# Add to your existing Content-Security-Policy (US data center)
connect-src https://api2.amplitude.com https://api-secure.amplitude.com https://api-sr.amplitude.com https://sr-client-cfg.amplitude.com;The session replay plugin docs list https://api-secure.amplitude.com in their CSP section and https://api-sr.amplitude.com (replay upload) plus https://sr-client-cfg.amplitude.com (remote config) in their endpoint list, so allow all three until that's settled. api2.amplitude.com is where the Browser SDK sends events. EU projects use the .eu. hosts instead and set serverZone: 'EU' at the top level of the initAll options. Add worker-src blob: only if you turn on useWebWorker.
Why. If remote configuration fails to load, session replay captures nothing, on purpose, so it can't record with privacy settings it never received. A blocked config request looks like "session replay does nothing" rather than like an error, until you open the console and find the Content Security Policy message.
Verify it worked
Run npm run dev, click through two or three routes, type into a form, then check in this order:
- Console and Network tab. No
Content Security Policyerrors in the console. In the Network tab, filter foramplitude: you should see event uploads toapi2.amplitude.comand a session replay upload to a/sessions/endpoint. - Instrumentation Explorer. Turn on the Session Replay debug panel in the Amplitude Instrumentation Explorer Chrome extension. It shows the current replay ID, the SDK and plugin versions, the sample rate, and the default and effective masking settings, which is the fastest way to confirm a remote setting isn't overriding yours.
- User lookup. Go to Users > User Profiles and search for your user ID or device ID. The event stream fills in ten seconds to one minute; look for a session event carrying
[Amplitude] Session Replay ID, then the play button on that session. Replays are generally viewable about five minutes after the session starts.
To force a fresh session while testing, delete the cookies that start with AMP_ in DevTools and reload.
Replays recorded against localhost often play back unstyled, because Amplitude can't fetch stylesheets from your machine. That's expected. Check styling against a deployed preview URL.
Troubleshooting
No session replay requests at all
The effective sample rate is 0. Check sessionReplay.sampleRate, then the rate in Session Replay Settings, which overrides code. In production at 0.01, only about one session in a hundred records, so test in development or on a preview build with a higher rate.
Replays work in production but not while you're debugging
Session replay only captures the tab that has focus, and an open DevTools panel can steal it. Keep debugMode: true in development, as in step 2, which ignores the focus check.
A replay has events but no play button
No event in that session carries [Amplitude] Session Replay ID. That happens when the session's only events come from your server (for example a server action tracking with @amplitude/analytics-node), or when the SDK initialized twice and the replay and events disagree on device or session ID. Search the codebase for a second initAll or init call.
Old replays lose their styling after a deploy
Next.js writes CSS to hashed file names under /_next/static/, and a deploy removes the old ones. Keep previous build assets available on your host or CDN, allow app.amplitude.com in your asset server's CORS policy, and add crossorigin="anonymous" to any external <link rel="stylesheet">.
Requests fail with ERR_BLOCKED_BY_CLIENT
An ad blocker is dropping them. Session replay isn't compatible with ad blocking software; test in a clean profile. For production traffic, route requests through your own domain with trackServerUrl and configServerUrl.
ReferenceError: window is not defined during build
Something calls the SDK at module scope outside initAmplitude, or imports it from a Server Component and calls a method. Keep every call behind the typeof window guard or inside an effect in a 'use client' component.
Full code reference
Every file from this tutorial in one block, the version to hand to an agent. It uses the Unified SDK; the plugin variant of lib/amplitude.ts is in step 2.
// -- .env.local --
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
// -- lib/amplitude.ts --
import * as amplitude from '@amplitude/unified';
let started = false;
export async function initAmplitude() {
if (typeof window === 'undefined' || started) return;
const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
if (!apiKey) {
console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
return;
}
started = true;
const isProduction = process.env.NODE_ENV === 'production';
await amplitude.initAll(apiKey, {
sessionReplay: {
sampleRate: isProduction ? 0.01 : 1,
debugMode: !isProduction,
},
});
}
export { amplitude };
// -- components/AnalyticsProvider.tsx --
'use client';
import { useEffect, type ReactNode } from 'react';
import { initAmplitude } from '@/lib/amplitude';
export function AnalyticsProvider({ children }: { children: ReactNode }) {
useEffect(() => {
void initAmplitude();
}, []);
return <>{children}</>;
}
// -- app/layout.tsx --
import type { ReactNode } from 'react';
import { AnalyticsProvider } from '@/components/AnalyticsProvider';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<AnalyticsProvider>{children}</AnalyticsProvider>
</body>
</html>
);
}
// -- app/account/page.tsx --
export default async function AccountPage() {
const user = await getCurrentUser(); // your auth helper
return (
<main>
<h1>Account</h1>
<p className="amp-mask">{user.email}</p>
<div className="amp-block">
<img src={user.idDocumentUrl} alt="" />
</div>
<input className="amp-unmask" name="search" placeholder="Search settings" />
</main>
);
}Hand it to your agent
Prefer to have Cursor or Claude Code do the wiring? This prompt points the agent at this page and pins the decisions that matter.
Add Amplitude Session Replay to this Next.js App Router app by following
https://amplitude.com/build/nextjs-session-replay exactly. If the app
already uses @amplitude/analytics-browser, add
@amplitude/plugin-session-replay-browser to the existing init instead of
installing a second SDK. Otherwise install @amplitude/unified. Initialize
only inside initAmplitude() in lib/amplitude.ts, behind a typeof window
check and a started flag, called from a 'use client' AnalyticsProvider
mounted in app/layout.tsx. Never call init at module scope or from a page.
Set sessionReplay.sampleRate to 1 in development and 0.01 in production,
and tell me that a rate in Session Replay Settings overrides it. Add
amp-mask to elements that render emails or account data and amp-block to
images of documents; don't use CSS Modules class names as masking
selectors. Read NEXT_PUBLIC_AMPLITUDE_API_KEY from the environment, don't
hardcode it. Report every file you changed and any CSP header you found.What this unlocks
- Session replay now sits on the same events as your charts: open a funnel step and watch the sessions where users dropped.
- Heatmaps build on session replay, so once heatmap capture is on for the project in Session Replay Settings, clicks and scrolls on
/accountaggregate without new code. - Feature Experiment, bundled in
@amplitude/unified, lets you filter replays by variant and see how people use the new version. - Guides and surveys, also bundled, can ask the users whose replays show a stall what they were looking for.
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.