Session Replay in Next.js: App Router Edition
Where does a browser-only SDK go in a framework that renders on the server by default? That’s the whole puzzle when you add session replay to a Next.js App Router project. Replay recorders need window, document, and a MutationObserver — none of which exist during server rendering — so the setup is really four small problems: getting the init into a client component, keeping React strict mode from initializing it twice, putting the config in env vars that actually reach the browser, and tracking route changes that never reload the page.
The snippets below use LogReplay as the worked example, but the shape is identical for any rrweb-lineage SDK — swap the import and the options object.
Adding session replay to Next.js: the provider pattern
In the App Router, app/layout.tsx is a Server Component and must stay one (it renders your <html> shell and benefits from it). You can’t call a browser SDK there. The standard move is a tiny client component that owns the init, mounted once in the root layout:
// app/replay-provider.tsx
'use client';
import { useEffect } from 'react';
import { LogReplay } from '@logreplay/browser';
export function ReplayProvider() {
useEffect(() => {
LogReplay.init(process.env.NEXT_PUBLIC_LOGREPLAY_PROJECT_ID!, {
environment: process.env.NEXT_PUBLIC_APP_ENV ?? 'production',
networkRecording: { enabled: true, recordHeadersAndBody: false },
});
}, []);
return null;
}
// app/layout.tsx (stays a Server Component)
import { ReplayProvider } from './replay-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ReplayProvider />
{children}
</body>
</html>
);
}
Why useEffect and not module scope or the component body? Because 'use client' does not mean “runs only in the browser” — client components still execute on the server during SSR to produce HTML. Module-level LogReplay.init(...) will run in Node and either crash on a missing window or silently do nothing, depending on how defensive the SDK is. Effects only run in the browser, after hydration. That’s exactly the semantics you want, and it’s the mistake I see most in the wild (and made myself, back when the error was a mysterious document is not defined in the build logs).
If you’d rather not think about any of this, a dynamic import inside the effect (await import('@logreplay/browser')) also keeps the SDK out of the server bundle entirely. Worth it for heavier SDKs; optional for most.
Surviving strict mode’s double effect
In development, React strict mode mounts, unmounts, and remounts every component — so that useEffect fires twice. With a replay SDK the symptoms range from a harmless console warning to two recorders fighting over one page, doubled network capture, and split sessions.
Don’t reach for the useRef dance. A module-level guard is simpler and also covers the edge case where something remounts your provider later in the app’s life:
'use client';
import { useEffect } from 'react';
import { LogReplay } from '@logreplay/browser';
let initialized = false;
export function ReplayProvider() {
useEffect(() => {
if (initialized) return;
initialized = true;
LogReplay.init(process.env.NEXT_PUBLIC_LOGREPLAY_PROJECT_ID!, {
/* ... */
});
}, []);
return null;
}
Some SDKs are internally idempotent and this guard is redundant. Add it anyway — it costs three lines and removes an entire class of “why are there two of everything” debugging sessions. Notably, you should not stop the recorder in the effect’s cleanup function: session replay is meant to span the whole visit, not the lifecycle of one component. Init once, never tear down.
What about instrumentation.ts?
Reasonable question, wrong file. Next.js’s instrumentation.ts runs when a server process boots — it’s the right home for server-side OpenTelemetry setup, error monitoring on your API routes, and anything OTLP-shaped on the backend. It never executes in the browser, so a session recorder has nothing to do there. (Its sibling hook for early client instrumentation exists in newer Next.js versions, but it runs before hydration on every page load and is meant for tiny, blocking setup — check the Next.js docs for its current status before relying on it.)
The clean split: browser recorder in the client provider above; server-side tracing and error capture in instrumentation.ts. If your replay vendor supports OTLP ingest, both halves can land in the same backend and you get server traces correlated with the session that triggered them, which is the actual payoff of doing this properly.
Env vars: the NEXT_PUBLIC_ contract
Only variables prefixed with NEXT_PUBLIC_ are inlined into the client bundle; everything else is server-only and reads as undefined in the browser. So:
# .env.local
NEXT_PUBLIC_LOGREPLAY_PROJECT_ID=your-project-id
NEXT_PUBLIC_APP_ENV=production
Two sharp edges. First, inlining happens at build time — these are string substitutions, not runtime lookups. If you build one Docker image and deploy it to staging and production expecting different project IDs from runtime env, you’ll get whatever was set when next build ran. Either build per environment or accept one project with an environment tag, which is my preference anyway: one project, filter by environment, fewer builds to babysit.
Second, remember that NEXT_PUBLIC_ values ship to every visitor. A replay project ID is designed to be public (same trust model as any client-side analytics key — ingest endpoints validate the origin server-side), so this is fine. Just don’t get into the habit of prefixing things to “make the error go away”; that’s how server secrets end up in bundles.
Route change tracking without page loads
App Router navigation is client-side: no full page load, no new session, and — because Next.js navigations don’t necessarily fire a popstate you can rely on — potentially no navigation events in your replay timeline. Modern rrweb-lineage SDKs patch history.pushState and catch most of it automatically, but the reliable, framework-blessed hook is a tiny client component:
// app/route-tracker.tsx
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { LogReplay } from '@logreplay/browser';
export function RouteTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
LogReplay.track('navigation', { url: pathname });
// Deliberately not recording searchParams — query strings
// are where tokens and emails hide.
}, [pathname, searchParams]);
return null;
}
Mount it next to ReplayProvider in the layout, wrapped in <Suspense> (Next.js requires that for useSearchParams). Now every replay has explicit navigation markers, which turns “the user was somewhere doing something” into “the user went from /billing to /settings and then the error fired.”
That’s the whole setup: one provider, one guard, two env vars, one tracker. Fifteen minutes, most of which is finding your project ID. If you’re on Vite instead, the same ideas apply with less framework in the way — I’ve written up the Vite + React version separately.
See the bug the way your user did
LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.
Try LogReplay free