Integrate Google Analytics with Next.js App

Integrate Google Analytics with Next.js App

Analytics is one of those things that is easy to ignore when you are in the middle of building features, and then suddenly it becomes the one tool you wish you had set up earlier. You ship a landing page, publish a blog, launch a product, or start collecting signups, and then the questions begin: Where are users coming from? Which pages do they read? Which buttons do they click? Where do they leave? Which campaign actually worked?

Google Analytics helps answer those questions, and Next.js gives you a very clean way to wire it in without turning your app into a mess of tracking code. The good news is that with a little structure, you can track page views, custom events, conversions, user behavior, and even consent-aware analytics without hurting performance or making your codebase harder to maintain.

This guide walks through the full process of integrating Google Analytics into a Next.js app in a practical, modern way. It focuses on GA4, because that is the current generation of Google Analytics used for most new projects. It also shows how to handle the App Router, how to track route changes properly, how to send custom events, and how to keep your implementation clean enough that future-you will not hate present-you.


Why analytics matters more than most people think

A lot of developers add analytics only because someone asks for it. That is understandable, but analytics is much more than a business checkbox. It is feedback from real users, and it often reveals things that no amount of guessing can uncover.

Maybe your homepage looks beautiful, but users scroll halfway and leave. Maybe your signup flow works perfectly in testing, but people abandon it on mobile because one field feels annoying. Maybe a blog post gets tons of traffic, but none of that traffic turns into product interest because the page never invites readers to take the next step. Or maybe your marketing team is spending time and money on the wrong channels, and analytics gives you the evidence to make a better choice.

Next.js is especially well suited for analytics because it is often used for sites that care about performance, SEO, and conversion. That means there is usually real value in understanding user journeys, not just raw traffic numbers. A well-instrumented app gives you insight without sacrificing the fast, elegant experience that made you choose Next.js in the first place.


What you are actually integrating

When people say “Google Analytics,” they often mean a few different things.

For a modern Next.js app, the most common choice is GA4, which is Google’s event-based analytics platform. Instead of thinking mostly in terms of page views alone, GA4 models everything as events. Page views are events. Button clicks are events. Form submissions are events. Purchases are events. That event-driven model is actually a very nice fit for React and Next.js, because your UI also behaves like a stream of events.

In practice, your integration usually needs three things:

  1. A way to load the Google Analytics script efficiently.

  2. A way to track route changes, since Next.js is a single-page app experience after the first load.

  3. A way to send custom events from components and user interactions.

If you get those three pieces right, the rest becomes mostly about naming, consistency, and privacy.


Before you start

You will need:

  • A Next.js app

  • A Google Analytics 4 measurement ID

  • Access to your app’s environment variables

  • A decision about whether to track in development or only in production

If you have not created a GA4 property yet, you can do that in Google Analytics and get a measurement ID that usually looks like G-XXXXXXXXXX.

For a real production app, it is a very good idea to keep this ID in an environment variable. That makes the implementation cleaner and makes it easier to switch between environments.


Recommended approach for Next.js

There are several ways to integrate analytics into Next.js, but the cleanest approach for most apps is:

  • Load the GA script with Next.js’ built-in Script component.

  • Track route changes from a client-side component.

  • Expose a tiny reusable helper for sending events.

This approach is simple, readable, and easy to maintain. It also avoids introducing a heavy dependency just for analytics.


Step 1: Add your environment variable

In your .env.local file, add:

NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX

Using NEXT_PUBLIC_ is important because the measurement ID needs to be available in the browser. Google Analytics is client-facing by design, so this is expected.

A useful habit is to separate production configuration from local development. For example, you may want analytics enabled only in production, or you may want to point development traffic to a test property so your data stays clean.


Step 2: Create a small analytics helper

Rather than sprinkling window.gtag(...) throughout your components, create a utility module. That keeps your tracking logic in one place and makes event names easier to standardize.

Create something like lib/analytics.ts:

// lib/analytics.ts
export const GA_ID = process.env.NEXT_PUBLIC_GA_ID;

