You shipped a Next.js 14 app router site, the /demo route looks fine in QA, and yet only 1 in 4 form opens turns into a submission. Server logs are clean. There's no error in Sentry. Something is happening in the browser between "user clicked the email field" and "user closed the tab," and the only way to see it is to record what they did.
This is the install guide I wish existed when I first wired a session tracker into an app router project. The big footgun isn't the SDK — it's that the root layout.tsx runs on the server, so naive snippets either fail to mount or kill streaming. Below is the 20-minute version that works.
What we're actually setting up
Two things, in this order:
- The CloseTrace tracker — captures clicks, scrolls, navigations, and the DOM mutations needed for replay.
- Lead recovery — captures form field values as the user types so half-filled forms aren't lost when someone closes the tab.
If you just want replay, stop after step 1. If you have a demo form, contact form, or pricing-quote flow, do both. Lead recovery is the thing that pays for itself on a B2B site — most teams find more pipeline in week one than they spent on the tool.
Install the tracker
Grab your site key from the CloseTrace dashboard (Settings → Sites → the small ct_pub_... string). Then add an env var:
# .env.local
NEXT_PUBLIC_CLOSETRACE_KEY=ct_pub_xxxxxxxxxxxx
The NEXT_PUBLIC_ prefix is required — the key has to reach the browser bundle. It's a public key, not a secret, so this is intentional.
Mount it from the root layout
In app router, the root layout.tsx is a server component by default. You can't just drop a script tag with inline JS into it the way you would in pages router. Use next/script with the afterInteractive strategy:
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Script
id="closetrace"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
(function(c,t){c.CloseTrace=c.CloseTrace||function(){
(c.CloseTrace.q=c.CloseTrace.q||[]).push(arguments)};
var s=document.createElement('script');s.async=1;
s.src='https://cdn.closetrace.com/t.js';
document.head.appendChild(s);
c.CloseTrace('init','${process.env.NEXT_PUBLIC_CLOSETRACE_KEY}');
})(window);
`,
}}
/>
</body>
</html>
);
}
A few things worth knowing:
afterInteractiveloads after hydration. That's the right strategy here — you want the tracker after React is interactive, not blocking First Contentful Paint.- Don't use
beforeInteractive. It only works in the root layout, and forcing the tracker to load that early measurably hurts LCP on slower mobile devices. - Don't put this in a per-route layout. Sessions need to span page transitions; if you mount the script in
/app/(marketing)/layout.tsx, you lose continuity when users navigate to/app/(app)/....
Deploy this, open the site in a private window, click around, and check the "Live sessions" view in the CloseTrace dashboard. You should see your own session within about 10 seconds.
Tie sessions to identified users
If you have a signed-in area or a demo form that collects email, you want sessions to be searchable by that email. App router has no global place to call an identify function from a server component, so you need one tiny client component.
// app/_components/identify-user.tsx
"use client";
import { useEffect } from "react";
declare global {
interface Window {
CloseTrace?: (...args: unknown[]) => void;
}
}
export function IdentifyUser({ email }: { email?: string }) {
useEffect(() => {
if (!email || typeof window === "undefined") return;
window.CloseTrace?.("identify", { email });
}, [email]);
return null;
}
Render it from any client component or layout that has the user's email. If you only know the email after a form submit, call CloseTrace("identify", ...) in your submit handler — same effect.
Turn on lead recovery
This is the feature most teams underuse. With identify wired up, CloseTrace can capture form drafts — what someone typed before they bailed — and attach them to the session.
Add the data-ct-capture attribute to forms you want to recover:
// app/demo/page.tsx (or wherever your form lives)
<form data-ct-capture="demo-form" onSubmit={handleSubmit}>
<input name="email" type="email" required />
<input name="company" />
<textarea name="use_case" />
<button type="submit">Request demo</button>
</form>
By default, fields named password, card, cvv, and ssn are never captured. If you have a custom-named sensitive field (e.g. nationalId), add it to the suppression list in Settings → Privacy → Suppressed fields, or mask it inline with data-ct-mask.
In the dashboard, filter sessions with Event: form_abandoned AND form_id: demo-form to see exactly who started filling and bailed. That's the list to triage on a Monday morning — pair the captured email with the replay and you'll usually see why they stopped (broken phone validation, a captcha that won't load, a hidden submit button on mobile — we keep a running list of the common ones).
The caveat nobody mentions
App router's prefetching is aggressive. When a user hovers a Link, Next prefetches the destination route, which can fire your useEffect identify call on a route the user never actually visits. It's harmless for replay, but it'll inflate your "unique identified users" count by 5-15% on a marketing site with lots of navigation.
The fix is to call identify on a user action (form interaction, click, submit) rather than on mount. Sessions still get captured — they just stay anonymous until the user does something that confirms intent.
Verify the install in 60 seconds
- Open your site in a fresh private window.
- Type a fake email into a
data-ct-captureform, then close the tab without submitting. - In the CloseTrace dashboard, open the session and confirm the email shows up in Form drafts with the field name and the partial value.
If step 3 works, you're done. The next thing to do is set up a funnel from your landing route to the form submit event so you can see the abandonment rate trend, not just individual sessions. That's a 5-minute job and it's where the recurring value lives.
