The problem specific to Next.js
In a traditional site, every page view is a full document load and the analytics script runs each time. In a Next.js application, the first load is a document request and every navigation after that is client-side — the URL changes, the content changes, and no new document loads.
An analytics script that only counts document loads will therefore report one page view per session and make your app look like a single-page site with terrible engagement.
There are two solutions, and the first is much better.
Solution one: use a tool that handles it
Most modern analytics scripts detect client-side navigation automatically, usually by hooking the History API. If yours does, you add the script once and nothing else is required.
Check the vendor's documentation for "SPA support" or "history mode" before writing any code. This is the path to take.
Where to put the script
In the App Router, add it to the root layout using the Script component:
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script
src="https://pure-analytics.com/tracking/utilities/script.js"
data-ea-domain="example.com"
strategy="afterInteractive"
/>
</body>
</html>
);
}afterInteractive is the right strategy for analytics: it loads after the page becomes interactive, so it never blocks rendering, but early enough that you do not lose fast bounces the way lazyOnload can.
Solution two: send page views manually
Only if your tool does not handle client-side routing. In a client component, watch the pathname and fire a page view on change:
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
export function PageViews() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
// call your analytics page-view function here
}, [pathname, searchParams]);
return null;
}Two traps. First, wrap a component that uses useSearchParams in a Suspense boundary, or the route opts out of static rendering. Second, guard against double counting on the initial load: many scripts already send the first view themselves, so your effect fires a duplicate.
Environment separation
Nothing is more annoying than development traffic in production data. Gate the script on the environment, and use a separate site or a different domain attribute for staging.
{process.env.NODE_ENV === "production" && <Script ... />}Before you ship, verify
- The first page load is counted once, not twice
- A client-side navigation is counted
- The back button is counted
- A route with query parameters is counted correctly, and the parameters you care about survive
- Nothing appears in the storage panel if the tool is meant to be cookieless
- The script does not appear in your development environment data
A note on Core Web Vitals
The reason to prefer a small script here is not ideology. Next.js applications are often optimised carefully for load performance, and a tracking script measured in tens of kilobytes undoes a meaningful portion of that work. Analytics should cost you a few kilobytes and no layout shift — if it costs more, you are paying for measurement with the thing you were trying to measure.