Next.js Analytics: The Complete Setup Guide
Add privacy-first analytics to a Next.js app — App Router and Pages Router — with SPA route-change tracking, Web Vitals, and zero impact on your Lighthouse score.
Next.js gives you a fast site out of the box; the wrong analytics script is the quickest way to throw that away. This guide sets up privacy-first analytics on Next.js — App Router or Pages Router — with client-side navigation tracked correctly, Web Vitals measured from real users, and a script budget of about 1 KB. This site (clycyo.com) is itself a Next.js static export running exactly this setup, and its numbers are public at /open.
The two Next.js-specific problems
- Client-side navigation. After the first load, Next.js swaps pages without full reloads. A naive tracker records one pageview per session and goes blind. Your tracker must hook the History API.
- Performance budget. You did not adopt Server Components and code-splitting to spend 100 KB on gtag + GTM. Script weight lands on INP and LCP — the metrics Google ranks you on.
Installation — App Router
Add the script in your root layout. Clycyo's tracker detects SPA route changes automatically (History API patching), so this is genuinely the whole setup:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<script
defer
src="https://clycyo.com/tracker.js"
data-tracking-id="YOUR_TRACKING_ID"
/>
</head>
<body>{children}</body>
</html>
);
}With defer, the 1.1 KB script downloads in parallel and executes after parsing — it never blocks rendering. Lighthouse impact: none measurable. If you prefer next/script, strategy="afterInteractive" achieves the same.
Installation — Pages Router
// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="en">
<Head>
<script
defer
src="https://clycyo.com/tracker.js"
data-tracking-id="YOUR_TRACKING_ID"
/>
</Head>
<body><Main /><NextScript /></body>
</Html>
);
}What gets tracked automatically
- Initial pageviews with full load timing (navigationStart → loadEventEnd).
- Client-side route changes, with SPA frame-time so slow transitions are visible too.
- Web Vitals (LCP, CLS, INP) from real visitors — field data, not lab estimates.
- JavaScript errors with the URL they occurred on.
- Clicks, referrers, UTM parameters, device and country.
Product events in client components
'use client';
export function UpgradeButton() {
return (
<button
onClick={() => {
window.webanalytics?.track('upgrade_clicked', {
plan: 'pro',
location: 'pricing_page',
});
}}
>
Upgrade
</button>
);
}The optional chaining matters: the tracker is deferred, ad blockers exist, and your UI should never depend on analytics being present.
identify() after signup
// In your post-signup client logic
window.webanalytics?.identify(user.email);
// And persist the visitor id for server-side revenue webhooks
await api.post('/me', {
clycyo_visitor_id: window.webanalytics?.getVisitorId(),
});That second line is what lets a Stripe webhook attach revenue to this user's journey later — the full wiring is in our Stripe guide.
Static export, middleware, and edge cases
- output: 'export' works unchanged — the tracker is a plain script tag with no server dependency. This site is proof.
- Streaming/Suspense: the tracker fires on load and route change, not on hydration milestones, so partial rendering does not double-count.
- No cookie banner needed: cookieless tracking means your Next.js app ships without a consent wall for analytics — one less layout shift, better CLS.
Total setup time is about two minutes; the quickstart has copy-paste snippets for every framework. For the React-specific background on why SPA tracking breaks naive tools, see React SPA Analytics.