Blog
Developers12 min readVisitTrack Team

Add Analytics to Next.js Without Google Analytics

Add privacy-friendly analytics to a Next.js App Router app: next/script in the root layout, route changes, custom events, server-side signups and AI crawlers.

To add analytics to a Next.js App Router app without Google Analytics, load a lightweight analytics script once in your root layout with the next/script component and strategy="afterInteractive", let it pick up client-side route changes through the History API, and call a small function for custom events such as signups. For the events that must not be lost (signups, payments) and for AI crawlers that never run JavaScript, add a server-side call from a server action, route handler or proxy.ts. The whole setup is about 30 lines.

The examples use VisitTrack's script and endpoints because we can verify them line by line, and they target Next.js 16. The structure applies to any script-based analytics tool; swap the URL and attribute names for yours.

Key takeaways

  • Put one <Script> in app/layout.tsx with strategy="afterInteractive"; don't add tags per page or per client component.
  • App Router navigations use history.pushState, so a script that listens to the History API records each route change as a pageview with no router code.
  • Fire custom events after the action succeeds (after your API confirms a signup), with optional chaining so an ad blocker can't break your UI.
  • Send signups and payments from the server too, carrying the browser's visitor ID so they keep their traffic source.
  • In Next.js 16, middleware is called Proxy (proxy.ts); it is the right place to report AI crawler requests.

What are the options for Next.js analytics besides Google Analytics?

OptionHow it installsCookies by defaultNotes
Google Analytics 4@next/third-parties GoogleAnalytics component or gtag scriptYesNeeds consent in the EU and UK
Vercel Web Analytics@vercel/analytics componentNoTied to Vercel hosting
Plausible, Fathom, Umami, Simple AnalyticsScript tag (some have community packages)NoPrivacy-first page analytics
PostHog, Mixpanel, Amplitudenpm SDK, often initialized in instrumentation-client.tsYes, configurableProduct analytics: events, funnels, flags
VisitTrackScript tag via next/scriptFirst-party ID by default; cookieless and EU-only cookieless modesPage analytics plus revenue attribution and AI crawler tracking

If you are deciding between these, the Vercel Analytics, Plausible, PostHog and Google Analytics comparisons go into the differences. The rest of this guide is about wiring any script-based tool into Next.js correctly.

How do you add an analytics script to the App Router?

Use next/script in the root layout, inside <body>. The root layout wraps every route and persists across navigations, so the script loads exactly once per page load.

// app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          defer
          data-site="SITE_ID"
          src="https://visitrack.app/tracker.js"
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}
  • strategy="afterInteractive" (the default) loads the script after hydration begins, so it never blocks rendering. Avoid beforeInteractive, which Next.js reserves for scripts that must run before any page code.
  • next/script forwards data-* attributes to the generated <script> element, which is how the tracker reads its site ID.
  • Don't put the tag in a page or a client component that re-renders: you risk duplicate loads and double-counted pageviews.
  • With the Pages Router, put the same <Script> in pages/_app.tsx.

Do you need to track route changes manually in Next.js?

Not with a script that listens to the History API. App Router navigations (<Link>, router.push, router.replace) update the URL with history.pushState or replaceState, and back/forward fires popstate. VisitTrack's tracker wraps pushState and replaceState and listens for popstate, then records a pageview whenever the pathname changes. Query-string-only changes, such as a filter updating ?sort=price, are deliberately not new pageviews.

If your tool doesn't do this, the usual fix is a small client component that watches the path and reports it:

// app/route-tracker.tsx — only needed for tools without History API support
"use client";

import { usePathname } from "next/navigation";
import { useEffect } from "react";

export function RouteTracker() {
  const pathname = usePathname();
  useEffect(() => {
    // Replace with your tool's manual pageview call.
    window.myAnalytics?.pageview(pathname);
  }, [pathname]);
  return null;
}

Render it once in the root layout. Don't combine it with a tool that already tracks the History API, or every navigation counts twice.

How do you track custom events like signups in Next.js?

Call the event function after the action succeeds, not when the button is clicked, so failed attempts don't count. Declare the global once for TypeScript and use optional chaining so nothing throws when a blocker stops the script:

// global.d.ts
declare global {
  interface Window {
    visitrack?: ((name: string, props?: Record<string, unknown>) => void) & {
      identify?: (userId: string, traits?: Record<string, unknown>) => void;
      visitorId?: () => string;
    };
  }
}
export {};

// app/signup/signup-form.tsx
"use client";

export function SignupForm() {
  async function onSubmit(formData: FormData) {
    const res = await fetch("/api/signup", { method: "POST", body: formData });
    if (!res.ok) return;
    const user = await res.json();
    window.visitrack?.("signup", { method: "email" });
    window.visitrack?.identify?.(user.id, { plan: user.plan });
  }
  return <form action={onSubmit}>{/* fields */}</form>;
}

For clicks that don't need code, such as a pricing CTA, a data-vt-goal="cta_pricing" attribute on the element fires the event on click and keyboard activation. A data-vt-scroll="pricing_viewed" attribute fires once when the element is half visible. The full list is in the custom events docs.

How do you send signups from the server in Next.js?

OAuth signups and magic links often create the account in a server callback, where no tracking script runs. Send the event from there, with a secret API key that never reaches the browser, and pass the visitor ID the browser script assigned so the signup is joined to the visitor's earlier pageviews and source:

// app/auth/callback/route.ts (route handler, runs on the server)
import { cookies } from "next/headers";

