Session replay for Next.js: the parts that actually break
How to add session replay to a Next.js app, App Router or Pages Router, including the next/script placement, CSP directives, why /_next/image breaks replay assets, and how to identify users without shipping auth code to the client.
Adding a session recorder to a Next.js app is four lines. Getting a recording that still plays back correctly a week later is where Next.js specifics start to matter, and there are five of them that bite: where the script tag can legally live, what the App Router does to a raw <script>, how /_next/image breaks asset capture, what your CSP has to allow, and how to attach a user identity without dragging auth code into the client bundle.
This page covers all five, then gives you the snippet.
Use next/script, not a raw script tag
A raw <script> in an App Router layout.tsx sits in a spot the React tree does not guarantee will execute the way you expect. Next.js has next/script precisely to handle load ordering, and that is what to use.
strategy="afterInteractive" is the right strategy here. beforeInteractive would block hydration to load a recorder, which is the wrong trade for something whose whole job is to observe without being noticed. lazyOnload waits until after everything else has settled and you lose the early part of the session, which is often the interesting part.
Get your real snippet, with your site's script filename and write key filled in, from the Install screen in your Filmroom dashboard. The write key is publishable and safe in client HTML: it is gated by a per-site origin allowlist and per-IP rate limiting on the ingest endpoint.
Pages Router works the same way from pages/_app.tsx. Do not put the recorder in _document.tsx, since next/script in _document has different semantics and there is no reason to reach for it.
Why the script filename looks random, and why that is deliberate
Your snippet's src will look something like /v1/9f3a2c81d4.js rather than /v1/session-replay.js. That filename is stable and derived from your write key. Two reasons it works this way, and both are practical rather than cosmetic.
First, filename-keyed tooling. Service worker route rules, CDN cache rules, monitoring filters and ad-blocker lists all key on paths. A distinctive per-site filename keeps your recorder from colliding with anything else on the page and never collides with another site's recorder in shared tooling. Second, the recorder is not named for what it does in anything customer-visible, which matters more than it sounds like it should when the alternative is a file called replay.js in your production network tab.
The server serves the same bundle at any /v1/<name>.js, so you can pick your own filename by editing the src. Nothing else needs to change.
/_next/image will break your replays if the recorder captures assets by reference
This is the Next.js specific problem, and it is the reason a replay that looked fine on Tuesday plays back with broken images on Friday.
Most session replay tools store images by reference: the recording holds the URL and the player fetches it at playback time. That works right up until the URL stops resolving the same way, and in a Next.js app there are several ways for that to happen:
/_next/image transforms are keyed by query parameters and served from your deployment. A redeploy, a cache eviction, or a changed transform and the player gets a 404 or a different image.
Anything behind authentication returns a login page to the player, because the player is not the visitor and does not have their session.
blob: URLs from client-side generated images are dead the moment the tab closes. They can never be resolved later, by anyone.
Assets on a build-hashed path disappear when that build is pruned.
Filmroom inlines images at record time. The recorder captures the pixels, encodes them as webp, and stores them in the recording itself, so the replay is self-contained and does not depend on any original URL still resolving. Same-origin images and CORS-enabled cross-origin images inline; non-CORS third-party images stay by reference, because the browser will not let us read those pixels and we would rather tell you that than pretend.
Storage is not the problem you would expect it to be, because inlined images are hashed and deduplicated: the same logo across ten thousand snapshots is stored once and re-inlined on read. The replay you watch is self-contained, and the storage bill behaves as if it were not.
Content Security Policy: two directives, not one
If your Next.js app sends a CSP (and it should), the recorder needs two entries and people routinely add only the first, then file a bug about the recorder not working.
script-src lets the recorder bundle load. connect-src lets it send events. Add these origins to your existing directives rather than replacing them. The Install screen prints the exact two lines for your instance.
Identifying users without shipping auth code to the client
You want to know which recording belongs to which account. You do not want your auth helper in the client bundle to get there.
The clean pattern in the App Router is to resolve the identity on the server and pass only the resulting values as props. A dynamic import keeps the auth module out of the client graph entirely:
app/layout.tsx
// app/layout.tsx
import Script from 'next/script'
const recordSessions = process.env.RECORD_SESSIONS === 'true'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
let identity: { id?: string; name?: string; email?: string } | null = null
if (recordSessions) {
try {
const { getRecorderIdentity } = await import('@/lib/observability/get-recorder-identity')
identity = await getRecorderIdentity()
} catch {
// identity resolution failed: record anonymously rather than not at all
}
}
return (
<html lang="en">
<body>
{children}
<Script
src="https://cdn.filmroom.dev/v1/<your-site-script>.js"
data-site-id="app"
data-write-key="wk_live_..."
data-ingest="https://<your-instance>.filmroom.dev"
data-user-id={identity?.id}
data-user-name={identity?.name}
data-user-email={identity?.email}
strategy="afterInteractive"
/>
</body>
</html>
)
}
Note the try/catch. If identity resolution throws, you want an anonymous recording, not a missing one.
If the identity only becomes known after the page loads (a client-side login, for example), call the runtime API instead:
Identity does more than label a row. It becomes the durable visitor key that groups a person's sessions together, drives the single-visitor filter, and is what makes a GDPR erasure request one query instead of a search. It also propagates backwards: when an identified batch arrives, earlier anonymous recordings from the same IP on the same site get backfilled with that identity, so the three sessions before someone signed up are attached to the account they became.
Client-side navigation is tracked, so the URL follows the route
An SPA route change does not fire a page load, which means a naive recorder shows the landing URL for the entire session and your replay's address bar lies to you for twenty minutes.
Filmroom patches history.pushState and history.replaceState and listens for popstate and hashchange, emitting a navigation event that the player folds into its timeline. The address bar in the replay follows your App Router navigations, including while you scrub. Those navigation hrefs also become page views server-side, so route-based conversion goals match on client-side navigations and not only on full page loads.
One honest limitation: this applies going forward. Recordings made before this behaviour existed have no navigation events, and nothing rewrites historical data, so a route-based goal you define today retro-matches older sessions only by their landing URLs.
What gets masked, and what you should mask yourself
Every input is masked in the visitor's browser before anything is sent. The typed value is never transmitted.
Rendered text is not masked by default, which matters in an authenticated Next.js app because a logged-in page renders plenty of personal data as ordinary text. Two class hooks handle it:
<span className="sr-mask">{user.email}</span> {/* text hidden, layout preserved */}
<div className="sr-block">{/* nothing inside is recorded */}</div>
Both apply to all descendants. data-mask and data-block attributes work identically if you prefer attributes to classes.
To keep a whole route out of recording, use a glob on the script tag rather than conditional rendering:
data-deny="/checkout/* /settings/billing"
A denied route loads no recorder and makes no network request at all. A pattern starting with / matches the path; anything else matches the host, so admin.* excludes a whole subdomain.
Filmroom also scans rendered text for things you forgot: email addresses, Luhn-valid card numbers, dashed SSNs, and prefixed secret tokens. Findings arrive as redaction flags with a selector and a masked sample, deduplicated across sessions, so you get told "this component leaks" once rather than once per visitor.
Sampling is a knob you set, not a cap we impose
data-sample={0.2} records 20% of visits. Omit it and every visit is recorded.
Worth being explicit about, because it is not how the category usually works. Several hosted tools sample your traffic automaticallywhen you exceed a plan limit. Hotjar's own documentation states that if you exceed your daily session capture limit, it will sample your traffic. That is a bad property for debugging, because the session you need is disproportionately likely to be an outlier and outliers are what sampling removes.
Filmroom never samples on your behalf. You set the rate, you set retention, you set the disposal thresholds for short and idle-heavy sessions, and you can pin any recording so no cleanup rule ever touches it. A session that hits a conversion goal is pinned automatically, because that is the replay most worth keeping.
What it costs on the page, measured
Three competitors assert that their recorder is lightweight without publishing a number, so here are ours, along with the part that is not flattering.
The bundle is a single 25 KB gzipped script (24 KB brotli on the wire), loaded async and served immutable-cached, so returning visitors do not re-download it. There is no second chunk, no web worker, no stylesheet and no runtime CDN dependency.
The page impact, on a media-rich reference page under 4x CPU throttling: First Contentful Paint unchanged, Largest Contentful Paint moved 12 ms in the typical case and 128 ms in the worst case we measured, which was the run where the hero image finished loading while the recorder was taking its initial DOM snapshot. Interaction latency after the first second was unchanged, at a median of 16 ms with and without the recorder.
The caveat that ships with those numbers.The recorder's cost is not spread across the page's life, it is one burst of main-thread work near page load, while rrweb serializes the DOM and inlines every image. On this page that burst was about one second at 4x CPU throttle, and an input landing inside it can be delayed: a click dispatched 300 ms after navigation waited about 0.8 seconds. Clicks before and after the burst were unaffected. The burst scales with image count and pixel area, because each image is drawn to a canvas and re-encoded, so a text-heavy page pays much less than an image-heavy one.
Two limits on how far to read those figures. They were measured on a throttled hand-built reference page, not on a Next.js app, so the interaction between strategy="afterInteractive" and React hydration competing for the same main thread is not represented. And 4x CPU throttling stands in for a mid-range phone, so a desktop visitor sees less than these numbers. The full methodology, the raw per-scenario results and every caveat live in docs/perf/measurements.md in our repository, including the harness bug that initially hid the input delay entirely.
After the snippet: what you actually get
Errors linked to replays. Window errors, unhandled rejections, and console.error calls are captured and grouped into issues, so one bad chunk load hitting four hundred visitors is one row rather than four hundred. Each issue links to a replay where it happened, and it survives the recording being deleted. Grouping deliberately ignores line numbers, since a rebuild shifts every line, and normalizes away build hashes, including the ones Next.js puts in chunk paths and the ones Vercel puts in query strings. Without that, every deploy forks your issue list.
AI reads the sessions.Sessions worth annotating (pinned, errored, identified, converted, or above your site's own 75th percentile for active time) get a summary, an interest score from 1 to 5, and attention and friction notes. Noteworthy ones are pinned, idle bounces are archived. You bring your own Anthropic or OpenAI key.
A weekly report answering the same four questions every week: engaged visitors who have never converted, visitors who have gone quiet, where people got most of the way to a goal and stopped, and which traffic sources produce converting visitors. Every number is computed in SQL before the model is called.
Conversion goals as route patterns or window.layerbase.sr.track('signed_up') calls, and defining a route goal retro-matches your existing history so it has numbers immediately.
If you would rather run all of this on your own infrastructure, the self-hosted options guide covers what that costs in operational surface.
Frequently asked questions
How do I add session replay to a Next.js app?
Use next/script with strategy="afterInteractive" inside <body> in app/layout.tsx (App Router) or pages/_app.tsx (Pages Router), passing your site id, write key, and ingest origin as data-* attributes. Copy the exact snippet from the Install screen in your dashboard. If your app sends a CSP, add the recorder origin to script-src and the ingest origin to connect-src.
Does session replay work with the App Router and React Server Components?
Yes. The recorder is a plain browser script and observes the DOM, so it does not care whether the markup came from a server component, a client component, or a streamed chunk. Client-side navigations are tracked via the History API, so the replay's address bar follows App Router route changes.
Will a session recorder slow down my Next.js app?
It loads after hydration with afterInteractive, so it does not block interactivity, and the recorder is a single 25 KB gzipped script (24 KB brotli on the wire), loaded async and served immutable-cached, so returning visitors do not re-download it. Measured on a throttled media-rich reference page: First Contentful Paint unchanged, Largest Contentful Paint moved 12 ms typically and 128 ms in the worst case we measured, and interaction latency after the first second unchanged at a median of 16 ms. The companion caveat, which belongs with those numbers: the recorder does about one second of main-thread work in a burst near page load (measured at 4x CPU throttle) while it snapshots the DOM and inlines images, and an input landing inside that burst can be delayed. Full methodology is in docs/perf/measurements.md in our repository.
How big is the session replay script?
A single 25 KB gzipped script (24 KB brotli on the wire, 80 KB uncompressed). There is no second chunk, no web worker, no stylesheet and no runtime CDN dependency. It is loaded async and served immutable-cached from a version-pinned URL, so returning visitors do not re-download it.
Why do images break in my session replays?
Because most recorders store images by reference and Next.js URLs do not stay resolvable. /_next/image transforms change across deploys, auth-gated assets return a login page to the player, and blob: URLs die with the tab. Filmroom inlines image data at record time so the replay is self-contained. Non-CORS third-party images are the exception and stay by reference, because the browser will not let any script read their pixels.
Can I exclude specific routes, like checkout, from recording?
Yes. Put a data-deny glob on the script tag, for example data-deny="/checkout/*". A denied route loads no recorder and makes no network request, so nothing is captured rather than captured and filtered. data-allow does the inverse if you would rather record one section and skip everything else.
How do I connect a recording to a logged-in user?
Pass data-user-id, data-user-name, and data-user-email on the script tag (resolve them on the server and pass them as props so your auth code stays out of the client bundle), or call window.layerbase.sr.identify({ id, name, email }) once the user is known client-side.