Google Tag Manager w Next.js — dataLayer, custom triggers i debugowanie jak pro

Opublikowano
25 września 2025
Aktualizacja
25 maja 2026
Czas czytania
8 min czytania

Dlatego dobre wdrożenie w Next.js nie polega na wklejeniu snippetu i nadziei, że „Google sobie poradzi”. Polega na zbudowaniu jasnego kontraktu między aplikacją a kontenerem: developer wysyła spójne eventy do , a marketer buduje na nich tagi, triggery i konwersje bez grzebania w kodzie.

Kiedy GTM ma sens, a kiedy tylko komplikuje projekt

Jeżeli strona używa wyłącznie GA4 i ma trzy podstawowe eventy, GTM nie zawsze jest najlepszym wyborem. Bezpośredni Google tag albo @next/third-parties/google może być prostszy: mniej warstw, mniej miejsc do pomyłki, łatwiejszy code review.

GTM zaczyna wygrywać, gdy dochodzą kolejne potrzeby:

  • Google Ads conversion tracking i remarketing,
  • Meta Pixel, LinkedIn Insight Tag, TikTok Pixel lub inne platformy reklamowe,
  • testy A/B, narzędzia heatmapowe i skrypty marketing automation,
  • wiele eventów konwersji zarządzanych przez marketing,
  • potrzeba szybkiej zmiany tagów bez deploya aplikacji.

Wtedy GTM jest warstwą operacyjną dla marketingu, a dataLayer staje się między produktem a analityką. To API musi być stabilne, nazwane i udokumentowane. Bez tego marketer będzie budował triggery na klasach CSS, tekście przycisku albo strukturze DOM, która zmieni się przy następnym refactorze.

Instalacja GTM w Next.js App Router

Next.js udostępnia komponent GoogleTagManager w @next/third-parties/google. Oficjalnie można go dodać w root layout i przekazać gtmId. To dobry punkt startowy, szczególnie gdy chcesz użyć wspieranego przez Next.js sposobu ładowania third-party scripts.

app/layout.tsx
Code
import { GoogleTagManager } from '@next/third-parties/google'
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="pl">
      <body>{children}</body>
      <GoogleTagManager gtmId="GTM-XXXXXXX" />
    </html>
  )
}

W bardziej kontrolowanych wdrożeniach nadal często wybieram własny komponent oparty o next/script. Powód jest praktyczny: łatwiej wymusić kolejność consent defaults, warunki ładowania, środowiska i fallback noscript.

components/GTM.tsx
Code
'use client'
 
import Script from 'next/script'
 
const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID
 
export function GTMHead() {
  if (!GTM_ID || process.env.NODE_ENV !== 'production') return null
 
  return (
    <Script id="gtm-script" strategy="afterInteractive">
      {`
        (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
        new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
        j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
        'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
        })(window,document,'script','dataLayer','${GTM_ID}');
      `}
    </Script>
  )
}
 
export function GTMNoScript() {
  if (!GTM_ID || process.env.NODE_ENV !== 'production') return null
 
  return (
    <noscript>
      <iframe
        src={`https://www.googletagmanager.com/ns.html?id=${GTM_ID}`}
        height="0"
        width="0"
        style={{ display: 'none', visibility: 'hidden' }}
      />
    </noscript>
  )
}
app/layout.tsx
Code
import { GTMHead, GTMNoScript } from '@/components/GTM'
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="pl">
      <body>
        <GTMNoScript />
        {children}
        <GTMHead />
      </body>
    </html>
  )
}

Nie ładuj samego kontenera GTM przez beforeInteractive, jeśli nie masz bardzo konkretnego powodu. Consent defaults to inna sprawa: one faktycznie powinny pojawić się przed tagami, które zależą od zgody. Sam kontener zwykle wystarczy uruchomić po interaktywności strony.

DataLayer jako kontrakt, nie worek na losowe dane

dataLayer to tablica obiektów JavaScript. Google Tag Manager czyta kolejne wpisy, przetwarza je po kolei i odpala tagi, których triggery pasują do danego eventu. Najważniejszy detal: jeśli pushujesz dane bez event, GTM może mieć problem z przewidywalnym odpaleniem tagów we właściwym momencie. Dlatego event powinien być jawny.