export async function GET(request: Request) {
  const user = await finishOAuth(request); // your auth logic
  if (user.isNew) {
    // Set in the browser before redirecting to the provider:
    // document.cookie = "vt_vid=" + window.visitrack?.visitorId?.() + "; path=/; max-age=600"
    const visitorId = (await cookies()).get("vt_vid")?.value ?? user.id;

    await fetch("https://visitrack.app/api/track", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.VISITRACK_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ type: "event", visitorId, name: "signup", props: { method: "google" } }),
    });
  }
  return Response.redirect(new URL("/app", request.url));
}

Report each signup from one place only, either the browser or the server, or it is counted twice. Payments are best handled the same way, from your billing provider's webhook; that is what lets you attribute revenue to the traffic source that brought each customer, covered in revenue attribution. The trade-offs between the two sides are in server-side vs client-side tracking.

How do you track AI crawlers in a Next.js app?

GPTBot, ClaudeBot and PerplexityBot never run your script, so the only place to see them is on the server. In Next.js 16 that is proxy.ts (the renamed middleware.ts), which runs for every matched request. Use event.waitUntil so the report never delays the response:

// proxy.ts (next to app/, or in src/)
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";

export function proxy(request: NextRequest, event: NextFetchEvent) {
  event.waitUntil(
    fetch("https://visitrack.app/api/collect-bot", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        siteId: "SITE_ID",
        path: request.nextUrl.pathname,
        userAgent: request.headers.get("user-agent"),
        ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
      }),
    }).catch(() => {}),
  );
  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

If you already have a proxy.ts (for auth or redirects), add the waitUntil call to it and keep your own return value; Next.js supports only one proxy file per project. Keep robots.txt, llms.txt and sitemap.xml inside the matcher, since crawlers fetch those first. More on reading the results in how to track AI crawlers, and the full reference in the AI crawler tracking docs.

How do you collect Core Web Vitals in Next.js?

Next.js ships a useReportWebVitals hook in next/web-vitals that hands you each metric as it is measured, so you can forward it to any endpoint. Because the hook needs "use client", the Next.js docs recommend wrapping it in a small client component that the root layout imports, which keeps the client boundary narrow. That is the right approach if your analytics tool has no built-in vitals collection, or if you want vitals in your own logging pipeline.

Many analytics scripts collect real-user vitals on their own, which saves you the component. VisitTrack's tracker, for example, reports LCP, INP, CLS, FCP and TTFB once per page load from real visitors' browsers, using the browser's PerformanceObserver API; browsers that don't support a metric simply don't report it. Don't run both for the same tool, or you will store each measurement twice. Either way, field data from real visitors is what Google's Core Web Vitals assessment uses, so it is worth more than a single Lighthouse run on your laptop.

One Next.js-specific caveat: client-side navigations in the App Router don't trigger a new page load, so LCP and TTFB describe the first page of a visit, not each route. INP and CLS keep accumulating as the visitor interacts. Read vitals per landing page rather than per route if you want comparable numbers.

How do you set it all up, step by step?

  1. 1.Create a site in your analytics tool and copy the site ID.
  2. 2.Add the <Script> to app/layout.tsx as shown above. If you need no cookie banner for EU visitors, add data-cookieless (or data-hybrid for cookieless only in the EU, UK and Switzerland); see cookieless mode.
  3. 3.If your app has a Content-Security-Policy, add https://visitrack.app to script-src and connect-src, or the browser will silently block the script.
  4. 4.Exclude private areas with data-exclude="/app/*,/settings" if you don't want the logged-in app tracked as marketing traffic.
  5. 5.Add global.d.ts and fire custom events after successful actions.
  6. 6.Send OAuth and other server-created signups from the server with the visitor ID.
  7. 7.Add the crawler report to proxy.ts.
  8. 8.Deploy and test in production. The script ignores localhost by default (add data-allow-local to test locally); open the Network tab and look for a request to /api/collect, then check the live view.

What are the common mistakes?

  • Testing on localhost and seeing nothing. Most privacy-first scripts skip local hosts on purpose.
  • Double pageviews from a manual route tracker on top of a script that already tracks the History API.
  • Firing signup on button click. Abandoned OAuth flows and validation errors then count as signups.
  • A matcher that excludes everything with a file extension, which silently drops robots.txt and sitemap.xml from crawler tracking.
  • Putting the API key in a NEXT_PUBLIC_ variable. Server event keys must stay server-side; only the public site ID belongs in the browser.
  • Using @next/third-parties or a gtag snippet for GA4 in the EU without a consent gate. If you keep GA, load it only after consent; see Is Google Analytics legal in the EU?.

The Next.js integration page has the two-minute version of the install if you only need pageviews.

How do I add analytics to a Next.js App Router app?

Add the analytics script once in app/layout.tsx using the next/script component with strategy="afterInteractive". The root layout persists across navigations, so the script loads once and tracks every route.

Does Next.js App Router navigation trigger pageviews automatically?

Only if your analytics script listens to the History API. App Router navigations call history.pushState, so scripts that wrap pushState and listen for popstate record each route change; otherwise use a client component with usePathname.

Should I use next/script or a plain script tag?

Use next/script in the App Router. It handles loading strategy and makes sure the script loads only once across navigations, and it forwards data-* attributes to the final script element, so a plain tag gives you no advantage.

How do I track custom events in Next.js?

Call your analytics tool's event function from client code after the action succeeds, for example window.visitrack?.("signup"). Declare the global in a .d.ts file for TypeScript and use optional chaining so blocked scripts don't throw.

What replaced middleware.ts in Next.js 16?

Next.js 16 renamed middleware to Proxy: the file is proxy.ts and the exported function is proxy. Functionality is the same, and it runs on the Node.js runtime by default.

Can I use analytics in Next.js without a cookie banner?

Yes, with a tool that stores nothing in the browser, such as a cookieless mode. GA4 and other cookie-based tools need consent for EU and UK visitors; cookieless tools are designed to avoid that requirement, though you still need a privacy policy.