declare global {
  interface Window {
    gtag?: (...args: any[]) => void;
  }
}

export const pageview = (url: string) => {
  if (!GA_ID) return;

  window.gtag?.("config", GA_ID, {
    page_path: url,
  });
};

type AnalyticsEventParams = {
  action: string;
  category?: string;
  label?: string;
  value?: number;
};

export const event = ({
  action,
  category = "engagement",
  label,
  value,
}: AnalyticsEventParams) => {
  if (!GA_ID) return;

  window.gtag?.("event", action, {
    event_category: category,
    event_label: label,
    value,
  });
};

This file gives you two basic functions:

  • pageview(url) for page navigation tracking

  • event(...) for custom interactions

A helper like this is small, but it makes the rest of your app cleaner immediately.


Step 3: Load Google Analytics script in your Next.js layout

If you are using the App Router, the best place to load the analytics script is usually your root layout.

Here is a clean version for app/layout.tsx:

// app/layout.tsx
import "./globals.css";
import Script from "next/script";
import { GA_ID } from "@/lib/analytics";
import Analytics from "@/components/Analytics";

export const metadata = {
  title: "My Next.js App",
  description: "A Next.js app with Google Analytics",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}

        {GA_ID && (
          <>
            <Script
              src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
              strategy="afterInteractive"
            />
            <Script
              id="google-analytics"
              strategy="afterInteractive"
              dangerouslySetInnerHTML={{
                __html: `
                  window.dataLayer = window.dataLayer || [];
                  function gtag(){dataLayer.push(arguments);}
                  gtag('js', new Date());
                  gtag('config', '${GA_ID}', {
                    page_path: window.location.pathname,
                  });
                `,
              }}
            />
            <Analytics />
          </>
        )}
      </body>
    </html>
  );
}

This does a few important things:

  • Loads the Google Analytics library after the page becomes interactive.

  • Initializes gtag.

  • Sends an initial page view.

  • Mounts a client component that will handle route changes.

The strategy="afterInteractive" option is a good fit because it avoids blocking initial rendering. That matters in Next.js, where performance is one of the big reasons people choose the framework in the first place.


Step 4: Track route changes in the App Router

In a single-page app experience, the browser does not fully reload when the user moves between routes. That means Google Analytics will not automatically see every page change unless you tell it.

For the App Router, a small client component can watch the pathname and query parameters and send page views when they change.

Create components/Analytics.tsx:

"use client";

import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { pageview } from "@/lib/analytics";

export default function Analytics() {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    if (!pathname) return;

    const query = searchParams.toString();
    const url = query ? `${pathname}?${query}` : pathname;

    pageview(url);
  }, [pathname, searchParams]);

  return null;
}

This component is intentionally tiny. It does one job only: it watches for route changes and forwards them to GA.

That separation is worth keeping. Analytics code tends to become messy when it starts spreading across components. A dedicated route tracker keeps the logic predictable.


Step 5: Send custom events

Page views are useful, but the real insight often comes from custom actions. Maybe a user clicks “Start free trial.” Maybe someone submits a newsletter form. Maybe they open a pricing accordion or watch a video.

With the helper we created earlier, tracking an event is easy.

Example button component:

"use client";

import { event } from "@/lib/analytics";

export default function PricingButton() {
  return (
    <button
      onClick={() =>
        event({
          action: "click_pricing_cta",
          category: "button",
          label: "pricing page hero",
        })
      }
      className="rounded-lg bg-black px-4 py-2 text-white"
    >
      Start Free Trial
    </button>
  );
}

Example form submission tracking:

"use client";

import { useState } from "react";
import { event } from "@/lib/analytics";

export default function NewsletterForm() {
  const [email, setEmail] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    event({
      action: "newsletter_signup",
      category: "form",
      label: "homepage newsletter",
    });

    // submit your form here
    console.log("Submitting:", email);
  };

  return (
    <form onSubmit={handleSubmit} className="flex gap-2">
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Enter your email"
        className="rounded-md border px-3 py-2"
      />
      <button type="submit" className="rounded-md bg-blue-600 px-4 py-2 text-white">
        Subscribe
      </button>
    </form>
  );
}