Zacznij od małego wrappera. Nie pushuj obiektów bezpośrednio z każdego komponentu, bo po kilku miesiącach nikt nie będzie wiedział, kto wysyła form_submit, kto formSubmit, a kto lead_sent.

lib/dataLayer.ts
Code
type DataLayerEvent = Record<string, unknown> & {
  event: string
}
 
declare global {
  interface Window {
    dataLayer?: Array<Record<string, unknown>>
    gtag?: (...args: unknown[]) => void
  }
}
 
export function pushToDataLayer(data: DataLayerEvent) {
  if (typeof window === 'undefined') return
 
  window.dataLayer = window.dataLayer || []
  window.dataLayer.push(data)
}
 
export function pushEcommerceEvent(
  event: string,
  ecommerce: Record<string, unknown>,
) {
  if (typeof window === 'undefined') return
 
  window.dataLayer = window.dataLayer || []
  window.dataLayer.push({ ecommerce: null })
  window.dataLayer.push({ event, ecommerce })
}

W dużym projekcie ten plik szybko warto rozbić na moduły: analytics/pageView, analytics/ecommerce, analytics/forms, analytics/consent. Ważne, żeby komponent nie znał szczegółów struktury ecommerce ani nazw parametrów używanych przez GA4.

Page view w App Router

Najczęstszy błąd w Next.js polega na tym, że kontener GTM ładuje się raz, a zespół zakłada, że page views będą zliczane jak w klasycznej stronie. Nie będą. App Router zmienia widok bez pełnego przeładowania dokumentu, więc potrzebujesz własnego eventu na zmianę pathname i searchParams.

components/GTMPageView.tsx
Code
'use client'
 
