Skip to content
VerifiedUpdated 2026-06-13
On this page

GA4 is two separate jobs: load the gtag tag, and feed metrics into it. Keep them apart. The framework hands you a clean primitive for each, and neither one belongs in a raw <script> tag or a Server Component that pretends to be interactive. By the end of this page you will have an analytics component that reports in production, stays completely silent in dev and preview, and lives in exactly one place in your layout.

Get your GA4 measurement ID

In Google Analytics, create a property (or open an existing one), then add a Web data stream for your domain. Once the stream exists, GA shows a Measurement ID in the stream details. It looks like this:

measurement id format
G-XXXXXXXXXX

That G- prefix is the GA4 format (the older UA- IDs are gone). Copy it. This is the only piece of GA-specific config you need.

Build an env-driven Analytics component

Create one component that owns the whole GA tag. The rule that makes it safe to ship to every environment: read the ID from the environment, and render nothing when it is missing.

components/analytics.tsx
import Script from "next/script";
 
/**
 * Google Analytics 4. Renders nothing unless NEXT_PUBLIC_GA_ID is set, so the
 * site stays clean in development and previews and only reports in production
 * once configured. afterInteractive keeps the GA script off the critical path.
 */
export function Analytics() {
  const gaId = process.env.NEXT_PUBLIC_GA_ID;
  if (!gaId) return null;
 
  return (
    <>
      <Script
        src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
        strategy="afterInteractive"
      />
      <Script id="ga4-init" strategy="afterInteractive">
        {`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', '${gaId}');
        `}
      </Script>
    </>
  );
}

Three things are load-bearing here.

The if (!gaId) return null guard is the whole point. No ID, no output, no network request, no GA cookie. Your local dev server and every Vercel preview deployment stay clean unless you deliberately set the variable for them.

There are two <Script> elements, not one. The first has a src and pulls in the gtag library from Google. The second is inline and bootstraps the dataLayer queue plus the gtag('config', ...) call. An inline <Script> must carry an id so the framework can dedupe it across navigations. That is why the second one is id="ga4-init".

Note the static process.env.NEXT_PUBLIC_GA_ID reference. NEXT_PUBLIC_ values are inlined at build time, but only when referenced directly like this. If you indirect through a computed key (process.env[name]), the value is not inlined and your component silently no-ops in production. Keep the reference literal.

Avoid

// Raw tag: no loading strategy, blocks hydration, runs everywhere
// including dev and preview because there is no env guard.
export function Analytics() {
  return (
    <script
      src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
    />
  );
}

Prefer

// next/script with afterInteractive, and an env guard that
// no-ops when the ID is unset.
import Script from "next/script";
 
export function Analytics() {
  const gaId = process.env.NEXT_PUBLIC_GA_ID;
  if (!gaId) return null;
  return (
    <Script
      src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
      strategy="afterInteractive"
    />
  );
}

Understand the loading strategies

next/script gives you four loading strategies through the strategy prop. Picking the right one is the difference between analytics that costs you nothing and analytics that drags your interactivity.

  • beforeInteractive: injected into the server HTML and fetched before any framework code. Reserved for things the whole site needs immediately, like a bot detector or a cookie consent manager. It must live in the root layout. GA does not belong here.
  • afterInteractive: the default. The script loads early but after first-party hydration, so it never blocks your app from becoming interactive. The docs name tag managers and analytics as the canonical use case for this strategy. This is what you want for GA4.
  • lazyOnload: loads during browser idle time, after everything else on the page. Good for low-priority widgets like chat support, too late for analytics that should catch the initial pageview.
  • worker: experimental, off-loads the script to a web worker, and does not work with the App Router. Skip it.

Load GA4 with strategy="afterInteractive". It is the documented strategy for analytics and the default, so the tag stays off your critical rendering path.

Do not use beforeInteractive for GA. It pushes the tag ahead of your own code for a measurement script that has no business blocking the page, and it forces the tag into the layout head whether you want it there or not.

Mount it once in the root layout

The component is a Server Component (no "use client", no hooks, no browser APIs at the React level), so import it straight into the root layout and render it once. Because it uses afterInteractive, the tag loads on whatever page the visitor opens, across every route.

app/layout.tsx
import { Analytics } from "@/components/analytics";
import "./globals.css";
 
export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

Mount it in the root layout and nowhere else. Mounting per page means duplicate config calls and double-counted pageviews. One mount in app/layout.tsx covers the entire site, including nested route groups, with a single source of truth.

Set the variable per environment

The component reads NEXT_PUBLIC_GA_ID. Set it only where you actually want to report. Leave it unset locally so your dev pageviews never pollute production data.

.env.local (do not commit)
# Leave this commented out in dev so Analytics() no-ops.
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX

For production, set the variable in your host. On Vercel that is the project's Environment Variables, scoped to Production only, so preview deployments stay silent too. Then redeploy.

To confirm it works: run a production build with the variable set, open the site, and in the browser console check window.dataLayer. You should see the js and config entries. In GA4, the Realtime report should show your visit within a few seconds. With the variable unset, the page source contains no googletagmanager.com request at all.

Respect consent before you fire

In many regions you cannot set analytics cookies until the visitor agrees. GA4 supports this through Consent Mode: you tell gtag the default consent state up front, then update it when the user chooses. The default should deny analytics storage until consent is granted.

components/analytics.tsx (consent default)
<Script id="ga4-consent" strategy="beforeInteractive">
  {`
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('consent', 'default', {
      analytics_storage: 'denied',
    });
  `}
</Script>

The consent default is the one GA snippet that legitimately uses beforeInteractive: it has to run before the tag does its first measurement. When your consent banner records a yes, call gtag('consent', 'update', { analytics_storage: 'granted' }) from the banner's client component. This page does not ship a full banner; pair it with whatever consent manager your project already uses.

Optional: forward Core Web Vitals

The framework measures Core Web Vitals (LCP, CLS, INP, and the rest) for you. To push them into the same GA4 instance, use the useReportWebVitals hook. It needs "use client", so the documented pattern is a dedicated client component imported by the layout, which confines the client boundary to just this file.

app/_components/web-vitals.tsx
"use client";
 
import { useReportWebVitals } from "next/web-vitals";
 
export function WebVitals() {
  useReportWebVitals((metric) => {
    window.gtag("event", metric.name, {
      value: Math.round(
        metric.name === "CLS" ? metric.value * 1000 : metric.value,
      ), // GA4 event values must be integers
      event_label: metric.id, // unique to the current page load
      non_interaction: true, // does not affect bounce rate
    });
  });
}

The CLS special case is not cosmetic. GA4 event values must be integers, and CLS is a small decimal, so you scale it by 1000 before rounding. Import <WebVitals /> into the root layout next to <Analytics />. It only does anything once the gtag tag has loaded, so the env guard on Analytics still governs whether any of this reports.

Sources

  • Next.js docs01-app/02-guides/analytics.md
  • Next.js docs01-app/03-api-reference/02-components/script.md