Tracking SPA et headless avec Next.js, Nuxt, React et GA4

Tracker une SPA ou un site headless avec GA4 et GTM : history change, page_view virtuels, Next.js, Nuxt, reset du dataLayer et consentement.

Par Ron Kopelman, consultant web analytics freelance — mis à jour le 2 novembre 2026

Dans une application monopage (SPA) ou un front headless, la navigation ne recharge pas la page : GA4 et GTM ne voient qu’un seul chargement, sauf si vous envoyez les vues manuellement. Deux approches fonctionnent. Soit GTM écoute les changements d’historique avec un déclencheur « History Change », soit l’application pousse elle-même un événement page_view à chaque changement de route. Le bon choix dépend de la qualité du titre et de l’URL que vous voulez remonter, et du nombre de vues qui n’ont pas d’URL propre.

Pourquoi le tracking classique casse en SPA

Un setup GA4 standard suppose une page qui se charge entièrement. Dans une SPA, quatre choses cassent.

Outils couverts Next.js Nuxt Google Analytics 4 Google Tag Manager GTM Server-Side Consent Mode v2
  • Pas de rechargement : le conteneur GTM se charge une fois, et le tag de configuration GA4 ne repart pas tout seul sur les routes suivantes.
  • URL et titre décalés : le titre et le contenu changent après le rendu du composant. Un tag déclenché trop tôt remonte l’ancienne URL ou l’ancien titre.
  • dataLayer persistant : sur une page classique, Google indique que les variables ne vivent que pour la page en cours. En SPA, rien ne se réinitialise. Une variable product_id poussée sur une fiche produit reste disponible sur toutes les pages suivantes, et contamine les événements qui ne la concernent pas.
  • Double page_view : l’« enhanced measurement » de GA4 écoute les changements d’historique et envoie son propre page_view. Si GTM envoie aussi le sien sur le même événement, chaque navigation est comptée deux fois.

Le troisième point est le plus sous-estimé. Il se traite avec un plan de marquage clair (voir la FAQ sur le data layer et GTM) et une règle simple : chaque page doit pousser explicitement les valeurs qui la décrivent. Si votre application part de zéro sur ce sujet, un setup GA4 par un consultant pose ces règles avant le premier déploiement, ce qui coûte moins cher que la correction de doublons six mois plus tard.

Les deux stratégies

CritèreHistory Change (GTM)page_view poussé par le routeur
Où vit la logiqueDans GTMDans le code applicatif
DéclencheurChangement d’historique du navigateurÉvénement émis par le routeur
Contrôle des paramètresLimité aux variables GTM (Page URL, Page Title)Total : vous choisissez chaque champ
Risque principalTitre pas encore à jour au moment du tagOubli sur une route, ou double si GA4 écoute aussi
Bon candidat pourSite simple, routes avec URL propreApp avec logique de routage, données chargées après coup

Ma règle par défaut : une seule source. Je désactive le suivi de l’historique dans l’enhanced measurement de GA4, puis je choisis la stratégie GTM si toutes les vues ont une URL propre et un titre fiable, ou le routeur sinon. Google documente la configuration GTM complète dans son guide SPA pour GTM : désactivation de l’historique dans GA4, déclencheur History Change, balise Google de mise à jour (update: true) exécutée avant la balise d’événement page_view.

Implémentation par framework

Next.js App Router

Le composant GoogleTagManager de @next/third-parties/google s’installe dans le layout racine. Par défaut, il charge le script après l’hydratation : si votre consentement doit être posé avant GTM, vérifiez ce point dans votre layout. Pour les vues virtuelles, je crée un petit composant client qui réagit au chemin et aux paramètres de recherche :

'use client'
import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect, useRef } from 'react'

export function VirtualPageView() {
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const first = useRef(true)

  useEffect(() => {
    // Le chargement initial est déjà compté par le conteneur GTM
    if (first.current) { first.current = false; return }
    window.dataLayer = window.dataLayer || []
    window.dataLayer.push({
      event: 'page_view',
      page_location: window.location.href,
      page_path: pathname,
    })
  }, [pathname, searchParams])

  return null
}

Deux pièges. useSearchParams impose d’envelopper le composant dans une frontière Suspense si la page est rendue statiquement. Et document.title peut être en retard dans l’effet : si le titre compte pour vous, poussez-le après un second cycle de rendu ou remontez-le depuis les métadonnées de la route.

Next.js Pages Router

Ici, l’événement de routage existe déjà : Router.events émet routeChangeComplete après chaque navigation client. Un listener dans _app suffit à pousser le même objet que ci-dessus. C’est plus simple que l’App Router, car il n’y a pas de composant à rendre.

Nuxt 3

Dans Nuxt, le hook router.afterEach joue le rôle de routeChangeComplete. Un plugin client suffit :

// plugins/virtual-pageview.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.$router.afterEach((to, from) => {
    if (!from.name) return // première navigation : déjà comptée par GTM
    window.dataLayer = window.dataLayer || []
    window.dataLayer.push({
      event: 'page_view',
      page_location: window.location.href,
      page_path: to.fullPath,
    })
  })
})

Le module @zadigetvoltaire/nuxt-gtm propose une option enableRouterSync qui fait ce travail côté module. Je la teste avant de l’activer : si elle couvre vos routes, inutile d’écrire le plugin ; si vous avez besoin d’un contrôle fin des paramètres, le plugin reste plus lisible.

