Session replay for / Next.js
Session replay for Next.js apps
You want to see what users hit in production, but your app is a SPA after first paint and most replay documentation assumes full page loads.
What to watch for
- Hydration mismatches React logs a hydration error and then silently re-renders. The user sees a flash and sometimes loses form state. It never reaches your error tracker as a user-facing failure because nothing threw.
- Dead clicks immediately after navigation A client-side route change paints before the handlers bind. Clicks in that window land on nothing, and the user thinks the app is broken.
-
Failed server action or route handler calls
A
fetchto/api/*or a failed server action returning a non-2xx that the component swallows — the most common silent failure in an App Router app. - Long sessions with no navigation In a SPA a single session covers the whole visit. A long tape with one URL is normal here, not a stall, and reading it needs a different instinct.
-
Rage clicks on optimistic UI
useOptimisticand pending states that never resolve leave a control looking live while nothing happens behind it.
What a session looks like
- 0:00 session start · /dashboard · viewport 1512×982
- 0:03 ⚠ JS error: Hydration failed because the server rendered HTML
didn't match the client
- 0:09 click a "Projects"
- ⚠ 0:10 dead click a "Projects" — nothing on the page responded
- 0:12 click a "Projects"
- 0:13 navigated to /dashboard/projects
- 0:28 click button "New project"
- ⚠ 0:29 POST /dashboard/projects · 500
- ⚠ 0:31 rage click ×3 · button "New project"
- 0:44 session end · total 0:44
Most session replay documentation assumes a website: full page loads, server-rendered HTML, one URL per view. A Next.js app is that for exactly one navigation, and then it is a single-page app for the rest of the visit.
That changes what the recordings look like and what you should read them for.
What is actually different
A session is one long tape. With the App Router, navigating from
/dashboard to /dashboard/projects is a client-side transition — no page
load, no new session. UserTapes stitches same-tab navigation into one recording
anyway, so this works, but it means a normal session here is longer and covers
more ground than on a traditional site. A five-minute tape with one entry URL
is healthy, not a stall.
Hydration errors are user-facing and invisible. This is the big one, and it is the reason this page exists.
When server-rendered HTML does not match what React produces on the client, React logs a hydration error and discards the server tree. The user sees a flash, sometimes loses form input, and — critically — the interactive handlers bind later than the paint suggests. Clicks in that gap land on nothing.
Your error tracker may record the hydration warning. What it cannot tell you is that a user clicked twice during the re-render and concluded your app was broken. That correlation only exists in a session recording, and in the transcript it is unmistakable:
- 0:03 ⚠ JS error: Hydration failed ...
- 0:09 click a "Projects"
- ⚠ 0:10 dead click a "Projects" — nothing on the page responded
Error at 0:03, dead click at 0:10. The relationship is right there in the ordering, and no amount of aggregate error monitoring surfaces it.
The dead-click window
Worth understanding precisely, because it is the failure mode people misread.
UserTapes marks a click dead when no DOM mutation follows it within one second and the session continued long enough for one to have happened. In a Next.js app, this catches a specific and real problem: paint before interactivity.
The route transition renders. The page looks finished. Handlers have not bound yet, because hydration is still working through the tree. A user who clicks in that window gets nothing, and their instinct is to click again — which is why dead clicks and rage clicks so often appear together in App Router apps.
If you see a cluster of dead clicks in the first second after navigation, that is not a bug in your click handlers. It is your time-to-interactive being worse than your time-to-paint, and the fix is upstream: less client JavaScript on that route, or a loading state that does not look finished.
Server actions and the silent 500
The App Router made it easy to call server code from a component, and easy to ignore the result.
- 0:28 click button "New project"
- ⚠ 0:29 POST /dashboard/projects · 500
- ⚠ 0:31 rage click ×3 · button "New project"
The server action failed, the component did not render an error, the button stayed enabled, and the user clicked three more times — plausibly creating three more failed requests, or worse, three duplicate records if only the response failed.
This pattern is common enough in App Router codebases that it is worth grepping for deliberately: server actions whose failure path renders nothing.
The token question
One thing to get right, since it comes up in every Next.js setup:
The site token in the script tag is public. It is ingest-only, it appears
in page source on every site running the tracker, and NEXT_PUBLIC_ is the
correct place for it. Someone who takes it can send you junk recordings; they
cannot read anything.
The MCP token is a read credential for your recordings. It must never
appear in client code, in NEXT_PUBLIC_ anything, or in a component that ships
to the browser. It belongs in your agent’s config or in server-side environment
variables only.
Conflating those two is the one genuinely dangerous mistake available here.
Reading it with an agent
Because a Next.js session is long and mostly one URL, scrubbing video is even less practical than usual. The transcript is the useful artefact, and it is text, so your agent can read it — see session replay over MCP for the setup.
The question worth asking on a Next.js app, roughly weekly: are there hydration
errors in real sessions, and do they correlate with dead clicks? That is a
question about production reality that no local development environment can
answer, because hydration mismatches are usually caused by things — dates,
locales, feature flags, window access — that behave identically on your
machine and differently on someone else’s.
Asking your agent
$ claude "users report the dashboard feels broken on first load"
▸ usertapes list_tapes(url_contains: "/dashboard", has_errors: true)
← 31 tapes · 22 with a hydration error in the first 5 seconds
▸ usertapes read_transcript(c9f14a03)
← 0:03 ⚠ Hydration failed ... didn't match the client
0:10 ⚠ dead click a "Projects"
▸ cause: <TimeAgo> renders new Date() during SSR.
Server and client disagree, React discards the tree,
handlers rebind ~700ms late.
Over MCP, in Claude Code or Cursor — see session replay over MCP.
Installing it here
Use next/script in your root layout rather than a raw tag, so Next controls
when it loads:
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
<Script
src="https://usertapes.com/tapes.js"
data-token={process.env.NEXT_PUBLIC_USERTAPES_TOKEN}
strategy="afterInteractive"
/>
</body>
</html>
);
}
afterInteractive is the right strategy: beforeInteractive would put a
non-essential script in the critical path, and lazyOnload waits until the
window load event, by which point you have missed the hydration errors
that are usually the most interesting thing in the session.
The token is public by design — it is an ingest-only credential that lives in
page source on every site. NEXT_PUBLIC_ is correct here. Your MCP token,
which reads recordings, is a different secret and must stay server-side.
When this is the wrong tool
- Server-side errors. A 500 thrown in a server component renders an error boundary; the transcript shows the boundary, not the stack. Keep Sentry.
- Debugging React state directly. This records the DOM, not your component tree — there is no Redux or hook inspector here.
- Apps whose main surface is canvas or WebGL, where DOM recording captures almost nothing of what the user saw.