Google Tag Manager & GA4 Setup for Next.js 16 (2026 Guide)
You finally got Google Search Console wired up and Google is crawling your site properly. Great — now you have zero idea what any of those visitors actually do once they land. Which pages do they bounce from? Did that "Subscribe" button you spent an hour styling get clicked even once this week? Search Console tells you how people find you. It says nothing about what happens next.
That's the gap Google Tag Manager (GTM) and Google Analytics 4 (GA4) fill — and in Next.js 16, setting both up correctly gives you a scalable analytics setup that can grow from a single GA4 tag to dozens of marketing and analytics integrations without changing your application code.
By the end of this guide, you'll have GTM installed the right way in the App Router, a GA4 property receiving real pageview data through GTM (not directly — there's a reason for that), a custom event tracked on a real button click, correct SPA pageview tracking for client-side navigations, and a working setup for Google's Consent Mode so you're not silently breaking analytics for EU visitors.
GTM vs. GA4: Get This Straight First
This trips up almost every beginner, so let's clear it up before touching any code.
- Google Analytics (GA4) is the tool that actually shows you data — pageviews, events, audiences, conversion reports. It's the dashboard.
- Google Tag Manager (GTM) is a container that delivers tags (tracking scripts) to your site — GA4, Google Ads conversion tracking, Meta Pixel, Hotjar, whatever you need — without you editing code every time marketing asks for a new pixel.
💡 Tip: Think of GTM as a delivery truck and GA4 as one of the packages it drops off. You could skip the truck and hand-deliver the GA4 package yourself (install
GoogleAnalyticsdirectly), but the moment you need a second or third tracking tool, you're stuck editing your codebase for every marketing request instead of adding a tag in a dashboard.
This guide sets it up the recommended way: GTM as the single container, GA4 wired inside GTM — not both installed side by side. Running GA4 twice (once directly, once through GTM) is one of the most common setup mistakes, and it silently doubles your pageview counts.
Why This Is Tricky in Next.js
Google's own install instructions assume a plain HTML site with a <head> and <body> you can paste a script into. Next.js's App Router doesn't work that way — you don't have a raw <head> tag to paste scripts into by hand, and doing it wrong can hurt your Core Web Vitals (specifically INP, since analytics scripts run JavaScript on every page).
The fix is @next/third-parties — an official Next.js package built specifically for this. It handles script loading strategy and script placement for you, so you don't have to reverse-engineer Google's raw <script> snippet into a Server Component. It does not, however, handle SPA pageview tracking for GTM automatically — more on that in Step 8.
Quick Comparison
| Feature | GTM | GA4 |
|---|---|---|
| Tracks visitors | ❌ | ✅ |
| Loads scripts | ✅ | ❌ |
| Dashboard | ❌ | ✅ |
| Events | via tags | stores events |
Prerequisites
This guide uses:
- Next.js 16 with the App Router
- TypeScript in strict mode
- A Google account (the same one you used for Search Console works fine)
You don't need a GA4 property or GTM container yet — we'll create both from scratch.
Step 1: Create a GTM Container
Head to tagmanager.google.com and sign in.
- Click Create Account
- Account name: your site or company name (e.g. "DevStacked")
- Country: your country
- Container name: your domain (e.g.
devstacked.tech) - Target platform: choose Web
- Click Create, accept the Terms of Service
You'll land on your container's workspace and see a Container ID in the top right that looks like GTM-XXXXXXX. That's the only value you need to copy right now.
⚠️ Common Mistake: Google shows you an install snippet with two
<script>blocks (one for<head>, one for<body>) right after creating the container. Ignore both of them — that's the plain-HTML method. In Next.js,@next/third-partiesreplaces this entirely, and pasting Google's raw snippet into your layout can actually break hydration.
Step 2: Create a GA4 Property
Head to analytics.google.com and sign in with the same account.
- Click Admin (the gear icon, bottom left)
- Under the Account column, click Create Account (or select an existing one)
- Fill in an account name, then click Next
- Under Property, enter a property name, your timezone, and currency
- Fill in basic business details, then click Create
- Under Data Streams, choose Web
- Enter your site's URL and a stream name, then click Create stream
You'll now see a Measurement ID that looks like G-XXXXXXXXXX. Copy that too — you'll paste it into GTM, not directly into your Next.js code.
💡 Tip: A GA4 "property" is the whole analytics dataset. A "data stream" is one source feeding it — your website in this case. You can add more streams (an iOS app, for example) to the same property later, and they'll all roll up into one set of reports.
Step 3: Install GTM in Next.js with @next/third-parties
Install the package (it may already be installed if you're on a recent Next.js scaffold — check package.json first):
npm install @next/third-parties@latest next@latest
What's happening here: @next/third-parties is an official Next.js package, maintained by the Next.js team, that wraps common third-party scripts (GTM, GA4, YouTube embeds, Google Maps) in components tuned for App Router's rendering and script-loading behavior — so you get the right strategy on the underlying next/script call without configuring it yourself.
Add the container to your root layout:
// app/layout.tsx
import { GoogleTagManager } from "@next/third-parties/google";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<GoogleTagManager gtmId="GTM-XXXXXXX" />
<body>{children}</body>
</html>
);
}
What's happening here: <GoogleTagManager /> renders GTM's script tags in the right place and loads them with a strategy that doesn't block your page's initial render. Placing it as a direct child of <html>, alongside <body>, matches Next.js's own documented pattern — the component internally handles inserting the <noscript> iframe fallback GTM needs for users with JavaScript disabled, so you don't add that by hand.
Don't hardcode the container ID directly in the file — pull it from an environment variable instead, so staging and production can use different containers if you ever need that:
# .env.local
NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX
// app/layout.tsx
<GoogleTagManager gtmId={process.env.NEXT_PUBLIC_GTM_ID!} />
⚠️ Common Mistake: Forgetting the
NEXT_PUBLIC_prefix. Without it, the variable only exists on the server and comes through asundefinedin the browser — GTM ID values aren't sensitive, soNEXT_PUBLIC_is the correct and required prefix here.
Deploy this change. GTM data won't show up instantly — give it a few minutes after your first real page load in production.
Step 4: Add GA4 as a Tag Inside GTM
This is the step most tutorials skip or get backwards. Don't install <GoogleAnalytics gaId="G-XXXXXXXXXX" /> directly in your layout — configure GA4 as a tag inside GTM instead, so GTM stays your single source of truth for every tracking script.
Back in your GTM workspace:
- Click Tags in the left sidebar → New
- Click the tag configuration box → choose Google Analytics: GA4 Configuration
- Paste your Measurement ID (
G-XXXXXXXXXX) from Step 2 - Under Triggering, click the trigger box → choose All Pages (a built-in trigger that fires once, on the initial load)
- Name the tag something clear, like
GA4 - Config - Click Save
What's happening here: this tag tells GTM "initialize GA4 with this Measurement ID and send a pageview." It also turns on GA4's Enhanced Measurement, a set of features enabled by default in every new GA4 property — one of which listens for browser history changes (pushState/replaceState) and can send a page_view on its own when the URL changes without a full reload. That's genuinely useful, because it means client-side navigations in Next.js might already be tracked correctly with nothing further to do. Whether that's actually true for your setup is what Step 8 checks.
Publish the Container
Nothing goes live until you publish. In the top right of GTM, click Submit → give the version a name (e.g. "Add GA4 config tag") → Publish.
⚠️ Common Mistake: Editing tags in GTM and expecting them to work immediately. Changes only take effect after you click Submit → Publish — the workspace view is always a draft until then.
Step 5: Verify It's Actually Working
Don't just assume it works — confirm it, in two places.
GTM Preview Mode
In your GTM workspace, click Preview in the top right. Enter your site's URL (http://localhost:3000 works fine for local testing) and click Connect. This opens a debug panel showing every tag that fires as you navigate your site in real time — you should see your GA4 - Config tag firing on Page View events.
GA4 Realtime Report
In GA4, go to Reports → Realtime. Open your site in another tab and click around. You should see yourself show up as an active user within a few seconds.
💡 Tip: If Preview mode shows the tag firing but GA4 Realtime shows nothing, double-check the Measurement ID was pasted correctly into the GTM tag — a single typo'd character is the most common cause of "everything looks connected but no data arrives."
Step 6: Track a Custom Event (Button Click)
Pageviews tell you traffic. Events tell you what people actually do — clicking "Subscribe," submitting a contact form, opening a pricing tab. Here's how to send a real custom event from a Next.js component into GTM, then forward it to GA4.
Send the Event from Your Component
// components/newsletter-button.tsx
"use client";
import { sendGTMEvent } from "@next/third-parties/google";
export function NewsletterButton() {
const handleClick = () => {
sendGTMEvent({ event: "subscribe_newsletter", value: "footer_cta" });
};
return (
<button onClick={handleClick} className="rounded-lg bg-primary px-4 py-2 text-white">
Subscribe
</button>
);
}
What's happening here: sendGTMEvent() pushes an object onto GTM's dataLayer — an internal array GTM constantly watches for new entries. The event: "subscribe_newsletter" key is what GTM matches against a trigger; any other keys (value here) become extra data you can reference later. This must run in a Client Component ("use client") since it fires on a real browser click.
⚠️ Common Mistake: Calling
sendGTMEvent()in a component that never actually renders inside a page where<GoogleTagManager />is present in a parent layout. If GTM's script hasn't loaded on that route, the event silently goes nowhere — since our GTM component sits in the root layout, every route is covered, but double-check if you ever load GTM per-page instead of globally.
Create a Trigger in GTM
- In GTM, click Triggers → New
- Choose trigger type Custom Event
- Event name:
subscribe_newsletter(must match exactly, including case) - Name it (e.g.
Trigger - Subscribe Click) and Save
Create a GA4 Event Tag
- Click Tags → New
- Tag type: Google Analytics: GA4 Event
- Configuration Tag: select your
GA4 - Configtag from Step 4 (this reuses the same Measurement ID instead of re-entering it) - Event Name:
subscribe_newsletter - Under Triggering, select the
Trigger - Subscribe Clicktrigger you just made - Save, then Submit → Publish
Click the button on your live site, check GTM Preview mode for the event firing, then check GA4 under Reports → Realtime → Event count by Event name to confirm it shows up.
Step 7: Set Up Consent Mode (Don't Skip This)
If any visitors are in the EU/EEA/UK, you're generally required to get consent before running non-essential tracking cookies like GA4's. Google's Consent Mode lets GTM/GA4 adjust their behavior based on what a user actually agreed to, instead of firing full tracking regardless of consent — which is both a compliance risk and, without Consent Mode, a hole in your data (GA4 can still model some conversions from consent-denied users with Consent Mode enabled; without it, you get nothing from those visitors).
The mechanics: before GTM's script even loads, you set a default consent state (usually "denied" until the user chooses), then update it once they interact with your cookie banner.
// app/layout.tsx
import Script from "next/script";
import { GoogleTagManager } from "@next/third-parties/google";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<Script id="consent-default" strategy="beforeInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
'ad_storage': 'denied',
'analytics_storage': 'denied',
'wait_for_update': 500
});
`}
</Script>
<GoogleTagManager gtmId={process.env.NEXT_PUBLIC_GTM_ID!} />
<body>{children}</body>
</html>
);
}
What's happening here: strategy="beforeInteractive" from next/script guarantees this consent-default script runs before GTM's own script executes, which is required — Consent Mode only works if the default state is set first. wait_for_update: 500 tells Google's tags to wait up to 500ms for a real consent decision (from your cookie banner) before falling back to the denied default.
When your cookie banner records a user's choice, call the same gtag() function again with 'update' instead of 'default' — it's the same lightweight stub defined in the script above, attached to window, so any client component can call it later:
// After the user clicks "Accept" in your cookie banner
window.gtag?.("consent", "update", {
ad_storage: "granted",
analytics_storage: "granted",
});
💡 Tip: Keep the update call in the same
gtag(...)form as the default call above rather than pushing a raw array todataLayerdirectly. Both work —gtag()here is justfunction gtag(){dataLayer.push(arguments)}— but sticking to one form avoids making readers wonder whether they're two different APIs.
⚠️ Common Mistake: Setting up a cookie banner UI but never actually calling
consent updatewhen the user clicks accept. Without this call, GTM stays permanently in denied mode and you collect essentially no analytics data, even from users who were fine with tracking.
💡 This guide covers the mechanics of wiring Consent Mode into GTM — pair it with a real cookie-consent banner library (or your own) that actually detects EU/UK visitors and blocks the page until a choice is made if your legal requirements call for that.
Step 8: Verify (and Fix, If Needed) Route Change Tracking
This is the step where most GTM + Next.js guides get sloppy, so let's be precise about it.
GTM itself doesn't understand Next.js routing. It's just a container — it has no built-in concept of client-side navigation. But that doesn't automatically mean pageviews break. The GA4 - Config tag from Step 4 turns on GA4's Enhanced Measurement, and Enhanced Measurement's history-based page tracking is a feature of the GA4 library itself, not something GTM adds on top. So in a lot of setups, client-side navigations already get tracked correctly with zero extra work — you have to verify it, not assume either outcome.
First: Check Whether You Even Need This Step
- Open GTM Preview mode and connect it to your site
- Click around between a few different pages using real Next.js
<Link>navigation (not a hard refresh) - In the Preview debug panel, watch for a
gtm.historyChange-v2(orgtm.historyChange) event firing on each navigation, immediately followed by yourGA4 - Configtag - Cross-check in GA4 → Reports → Realtime → Event count by Event name — you should see a fresh
page_viewfor each page you visited, not just the first one
If that's what you see, you're done — Enhanced Measurement is already handling it, and nothing else in this step applies to you.
💡 Tip: "Page changes based on browser history events" is the setting responsible for this, and it's on by default. You can find it under GA4 → Admin → Data Streams → your web stream → Enhanced Measurement → (gear icon) → Page views. Confirm it's checked before assuming it's broken.
If It's Not Firing Reliably
Some SPA routing patterns don't dispatch history events in a way Enhanced Measurement's listener reliably picks up, and Next.js's App Router is one of the setups worth testing carefully rather than assuming either way. If Step 8's check above came back with missing or inconsistent page_view events, here's the fix — following Google's own documented pattern for measuring SPAs with GTM, not a repeat of the Configuration tag.
1. Turn off Enhanced Measurement's history tracking so it stops competing with the manual setup below: GA4 → Admin → Data Streams → your web stream → Enhanced Measurement → (gear icon) → Page views, uncheck Page changes based on browser history events.
2. Create a History Change trigger in GTM:
- Triggers → New → trigger type History Change
- Name it
Trigger - SPA Pageview, Save
3. Create a separate GA4 Event tag — don't add this trigger to the Configuration tag itself:
- Tags → New → tag type Google Analytics: GA4 Event
- Configuration Tag: select your existing
GA4 - Configtag (this reuses its Measurement ID without re-initializing GA4) - Event Name:
page_view - Triggering: the
Trigger - SPA Pageviewtrigger you just made - Name it
GA4 - SPA Page View, Save, then Submit → Publish
Why a separate tag instead of re-firing the Configuration tag: the Configuration tag's job is to initialize GA4 once per page load — it's not designed to be re-triggered repeatedly. Google's own SPA measurement guide is explicit about this: create the History Change trigger, then fire a dedicated GA4 Event tag off it, referencing the Configuration tag rather than reusing it directly as the trigger target.
If GTM's History Change trigger still misses some router.push() navigations in testing (this does happen on certain SPA setups), the more reliable fallback is asking a developer to push the event manually instead:
// components/gtm-pageview-tracker.tsx
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { sendGTMEvent } from "@next/third-parties/google";
export function GtmPageviewTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const url = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
sendGTMEvent({ event: "spa_page_view", page_path: url });
}, [pathname, searchParams]);
return null;
}
Mount it once in the root layout, alongside {children}. Then in GTM, create a Custom Event trigger for spa_page_view and point your GA4 - SPA Page View tag at that trigger instead of History Change — everything else about the tag (Configuration Tag reference, page_view event name) stays the same.
⚠️ Common Mistake: Wiring up both the History Change trigger and the manual
sendGTMEventpush to the same GA4 event tag, or forgetting to disable Enhanced Measurement's history tracking after adding either one. Any of these combinations double-firespage_viewfor the same navigation — pick exactly one source of truth for SPA pageviews.
The takeaway: don't reach for manual tracking by default. Verify first with GTM Preview and GA4 Realtime, and only build the History Change (or manual) setup if the default Enhanced Measurement behavior genuinely isn't covering your navigations.
Full Setup Checklist
- GTM container created, ID copied (
GTM-XXXXXXX) - GA4 property + web data stream created, Measurement ID copied (
G-XXXXXXXXXX) -
@next/third-partiesinstalled -
<GoogleTagManager gtmId={...} />added as a sibling of<body>inside<html>in the root layout - GTM ID pulled from a
NEXT_PUBLIC_environment variable, not hardcoded - GA4 Configuration tag created inside GTM, triggered on All Pages
- Container published (Submit → Publish), not left as a draft
- Verified in GTM Preview mode and GA4 Realtime report
- At least one custom event wired end-to-end (component →
sendGTMEvent→ GTM trigger → GA4 event tag) - Verified in GTM Preview + GA4 Realtime whether SPA route changes are already tracked by Enhanced Measurement — only added a History Change trigger or manual
sendGTMEventpush if that check showed a real gap, never both at once - Consent Mode default state set with
beforeInteractive, and a realconsent updatecall wired to your cookie banner - No leftover
GoogleAnalytics-style automatic pageview hooks running alongside your GTM setup (that's what causes double-counted pageviews)
Frequently Asked Questions
Should I install GA4 directly, or always go through GTM?
Go through GTM. Installing <GoogleAnalytics gaId="..." /> directly is fine for a tiny personal project that will never need another tracking tool, but the moment you want Google Ads conversion tracking, a Meta Pixel, or any other tag, you'll wish you'd started with GTM as the single container instead of managing multiple direct integrations in your codebase.
Why isn't data showing up in GA4 even though GTM Preview shows the tag firing?
Check three things in order: the Measurement ID was pasted correctly (no missing characters), the container was actually published (not left as a draft), and you're looking at Realtime, not the main reports — standard GA4 reports can take 24–48 hours to populate, but Realtime should update within seconds.
Do I need both GTM and Google Analytics accounts, or just one Google account?
Just one Google account — GTM and GA4 are separate products under the same Google login, so you don't need two different sign-ins. You will end up with a GTM container ID and a separate GA4 Measurement ID, which is expected.
Does adding GTM hurt my Core Web Vitals?
It can, if done wrong — GTM and any tags loaded inside it are extra JavaScript. @next/third-parties mitigates this with sensible load strategies out of the box, but every additional tag you add inside GTM (heatmaps, chat widgets, ad pixels) adds real weight. Periodically audit your GTM container and remove tags you're no longer using.
Can I test GTM/GA4 locally before deploying?
Yes — GTM Preview mode works against localhost directly, and you'll see tags firing in the debug panel without needing a live deployment. GA4 Realtime also picks up local traffic the same way it does production traffic, since it's just JavaScript running in your browser.
Does GTM need extra work for SPA pageviews that GA4-direct doesn't?
Not necessarily. GA4's automatic history-change tracking comes from Enhanced Measurement, a feature of the GA4 library itself — it's on by default whether GA4 loads directly or as a tag inside GTM, since GTM just delivers the same GA4 script either way. In practice this means a lot of GTM setups track SPA navigations correctly with no extra configuration. The only time you need the History Change trigger (or a manual sendGTMEvent push) from Step 8 is if you actually tested with GTM Preview + GA4 Realtime and found navigations weren't being picked up — treat it as a fix for a confirmed gap, not a mandatory step.
Do I still need Google Search Console if I have GA4?
Yes — they answer different questions. GSC tells you how Google finds and ranks your pages (impressions, search queries, indexing status). GA4 tells you what happens after someone arrives (behavior, events, conversions). If you haven't set up Search Console yet, see our Search Console guide first.
Wrapping Up
You now have Google Tag Manager installed the right way in Next.js 16 — as a single container in the root layout, with GA4 configured as a tag inside it rather than a second direct integration, a real custom event tracked end-to-end, confirmed SPA pageview tracking (with a fix on hand if Enhanced Measurement didn't already cover it), and Consent Mode wired up so you're not flying blind on EU traffic. From here, every future tracking tool — Google Ads, a heatmap tool, a Meta Pixel — gets added inside the GTM dashboard, not by touching your codebase again.
The natural next step is deciding which user actions actually matter for your site (signups, checkout starts, contact form submissions) and wiring each one up as its own sendGTMEvent() call, the same pattern used for the newsletter button above.
Continue Learning
- Google Search Console Setup for Next.js (2026 Beginner's Guide)
- The Complete Technical SEO Checklist for Next.js Developers (2026 Guide)
- How to Add Metadata in Next.js 16 (2026 Beginner's Guide)
Free SEO Tools
- JSON-LD Generator for Next.js & HTML - generate Article, FAQ, Product, LocalBusiness, SoftwareApplication, Breadcrumb, Person, Organization, and Website JSON-LD — checked live against Google's actual Rich Results requirements.
- Meta Tag Generator for Next.js & HTML — generate SEO meta tags, Open Graph tags, Twitter Cards, and canonical URLs instantly. Handy for wiring up per-post metadata like the
generateMetadata()function above. - Supabase RLS Policy Generator — if your next project pairs this blog setup with a Supabase backend, generate secure, performance-optimized Row Level Security policies in seconds.
- Zod Schema Generator — generate Zod schemas instantly from JSON or TypeScript. Includes smart inference, live validation, React Hook Form, Next.js API Routes, and Server Actions.
More in SEO
View AllGoogle Search Console Setup for Next.js (2026 Beginner's Guide)
Learn how to set up Google Search Console for a Next.js 16 App Router site — verification, sitemap.ts, robots.ts, and indexing best practices.
The Complete Technical SEO Checklist for Next.js Developers (2026 Guide)
A beginner-friendly, step-by-step technical SEO checklist for Next.js apps — crawlability, metadata, structured data, Core Web Vitals, and more.
Open Graph vs Twitter Cards: What's the Difference? (2026 Guide)
A beginner-friendly guide to Open Graph and Twitter Card meta tags — what they do, how they're different, and how to add both correctly in Next.js 16.
How to Add Metadata in Next.js 16 (2026 Beginner's Guide)
Learn how to add SEO metadata in Next.js 16 App Router — static metadata, generateMetadata for dynamic pages, Open Graph, Twitter Cards, and common mistakes to avoid.