React Router et Vue Router

Dans React Router, un useEffect qui dépend de useLocation() joue le même rôle que le composant Next.js. Dans Vue Router, le router.afterEach se place dans le point d’entrée de l’application, comme le plugin Nuxt, mais sans la couche Nuxt. Dans les deux cas, la règle ne change pas : un seul point d’émission, appelé à chaque navigation, et pas de page_view dans les composants de page eux-mêmes, sinon les doubles apparaissent dès qu’un composant se remonte.

Le dataLayer côté application

Le problème du persistant se règle au moment où l’on pousse. Je recommande deux réflexes.

  1. Vider le bloc e-commerce avant chaque événement : dataLayer.push({ ecommerce: null }) avant un view_item, un add_to_cart ou un purchase. C’est une pratique courante dans les implémentations GTM. La documentation GA4 e-commerce ne la décrit pas explicitement, donc je la valide dans le mode Preview avant de la généraliser.
  2. Pousser les variables de page à chaque route, même vides : page_type, content_group, logged_in. Une valeur absente doit être envoyée explicitement comme vide, sinon l’ancienne reste active.

Sur le headless e-commerce, le principe est identique. Shopify Hydrogen, une couche Commerce headless ou un backend Medusa exposent les données de produit et de panier dans leurs propres API. Le dataLayer se construit donc dans vos composants, à partir de ces données, et non dans le CMS. Vérifiez dans la documentation actuelle du framework s’il propose déjà des événements analytics ; c’est souvent le cas, et il vaut mieux les utiliser plutôt que de les réécrire.

SSR et hydratation

Le rendu serveur pose une contrainte : la balise de consentement par défaut doit être présente dans le HTML servi, avant gtm.js. Je la place dans le <head> du layout, pas dans un useEffect, car un effet ne s’exécute qu’après hydratation et arrive après le premier chargement de GTM. Le consentement mis à jour par la CMP se pousse ensuite côté client, et il n’a pas besoin d’être renvoyé à chaque route.

Autre point : le dataLayer est une file. Un push effectué avant que GTM soit chargé est traité au chargement du conteneur. Il faut donc initialiser window.dataLayer = window.dataLayer || [] avant le snippet GTM, comme le demande la documentation dataLayer de Google, et ne jamais réassigner le tableau après coup.

La logique de consentement ne change pas avec la navigation client. Trois conditions :

  • le mode par défaut (denied pour les finalités concernées) est posé avant GTM, côté HTML ;
  • la CMP envoie la mise à jour au choix de l’utilisateur, par un événement de type cookie_consent_update ;
  • les tags non Google (Meta, LinkedIn, TikTok) se déclenchent sur ce consentement, pas sur chaque route, sinon ils tirent avant l’accord.

Le détail des configurations non conformes est traité dans le guide pratique Consent Mode v2.

Server-side en SPA

Le server-side ne supprime pas le besoin de pousser les vues côté client. Le serveur ne voit une navigation que si le navigateur envoie une requête vers lui : un changement de route sans requête réseau n’existe pas pour le conteneur serveur. En revanche, le server-side reçoit proprement chaque requête de collecte, filtre les données et gère la déduplication. Pour une SPA avec des événements sensibles (achat, génération de lead), l’approche la plus stable reste le client qui pousse les vues et le serveur qui les transmet. Voir le panorama server-side 2026.

Recette : comment valider

Je teste toujours le même parcours, dans l’ordre :

  1. Ouvrir le mode Preview de GTM, puis naviguer par les liens du menu, jamais par rechargement.
  2. Vérifier dans GA4 DebugView qu’un seul page_view apparaît par navigation, avec un page_location correct.
  3. Utiliser le bouton « retour » du navigateur : il doit produire une vue, pas deux.
  4. Contrôler le dataLayer dans la console (window.dataLayer) après une fiche produit puis une page catégorie : aucune valeur produit ne doit persister.
  5. Tester le consentement : refus, puis acceptation, puis navigation.

Tag Assistant complète utilement cette recette sur les balises et leur ordre de déclenchement.

FAQ

Faut-il désactiver l’enhanced measurement sur une SPA ?

Oui, dès que vous envoyez vos propres page_view, sinon chaque navigation est comptée deux fois. Gardez-le actif uniquement si vous ne poussez rien d’autre et que vos routes ont des URL propres.

Un déclencheur History Change suffit-il avec Next.js ?

Souvent oui, pour des routes avec une URL propre et un titre à jour. Si le titre est mis à jour après le rendu, ou si vous voulez des paramètres personnalisés, le composant client de la section Next.js reste préférable.

Où brancher le page_view en Nuxt 3 ?

Dans un plugin client, avec router.afterEach. Un plugin serveur ne voit pas les navigations client. Ignorez la première navigation si GTM envoie déjà la vue de chargement.

Sources

Voir aussi : PrestaShop, WordPress, WooCommerce, Shopify, Magento / Adobe Commerce, Webflow, HubSpot CMS, Wix / Squarespace, Drupal, Autres plateformes — ou le tableau comparatif de toutes les plateformes.

Ron Kopelman

Un tracking SPA / headless à poser ou à réparer ?

Audit du setup existant, dataLayer propre, GTM, Consent Mode, server-side. Cadrage gratuit de 30 minutes.