There is one subtle but important point here: do not over-track everything. More data is not always better. If you track every mouse movement and every tiny interaction, you may end up with noise instead of insight. The best analytics setups are opinionated. They track the events that actually matter.


A cleaner reusable trackEvent function

Some developers prefer a more explicit naming style because it reads nicely in components.

You can replace event with trackEvent if that feels clearer:

// lib/analytics.ts
export const trackEvent = (
  action: string,
  params: {
    category?: string;
    label?: string;
    value?: number;
  } = {}
) => {
  if (!GA_ID) return;

  window.gtag?.("event", action, {
    event_category: params.category ?? "engagement",
    event_label: params.label,
    value: params.value,
  });
};

Then in your component:

trackEvent("download_pdf", {
  category: "resource",
  label: "pricing_guide",
});

This style is often easier to read when you revisit code months later.


Handling the Pages Router

Not every Next.js project has moved to the App Router yet. If you are still using the Pages Router, the idea is the same, but the route tracking is usually done in _app.tsx with the router events API.

Here is an example:

// pages/_app.tsx
import type { AppProps } from "next/app";
import Script from "next/script";
import { useEffect } from "react";
import { useRouter } from "next/router";
import { GA_ID } from "@/lib/analytics";

export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();

  useEffect(() => {
    if (!GA_ID) return;

    const handleRouteChange = (url: string) => {
      window.gtag?.("config", GA_ID, {
        page_path: url,
      });
    };

    router.events.on("routeChangeComplete", handleRouteChange);

    return () => {
      router.events.off("routeChangeComplete", handleRouteChange);
    };
  }, [router.events]);

  return (
    <>
      {GA_ID && (
        <>
          <Script
            src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
            strategy="afterInteractive"
          />
          <Script
            id="google-analytics"
            strategy="afterInteractive"
            dangerouslySetInnerHTML={{
              __html: `
                window.dataLayer = window.dataLayer || [];
                function gtag(){dataLayer.push(arguments);}
                gtag('js', new Date());
                gtag('config', '${GA_ID}', {
                  page_path: window.location.pathname,
                });
              `,
            }}
          />
        </>
      )}
      <Component {...pageProps} />
    </>
  );
}

The Pages Router version is slightly more manual, but the idea is still the same: initialize once, track route changes, and keep event tracking available in reusable helpers.


Avoiding duplicate page views

One of the most common mistakes is accidentally sending duplicate page views.

This can happen when:

  • You initialize GA in the layout and also in another component.

  • You track both on script load and on route change without coordination.

  • You use a wrapper library that already sends page views automatically, then add your own manual tracking on top.

Duplicate page views can make your analytics misleading. A page with 500 visits may appear to have 1,000. That sounds like a minor issue until you start making decisions based on the wrong numbers.

The easiest way to avoid this is to choose one source of truth for page views and stick with it. In a Next.js App Router setup, a common pattern is:

  • initialize GA once in the layout

  • send the first page view on load

  • send subsequent page views from the route watcher

That keeps behavior consistent.


Tracking conversions the right way

Analytics is most valuable when it helps you understand outcomes, not just traffic. A conversion can mean many things depending on your product:

  • newsletter signup

  • account registration

  • trial start

  • purchase

  • demo request

  • file download

  • contact form submission

You should define these clearly before wiring them in. A conversion should reflect a real business or product milestone, not just a random click.

Example conversion event:

trackEvent("generate_lead", {
  category: "conversion",
  label: "demo_request_form",
});

For ecommerce, GA4 has more structured recommended events like purchase, add_to_cart, and begin_checkout. Those are worth following when your app has a store or payment flow.

For non-ecommerce apps, your own consistent naming conventions matter just as much. Pick names that are descriptive and stable. cta_click is okay. button1_clicked is not helpful. Future analysis is much easier when the names still make sense after the code around them changes.


Tracking scroll depth, downloads, and other useful behavior

