Guides7 min read

How to add analytics to Next.js App Router

Step-by-step: add cookie-free analytics to a Next.js App Router app with next/script in the root layout, plus SPA route tracking.

Adding analytics to a Next.js App Router project is one of those tasks that looks trivial and then surprises people, because the App Router does not reload the page on navigation. If you paste a script tag into an old Pages Router project, every route change fires a fresh page load and everything works. In the App Router, a client-side navigation swaps the page content without a full reload, so a naive install can count the first page and then go quiet.

The good news is that the fix is small. You add one script once, in the root layout, and choose a tool whose script already listens for the History API. That is the whole install. This guide uses Lumenlytic as the example because its script handles client-side navigation out of the box, but the same pattern works for any tool that supports single-page apps.

Why the script belongs in the root layout

The App Router renders a root layout that wraps every route in the application. If you put the analytics tag there, it loads exactly once for the whole session, no matter how many routes the visitor opens. Put it in an individual page instead, and you either lose tracking on other pages or you reload the script on every navigation, which is wasteful and can double-count. The root layout is the correct home for anything that should exist for the entire visit.

Step 1 — add the script once

Open app/layout.tsx and import Script from next/script. Place the component inside the body, after your providers. The afterInteractive strategy loads the script after the page becomes interactive, so it never blocks rendering or harms your Core Web Vitals.

import Script from "next/script";

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://cdn.lumenlytic.com/analytics.js"
          data-website-id="YOUR_TRACKING_ID"
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}

Replace YOUR_TRACKING_ID with the id from your site settings. The id is safe to expose in page source; it only tells the script which site is reporting and cannot be used to read your data.

Client-side navigation is handled for you

Because the App Router uses the History API for navigation, the tracking script needs to fire on pushState and popstate events rather than only on the initial load. A script that supports single-page apps subscribes to those events automatically and sends a pageview each time the URL changes. You do not need a usePathname effect, a route-change listener or any wrapper component. If you are evaluating a tool and its documentation does not mention SPA or history tracking, treat that as a warning sign: in the App Router you will silently miss every page after the first.

Track custom events

Pageviews cover most marketing questions, but eventually you want to know whether people clicked the pricing button or finished a signup. A well-designed script exposes a small global helper, so you do not need a React provider just to send an event. Call it at the moment the action happens.

"use client";

export function SignupButton() {
  function handleClick() {
    // The global helper is available once the script has loaded.
    if (typeof window !== "undefined" && window.lumen) {
      window.lumen("Signup", { plan: "pro" });
    }
  }

  return <button onClick={handleClick}>Start free trial</button>;
}

Keep custom event names stable and short, and put the variable detail in properties rather than in the name. A single Signup event with a plan property is far easier to analyse later than Signup-Pro, Signup-Studio and Signup-Agency as separate events.

Verify the install

Load your site, open the real-time view in your analytics dashboard, and navigate between two routes. You should see the first pageview appear within a few seconds and a second pageview when you click to another route. If the first appears but the second does not, the script is not handling client-side navigation and needs a manual route-change call. If nothing appears, check for an ad blocker, a strict browser privacy mode, or a mistyped data-website-id. Adding ?lumen_debug to your URL logs what the script sends to the console while you test.

Common mistakes in the App Router

  • Putting the script in a page component instead of the root layout.
  • Wrapping the tag in a component that renders on every route change.
  • Assuming a full page reload happens on navigation — it does not.
  • Forgetting the data-website-id, so the script has nothing to report to.
  • Loading the script with a blocking strategy and hurting Largest Contentful Paint.

None of these are hard to avoid once you know them. The root-layout pattern plus a script that understands the History API is genuinely all it takes. From there, the important thing is to keep the script deferred and the event names tidy, and to confirm with the real-time view that route changes are being counted.

A note on serving the script

The example loads the script from the vendor's CDN. That is the simplest option and it is fine for most sites. If you want to reduce the chance of the script being blocked, or if your Content Security Policy only allows assets from your own origin, you can download the script, host it yourself and point the src at your domain. The tracking identifier still tells the service which site is reporting, so self-hosting the file does not change how the data flows. Keep it deferred wherever it lives, and serve it with long cache headers so repeat visits do not pay for it again.

If you add a strict Content Security Policy, remember that the script needs to be allowed in script-src and that it will post events to the ingest origin, so connect-src must include that origin too. A report-only policy is a good way to discover the origins your app actually uses before you enforce anything. Start with report-only, watch the console for violations, then tighten. That is far less disruptive than enabling a strict policy in one go and discovering a blocked script after a deploy. A report-only run costs nothing and tells you exactly which origins your app touches, so make it the first step whenever you add or change a third-party script. Keep the report for a week, review the violations, and enforce only when the list is empty. That small discipline prevents the most common cause of silently missing analytics. It is a small habit that pays for itself the first time a tag goes missing.

Related posts