Plateformes SPA et headless
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.
- 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_idpoussé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ère | History Change (GTM) | page_view poussé par le routeur |
|---|---|---|
| Où vit la logique | Dans GTM | Dans le code applicatif |
| Déclencheur | Changement d’historique du navigateur | Événement émis par le routeur |
| Contrôle des paramètres | Limité aux variables GTM (Page URL, Page Title) | Total : vous choisissez chaque champ |
| Risque principal | Titre pas encore à jour au moment du tag | Oubli sur une route, ou double si GA4 écoute aussi |
| Bon candidat pour | Site simple, routes avec URL propre | App 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.
- Vider le bloc e-commerce avant chaque événement :
dataLayer.push({ ecommerce: null })avant unview_item, unadd_to_cartou unpurchase. 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. - 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.
Consent Mode v2 en SPA
La logique de consentement ne change pas avec la navigation client. Trois conditions :
- le mode par défaut (
deniedpour 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 :
- Ouvrir le mode Preview de GTM, puis naviguer par les liens du menu, jamais par rechargement.
- Vérifier dans GA4 DebugView qu’un seul
page_viewapparaît par navigation, avec unpage_locationcorrect. - Utiliser le bouton « retour » du navigateur : il doit produire une vue, pas deux.
- 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. - 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
- Measure single-page applications with Google Tag Manager, Google for Developers
- Third Party Libraries (@next/third-parties), Next.js
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.