Sometimes the most valuable events are not obvious at first. A blog or marketing site often benefits from tracking things like:

  • scroll depth

  • outbound link clicks

  • PDF or file downloads

  • video plays

  • pricing card selections

  • tab interactions on feature pages

Example outbound link tracking:

"use client";

import { trackEvent } from "@/lib/analytics";

export default function ExternalLink() {
  return (
    <a
      href="https://example.com"
      target="_blank"
      rel="noopener noreferrer"
      onClick={() =>
        trackEvent("click_outbound_link", {
          category: "navigation",
          label: "example.com",
        })
      }
    >
      Visit partner site
    </a>
  );
}

Example download tracking:

<button
  onClick={() =>
    trackEvent("download_whitepaper", {
      category: "resource",
      label: "ai_guide_2026",
    })
  }
>
  Download whitepaper
</button>

You can also use these patterns to track internal product behaviors, but again, keep it intentional. The goal is not to create a giant wall of data. The goal is to answer specific questions.


Using Google Tag Manager instead

There is another path you might hear about often: Google Tag Manager. Some teams use GTM instead of directly embedding GA code.

That can be useful when:

  • marketing wants to manage tags without deploys

  • you expect multiple scripts and tracking pixels

  • you want a central dashboard for tag configuration

But if your needs are simple and you just want solid analytics, direct GA4 integration is usually easier and less fragile. For many Next.js apps, especially early-stage products, direct integration is the most sensible default.

If your project already uses GTM, the Next.js pattern is similar: load the tag manager script carefully, initialize it once, then push events to the data layer. The implementation is a little different, but the conceptual flow is nearly the same.


Consent, privacy, and user trust

This part matters more than people often admit. Tracking is not just a technical issue. It is a trust issue.

Depending on where your users live and how your site operates, you may need to show a cookie consent banner or respect tracking preferences before loading analytics. Even when regulations do not strictly force a banner, it is often a good idea to be transparent about what you collect.

A privacy-friendly approach usually means:

  • not loading analytics until consent is given, if required

  • not tracking sensitive personal data

  • not sending raw emails, names, phone numbers, or other personal identifiers to GA

  • keeping event names generic and business-safe

  • documenting what you track and why

You should never use analytics as an excuse to quietly collect more than users would reasonably expect. A healthy implementation is useful and respectful at the same time.


A consent-aware pattern

Here is a simple example of delaying analytics until a user accepts tracking. This is not a full consent management platform, but it shows the structure.

"use client";

import Script from "next/script";
import { useState, useEffect } from "react";
import { GA_ID } from "@/lib/analytics";

export default function AnalyticsWithConsent() {
  const [consent, setConsent] = useState(false);

  useEffect(() => {
    const saved = localStorage.getItem("analytics_consent");
    setConsent(saved === "yes");
  }, []);

  const accept = () => {
    localStorage.setItem("analytics_consent", "yes");
    setConsent(true);
  };

  return (
    <>
      {!consent && (
        <div className="fixed bottom-4 left-4 right-4 rounded-lg border bg-white p-4 shadow">
          <p className="mb-3 text-sm">
            We use analytics to understand how people use the site.
          </p>
          <button
            onClick={accept}
            className="rounded-md bg-black px-4 py-2 text-white"
          >
            Accept analytics
          </button>
        </div>
      )}

      {consent && GA_ID && (
        <Script
          src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
          strategy="afterInteractive"
        />
      )}
    </>
  );
}

A real-world consent setup may be more sophisticated, but the core idea remains simple: do not start tracking before you are ready to do so.


Debugging your analytics setup

The first time you integrate analytics, expect a little debugging. That is normal. A setup can look correct in code and still fail quietly because of a small config issue.

Here are the most common things to check:

  • Is your measurement ID correct?

  • Is the environment variable available on the client?

  • Is the script actually loading in the browser?

  • Are you testing in an environment where analytics is intentionally disabled?

  • Are you sending duplicate events?

  • Is your browser blocking tracking scripts?

GA4 also has debugging tools and real-time views that help you confirm whether events are arriving. When the setup is working, test a few flows end to end: load the homepage, navigate between routes, click a tracked button, submit a form, and verify that the events appear as expected.

A clean test procedure is worth the effort because analytics bugs are sneaky. They usually do not crash the app. They just quietly give you wrong information.


A more complete analytics module

For a real app, it can help to centralize more functionality in one module. Here is a slightly richer version:

// lib/analytics.ts
export const GA_ID = process.env.NEXT_PUBLIC_GA_ID;

declare global {
  interface Window {
    gtag?: (...args: any[]) => void;
  }
}

export const isAnalyticsEnabled = () => {
  return typeof window !== "undefined" && !!GA_ID;
};

export const initializeAnalytics = () => {
  if (!GA_ID || typeof window === "undefined") return;

  window.gtag?.("js", new Date());
  window.gtag?.("config", GA_ID, {
    page_path: window.location.pathname,
  });
};

export const trackPageView = (url: string) => {
  if (!GA_ID || typeof window === "undefined") return;

  window.gtag?.("config", GA_ID, {
    page_path: url,
  });
};

export const trackEvent = (
  action: string,
  params: Record<string, string | number | undefined> = {}
) => {
  if (!GA_ID || typeof window === "undefined") return;

  window.gtag?.("event", action, params);
};

This style is especially nice in larger codebases because it makes your analytics API feel intentional rather than improvised.


Example: full App Router implementation

Here is a simple but realistic example of the full setup in an App Router project.

lib/analytics.ts

export const GA_ID = process.env.NEXT_PUBLIC_GA_ID;

declare global {
  interface Window {
    gtag?: (...args: any[]) => void;
  }
}

export const pageview = (url: string) => {
  if (!GA_ID) return;

  window.gtag?.("config", GA_ID, {
    page_path: url,
  });
};

export const trackEvent = (
  action: string,
  params: {
    category?: string;
    label?: string;
    value?: number;
  } = {}
) => {
  if (!GA_ID) return;

  window.gtag?.("event", action, {
    event_category: params.category ?? "engagement",
    event_label: params.label,
    value: params.value,
  });
};

components/Analytics.tsx

"use client";

import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { pageview } from "@/lib/analytics";

export default function Analytics() {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    if (!pathname) return;

    const query = searchParams.toString();
    const url = query ? `${pathname}?${query}` : pathname;

    pageview(url);
  }, [pathname, searchParams]);

  return null;
}

app/layout.tsx

import "./globals.css";
import Script from "next/script";
import { GA_ID } from "@/lib/analytics";
import Analytics from "@/components/Analytics";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}

        {GA_ID && (
          <>
            <Script
              src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
              strategy="afterInteractive"
            />
            <Script
              id="gtag-init"
              strategy="afterInteractive"
              dangerouslySetInnerHTML={{
                __html: `
                  window.dataLayer = window.dataLayer || [];
                  function gtag(){dataLayer.push(arguments);}
                  gtag('js', new Date());
                  gtag('config', '${GA_ID}', {
                    page_path: window.location.pathname,
                  });
                `,
              }}
            />
            <Analytics />
          </>
        )}
      </body>
    </html>
  );
}

Example button tracking

"use client";

import { trackEvent } from "@/lib/analytics";

export default function HeroCTA() {
  return (
    <button
      onClick={() =>
        trackEvent("click_cta", {
          category: "hero",
          label: "get_started",
        })
      }
      className="rounded-xl bg-blue-600 px-5 py-3 text-white"
    >
      Get Started
    </button>
  );
}

That is enough to get a solid, maintainable analytics foundation in place.


Example: tracking a multi-step funnel

One of the best uses of analytics is understanding how people move through a funnel. Suppose your app has a three-step onboarding flow.

You might track events like:

  • onboarding_step_view

  • onboarding_step_complete

  • onboarding_finished

Example:

trackEvent("onboarding_step_view", {
  category: "onboarding",
  label: "step_1_profile",
});
trackEvent("onboarding_step_complete", {
  category: "onboarding",
  label: "step_1_profile",
});
trackEvent("onboarding_finished", {
  category: "conversion",
  label: "onboarding_flow",
});

With that data, you can later see where users hesitate and where the flow is working beautifully. That kind of insight is often more valuable than pure traffic numbers because it directly connects user behavior to product success.


Performance considerations

Analytics should not make your app feel slow or bloated. Next.js already gives you a strong performance baseline, so your analytics setup should respect that.

A few good habits help a lot:

  • Load analytics after the app becomes interactive.

  • Avoid loading multiple analytics libraries unless you truly need them.

  • Keep the tracking helper tiny.

  • Do not wrap analytics in unnecessary stateful logic.

  • Do not block rendering just to initialize analytics.

The script loading pattern shown earlier is a good default because it defers external script execution until the page is interactive. That helps protect your user experience while still capturing useful data.


Naming conventions that save you later

Analytics becomes much more useful when your event names are consistent.

A few simple conventions help:

  • Use lowercase snake_case or a consistent naming style.

  • Make names descriptive, not clever.

  • Keep the same event names across pages and components.

  • Group related events using category or logical naming patterns.

  • Avoid renaming events constantly unless necessary.

Examples of good event names:

  • sign_up

  • newsletter_signup

  • download_pdf

  • click_pricing_cta

  • start_trial

Examples of weaker names:

  • btn1

  • clicked2

  • event_123

  • my_special_action

Good names make reports understandable. Weak names make reports feel like a puzzle nobody wants to solve.


Common mistakes to avoid

A few mistakes show up again and again:

1. Tracking too late

If your script loads only after a user action, you may miss the first page view or early interactions.

2. Tracking too much

Not every interaction deserves an event. Keep the important ones.

3. Sending personal data

Avoid sending names, emails, or other sensitive values directly into analytics.

4. Forgetting route changes

This is especially common in Next.js. The initial page may be tracked, but navigation between pages is missed.

5. Creating duplicate trackers

Multiple analytics components can produce duplicate events and confusing reports.

6. Hardcoding the measurement ID all over the app

Keep the ID in one place and refer to it through a helper.

7. Ignoring consent and privacy

A technically working implementation is not enough if it ignores user trust or legal requirements.


When to use enhanced measurement and when not to

GA4 offers enhanced measurement features that can automatically collect some interactions, depending on your configuration. That can be convenient, but convenience is not always the same as clarity.

Automatic tracking is fine for general overview data. But when you care about a specific funnel or product behavior, explicit event tracking is usually better because it is more predictable and easier to interpret. In other words, let the automatic features cover broad baseline behavior, and use manual events for the business-critical actions.

That combination often gives you the best of both worlds.


A practical analytics checklist for a Next.js app

Before you call the integration done, check these items mentally:

  • The GA4 measurement ID is stored in an environment variable.

  • The analytics script loads only once.

  • The app sends an initial page view.

  • Route changes are tracked.

  • Important conversions are tracked as custom events.

  • Sensitive data is not sent to analytics.

  • Duplicate events are not happening.

  • Consent behavior is handled correctly.

  • Development and production behavior are intentionally different if needed.

That list may look simple, but it covers the parts that usually make or break the quality of your implementation.


A human way to think about analytics

Analytics works best when you think of it less like surveillance and more like listening. Your app is having thousands of tiny conversations with users every day. Some people arrive curious and leave quickly. Some explore deeply. Some nearly convert and then hesitate. Some come back three times before they trust you enough to sign up.

Google Analytics gives you a rough map of that behavior. It will never tell the whole story, and it should not be treated like absolute truth. But it will show you patterns, and patterns are where good product decisions begin.

The real value is not in collecting data for the sake of collecting data. It is in creating a feedback loop. You build. Users respond. You observe. You improve. That is the rhythm of a healthy product.


Final thoughts

Integrating Google Analytics into a Next.js app is not difficult once you break it into the right pieces. Load the script cleanly, track route changes correctly, centralize your event helpers, and keep privacy in mind from the start. If you do that, analytics becomes a helpful part of your stack instead of a messy afterthought.

A good setup should feel invisible in the codebase and valuable in the dashboard. It should not slow your app down, and it should not make your components harder to read. It should simply give you the information you need to make better decisions.