import { Suspense, useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
import { pushToDataLayer } from '@/lib/dataLayer'
 
function GTMPageViewInner() {
  const pathname = usePathname()
  const searchParams = useSearchParams()
 
  useEffect(() => {
    const query = searchParams?.toString()
    const pagePath = query ? `${pathname}?${query}` : pathname
 
    pushToDataLayer({
      event: 'page_view',
      page_path: pagePath,
      page_location: window.location.href,
      page_title: document.title,
    })
  }, [pathname, searchParams])
 
  return null
}
 
export function GTMPageView() {
  return (
    <Suspense fallback={null}>
      <GTMPageViewInner />
    </Suspense>
  )
}

W GTM tworzysz potem Custom Event Trigger dla page_view i podpinasz pod niego tag eventowy . Jeśli używasz Google tag jako konfiguracji bazowej, wyłącz automatyczne wysyłanie page view tam, gdzie grozi duplikat. Jedno źródło prawdy jest ważniejsze niż „więcej danych”.

Ecommerce: mniej kreatywności, więcej standardu GA4

Przy ecommerce nie wymyślaj własnych nazw, jeśli nie musisz. GA4 ma rekomendowane eventy i strukturę items[], więc używaj view_item, add_to_cart, begin_checkout, purchase oraz standardowych parametrów. Dzięki temu raporty e-commerce, import konwersji do Google Ads i diagnostyka będą dużo prostsze.

lib/dataLayer-ecommerce.ts
Code
import { pushEcommerceEvent } from './dataLayer'
 
type Product = {
  id: string
  name: string
  brand?: string
  category?: string
  price: number
}
 
type CartItem = {
  product: Product
  quantity: number
}
 
type Order = {
  id: string
  totalValue: number
  tax?: number
  shippingCost?: number
  couponCode?: string
  items: CartItem[]
}
 
function toItem(product: Product, index?: number) {
  return {
    item_id: product.id,
    item_name: product.name,
    item_brand: product.brand,
    item_category: product.category,
    price: product.price / 100,
    quantity: 1,
    index,
  }
}
 
export const dlEcommerce = {
  viewItemList: (listName: string, products: Product[]) =>
    pushEcommerceEvent('view_item_list', {
      item_list_id: listName,
      item_list_name: listName,
      items: products.map((product, index) => toItem(product, index)),
    }),
 
  viewItem: (product: Product) =>
    pushEcommerceEvent('view_item', {
      currency: 'PLN',
      value: product.price / 100,
      items: [toItem(product)],
    }),
 
  addToCart: (product: Product, quantity = 1) =>
    pushEcommerceEvent('add_to_cart', {
      currency: 'PLN',
      value: (product.price * quantity) / 100,
      items: [{ ...toItem(product), quantity }],
    }),
 
  beginCheckout: (items: CartItem[], total: number) =>
    pushEcommerceEvent('begin_checkout', {
      currency: 'PLN',
      value: total / 100,
      items: items.map((item) => ({
        ...toItem(item.product),
        quantity: item.quantity,
      })),
    }),
 
  purchase: (order: Order) =>
    pushEcommerceEvent('purchase', {
      transaction_id: order.id,
      currency: 'PLN',
      value: order.totalValue / 100,
      tax: (order.tax ?? 0) / 100,
      shipping: (order.shippingCost ?? 0) / 100,
      coupon: order.couponCode,
      items: order.items.map((item) => ({
        ...toItem(item.product),
        quantity: item.quantity,
      })),
    }),
}

Najważniejsze są dwie zasady. Po pierwsze: wartości pieniężne powinny być liczbami w walucie raportowania, nie stringami typu "149,99 zł". Po drugie: purchase musi mieć stabilny transaction_id, bo bez niego trudno diagnozować duplikaty i import konwersji do Google Ads.

Custom events dla marketingu

Nie każdy event jest ecommerce. W stronach usługowych, SaaS-ach i aplikacjach B2B ważniejsze będą formularze, kliknięcia CTA, przejścia między krokami onboardingowymi, pobrania plików i interakcje z kalkulatorem.

lib/dataLayer-custom.ts
Code
import { pushToDataLayer } from './dataLayer'
 
export const dlCustom = {
  ctaClick: (ctaName: string, location: string) =>
    pushToDataLayer({
      event: 'cta_click',
      cta_name: ctaName,
      cta_location: location,
    }),
 
  formStart: (formName: string) =>
    pushToDataLayer({
      event: 'form_start',
      form_name: formName,
    }),
 
  formSubmit: (formName: string, success: boolean) =>
    pushToDataLayer({
      event: 'form_submit',
      form_name: formName,
      form_success: success,
    }),
 
  fileDownload: (fileName: string, fileUrl: string) =>
    pushToDataLayer({
      event: 'file_download',
      file_name: fileName,
      file_url: fileUrl,
    }),
 
  signUp: (method: string) =>
    pushToDataLayer({
      event: 'sign_up',
      signup_method: method,
    }),
}

Nazwy eventów powinny być stabilne i nudne. cta_click jest lepsze niż heroBigGreenButtonClicked, bo może obsłużyć wiele miejsc w produkcie, a szczegóły przenosisz do parametrów. To samo dotyczy formularzy: jeden form_submit z form_name i form_success daje lepszy model danych niż piętnaście osobnych eventów.

Dokumentacja dataLayer dla marketera

Dobry dataLayer bez dokumentacji nadal jest słaby. Marketer musi wiedzieć, jakie eventy istnieją, kiedy się odpalają i jakie parametry może mapować w GTM. Najprostszy format to tabela w Notion, Google Docs albo markdown w repo.

docs/data-layer-contract.md
Code
## page_view
 
Kiedy: każda zmiana route w App Router oraz hard reload.
Parametry: page_path, page_title, page_location.
Typowy trigger w GTM: Custom Event = page_view.
 
## cta_click
 
Kiedy: kliknięcie przycisku CTA mierzonego marketingowo.
Parametry: cta_name, cta_location.
Typowy trigger w GTM: Custom Event = cta_click.
 
## form_submit
 
Kiedy: próba wysłania formularza.
Parametry: form_name, form_success.
Typowy trigger w GTM: Custom Event = form_submit + warunek form_success = true.
 
## purchase
 
Kiedy: potwierdzone zamówienie po stronie aplikacji.
Parametry: ecommerce.transaction_id, ecommerce.value, ecommerce.currency, ecommerce.items[].
Typowy trigger w GTM: Custom Event = purchase.

Taki dokument zmienia rozmowę. Zamiast „czy możesz dodać event na ten przycisk?” pojawia się pytanie „czy ten przycisk powinien użyć istniejącego cta_click, czy potrzebujemy nowego typu zdarzenia?”. To jest różnica między analityką utrzymywalną a przypadkowym zbiorem tagów.

Konfiguracja tagów w GTM

Po stronie GTM marketer zwykle tworzy kilka warstw.

Google tag / GA4 config inicjalizuje pomiar. Jeśli page view wysyłasz ręcznie, wyłącz automatyczny page view tam, gdzie powodowałby duplikaty. Ten tag nie powinien być odpowiedzialny za mierzenie każdej nawigacji w App Routerze.

GA4 Event Tag: page_view powinien odpalać się na Custom Event page_view i mapować parametry page_path, page_title, page_location.

GA4 Event Tag: form_submit może odpalać się na Custom Event form_submit, ale często warto dodać warunek form_success equals true, żeby nie raportować walidacyjnych porażek jako leadów.

Google Ads Conversion Tag zwykle podpinasz pod event biznesowy, np. purchase albo generate_lead. Wartość konwersji mapujesz z Data Layer Variable, a transaction_id przekazujesz tam, gdzie narzędzie to obsługuje.

Remarketing i piksele zewnętrzne powinny mieć jasno opisane warunki odpalenia i consent requirements. To nie są „niewinne skrypty”; wpływają na prywatność, performance i jakość danych.

Consent to nie jest popup z przyciskiem „Akceptuję”. Dla tagów ważne jest to, jaki stan zgody obowiązuje w momencie odpalenia eventu. Dlatego defaulty powinny być ustawione zanim tagi zależne od consent zaczną działać.

Jeśli obsługujesz banner w aplikacji, możesz ustawić domyślne zgody przed kontenerem GTM:

components/ConsentDefaults.tsx
Code
import Script from 'next/script'
 
export function ConsentDefaults() {
  return (
    <Script id="consent-defaults" strategy="beforeInteractive">
      {`
        window.dataLayer = window.dataLayer || [];
        function gtag(){dataLayer.push(arguments);}
        gtag('consent', 'default', {
          analytics_storage: 'denied',
          ad_storage: 'denied',
          ad_user_data: 'denied',
          ad_personalization: 'denied',
          wait_for_update: 500
        });
      `}
    </Script>
  )
}

Po decyzji użytkownika wysyłasz update:

lib/consent.ts
Code
import { pushToDataLayer } from './dataLayer'
 
export function grantMarketingConsent() {
  window.gtag?.('consent', 'update', {
    analytics_storage: 'granted',
    ad_storage: 'granted',
    ad_user_data: 'granted',
    ad_personalization: 'granted',
  })
 
  pushToDataLayer({
    event: 'consent_update',
    consent_analytics: 'granted',
    consent_ads: 'granted',
  })
}

Jeśli consent jest zarządzany przez CMP w GTM, nie doklejaj własnych Custom HTML tagów z przypadkowym gtag('consent'). Oficjalne zalecenie Google jest proste: do consent w Tag Managerze używaj Consent APIs i odpowiednich szablonów, bo stan zgody musi być przetworzony przed eventami, które zależą od tej zgody.

Debugowanie GTM w Next.js

Najlepsze debugowanie zaczyna się od GTM Preview. Uruchamiasz Preview, wpisujesz URL, przechodzisz przez scenariusz i sprawdzasz:

  • czy event trafił do dataLayer,
  • czy trigger pasuje do eventu,
  • czy tag się odpalił,
  • jakie wartości miały Data Layer Variables,
  • czy consent pozwalał na odpalenie taga.

Drugim narzędziem jest Tag Assistant. Szczególnie przy consent mode pokazuje, czy zgody są ustawiane w poprawnej kolejności i czy tagi nie odpalają się zbyt wcześnie.

Do szybkiej diagnostyki w konsoli przydają się proste komendy:

Code
// Wszystkie dotychczasowe wpisy
console.table(window.dataLayer)
 
// Tylko konkretne eventy
window.dataLayer.filter((entry) => entry.event === 'purchase')
 
// Tymczasowy podgląd nowych pushy
const originalPush = window.dataLayer.push.bind(window.dataLayer)
 
window.dataLayer.push = function (...args) {
  console.log('dataLayer push:', args)
  return originalPush(...args)
}

W development uważaj na React Strict Mode. Jeśli widzisz podwójne eventy lokalnie, najpierw sprawdź produkcyjny build albo preview deployment. Dopiero jeśli duplikaty występują poza dev mode, szukaj błędu w efektach Reacta, automatycznym page view w GA4 albo podwójnie opublikowanych tagach w GTM.

Najczęstsze błędy

Duplikaty page_view zwykle biorą się z jednoczesnego automatycznego page view w Google tagu i ręcznego eventu w App Routerze. Wybierz jeden model i trzymaj się go konsekwentnie.

Brak ecommerce: null powoduje mieszanie danych ecommerce między eventami. To klasyczny problem przy view_item, add_to_cart i purchase.

Triggery oparte na CSS selectorach są kruche. Jeśli marketer podpina konwersję pod .btn-primary:nth-child(2), to refactor UI może zepsuć pomiar bez żadnego błędu w buildzie.

Custom HTML bez review to ryzyko wydajności, bezpieczeństwa i błędów w . Wbudowane szablony tagów są bezpieczniejsze niż dowolny skrypt wklejony do kontenera.

Brak procesu publikacji kończy się tym, że nikt nie wie, która wersja GTM zmieniła dane. Korzystaj z workspace, opisuj zmiany i testuj Preview przed publikacją.

Współpraca developer - marketer

Najzdrowszy model jest prosty:

  1. Developer tworzy i utrzymuje dataLayer contract.
  2. Marketer zgłasza potrzeby pomiarowe językiem biznesowym: lead, zakup, kliknięcie CTA, pobranie pliku.
  3. Developer mapuje te potrzeby na eventy i parametry.
  4. Marketer konfiguruje tagi w GTM.
  5. Obie strony sprawdzają scenariusz w Preview mode przed publikacją.

Ten proces może wyglądać wolniej niż „wrzućmy tag od razu”, ale w praktyce oszczędza czas. Mniej duplikatów, mniej niejasnych konwersji, mniej sytuacji, w których kampania optymalizuje się na błędny event.

Kampanie, landing page, tracking konwersji, GA4 i GTM w jednym procesie.
Google Ads i Analityka

Często zadawane pytania

Czy GTM ma sens, jeśli używam tylko GA4?

Niekoniecznie. Jeśli potrzebujesz tylko jednego taga Google i kilku prostych eventów, bezpośrednie wdrożenie GA4 bywa prostsze, czytelniejsze i łatwiejsze do utrzymania. GTM zaczyna mieć sens wtedy, gdy dochodzą Google Ads, remarketing, piksele platform reklamowych, testy A/B albo zespół marketingowy, który musi zmieniać tagi bez każdego deploya.

W praktyce tak, jeśli chcesz mieć pełną kontrolę nad pomiarem nawigacji w aplikacji SPA. App Router nie przeładowuje całego dokumentu przy przejściu między trasami, dlatego warto wysyłać jawny event page_view po zmianie pathname lub searchParams i dopiero na nim budować trigger w GTM.

To zależy od ownershipu. Jeśli banner cookies i preferencje są częścią aplikacji, ustaw default consent przed załadowaniem kontenera GTM i wysyłaj update po decyzji użytkownika. Jeśli zgody są zarządzane przez CMP lub szablon w GTM, trzymaj consent po stronie Tag Managera i korzystaj z Consent APIs. Najgorszy wariant to mieszanie obu podejść bez jasnego kontraktu.

W GTM wartości w dataLayer mogą zostawać dostępne dla kolejnych eventów. Jeśli przed nowym eventem ecommerce nie wyczyścisz poprzedniego obiektu, dane z view_item mogą pomieszać się z add_to_cart albo purchase. Dlatego bezpieczny wzorzec to osobny push { ecommerce: null }, a dopiero potem event z aktualnym obiektem ecommerce.

Najpierw sprawdź, czy problem występuje w produkcyjnym buildzie. W development React Strict Mode może uruchamiać efekty dwukrotnie, przez co eventy wyglądają jak zdublowane. Do realnego debugowania używaj GTM Preview, Tag Assistant i produkcyjnego lub preview deploya, a nie tylko lokalnego dev servera.

Przy prostych zmianach opisowych czasem wystarczy proces marketingowy, ale tagi mierzące konwersje, ecommerce, consent, remarketing i Custom HTML powinny przejść review. Najlepszy proces to workspace w GTM, test w Preview mode, weryfikacja dataLayer i dopiero publikacja.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty side-projecty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów