Aller au contenu

Note d'ingénierie

GA4 et CNIL sur Next.js : zéro requête avant consentement

Par Alexandre Kaczor7 min de lecture
  • Next.js
  • RGPD
  • Sécurité
Illustration de l'article « GA4 et CNIL sur Next.js : zéro requête avant consentement »

Charger Google Analytics seulement après le clic, le prouver dans l'onglet réseau, et le piège d'un export de module client lu côté serveur.

Mettre Google Analytics sur un site Next.js prend dix minutes. Le mettre de façon conforme aux recommandations de la CNIL demande de répondre à une seule question, et de la prouver : combien de requêtes partent vers Google avant que le visiteur ait cliqué sur « Tout accepter » ? La seule bonne réponse est zéro.

C'est ce que j'ai mis en place sur ce site, qui tourne en Next.js App Router. L'intégration tient en une cinquantaine de lignes. Elle m'a pourtant réservé un piège que je n'aurais pas trouvé en relisant le code, et qui ne se voit qu'en regardant le trafic réseau.

Le problème des intégrations « clé en main »

La plupart des intégrations GA4 posent le script dans le <head> et comptent sur le Consent Mode de Google pour « ne rien stocker » tant que le visiteur n'a pas choisi. Techniquement, le script se charge quand même : une requête vers googletagmanager.com part à l'ouverture de la page, avec l'adresse IP et l'en-tête du navigateur. Pour un site français, je ne veux pas avoir à argumenter que cette requête est acceptable. Je préfère qu'elle n'existe pas.

La règle retenue est donc simple : tant que le consentement n'est pas explicite, le script GA n'est pas dans le DOM. Pas désactivé, pas mis en mode restreint : absent.

Le consentement vit hors de React

Le choix du visiteur est stocké dans localStorage, dans un petit module sans dépendance :

export type ConsentValue = "accepted" | "rejected";

const KEY = "kaczor-cookie-consent";
export const CONSENT_EVENT = "kaczor-consent-change";

export function getConsent(): ConsentValue | null {
  if (typeof window === "undefined") return null;
  const v = window.localStorage.getItem(KEY);
  return v === "accepted" || v === "rejected" ? v : null;
}

export function setConsent(value: ConsentValue): void {
  window.localStorage.setItem(KEY, value);
  window.dispatchEvent(new CustomEvent(CONSENT_EVENT, { detail: value }));
}

export function hasAnalyticsConsent(): boolean {
  return getConsent() === "accepted";
}

Deux choix comptent ici. D'abord, une valeur inconnue ou absente vaut null, jamais « accepté » : le défaut est le refus. Ensuite, setConsent émet un événement personnalisé, ce qui permet à n'importe quel composant de réagir au clic sans partager d'état React.

Côté composants, j'utilise useSyncExternalStore, qui est la manière prévue de lire une source externe à React. L'alternative habituelle — un useEffect qui lit localStorage puis appelle setState — provoque un second rendu à chaque montage et laisse une fenêtre où l'état est faux.

Le chargement conditionnel

Le composant Analytics ne rend rien tant que les deux conditions ne sont pas réunies : un identifiant GA configuré au build, et un consentement explicite.

"use client";

const GA_ID = process.env.NEXT_PUBLIC_GA_ID ?? "";

export default function Analytics() {
  const consented = useSyncExternalStore(subscribe, hasAnalyticsConsent, () => false);
  if (!GA_ID || !consented) return null;

  return (
    <>
      <Script src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`} strategy="afterInteractive" />
      <Script id="ga4-init" strategy="afterInteractive">
        {`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}
gtag('js',new Date());
gtag('consent','default',{analytics_storage:'granted',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'});
gtag('config','${GA_ID}',{anonymize_ip:true});`}
      </Script>
    </>
  );
}

Le troisième argument de useSyncExternalStore est l'instantané côté serveur. Il vaut false : le HTML rendu au build ne contient jamais le script, quel que soit le visiteur. C'est seulement après l'hydratation, dans le navigateur, que le composant lit le vrai choix. Le visiteur qui clique sur « Tout accepter » voit le script apparaître immédiatement, grâce à l'événement émis par setConsent ; celui qui a déjà accepté lors d'une visite précédente le charge dès l'hydratation.

Même logique inversée pour le bandeau : son instantané serveur répond « déjà choisi ». Le bandeau n'est donc jamais dans le HTML mis en cache et servi à tout le monde, ce qui évite de le faire clignoter chez les visiteurs qui ont déjà répondu.

La consigne gtag('consent', ...) précise enfin que seul le stockage de mesure est accordé : tout ce qui touche à la publicité reste refusé, ce qui correspond à ce que le bandeau annonce.

Le piège : une référence client toujours « vraie »

L'identifiant GA est facultatif. Sans lui, je ne voulais ni script, ni bandeau : afficher une demande de consentement pour un traceur qui n'existe pas serait absurde. Ma première version exportait donc, depuis le module Analytics.tsx, une constante indiquant si GA était configuré, et le layout s'en servait pour décider d'afficher le bandeau.

Le build passait, le typage aussi. Et le bandeau s'affichait même sans identifiant GA.

L'explication tient à la façon dont Next.js traite la directive "use client". Le layout est un composant serveur. Quand il importe un module client, il ne reçoit pas les vraies valeurs exportées : il reçoit des références client, des objets qui disent au moteur de rendu « ce composant sera exécuté dans le navigateur ». C'est parfait pour un composant. Pour une constante booléenne, c'est un désastre silencieux : un objet est toujours vrai en JavaScript. La condition {GA_ENABLED && <CookieConsent />} était donc toujours remplie.

Le correctif consiste à lire la variable directement dans le layout, sans passer par le module client :

{/* Bandeau et mesure d'audience n'existent que si GA4 est configuré.
    Lu ici et non importé d'Analytics : un export d'un module client
    devient, côté serveur, une référence toujours « vraie ». */}
{process.env.NEXT_PUBLIC_GA_ID && <CookieConsent />}
<Analytics />

La règle que j'en tire : un module "use client" n'exporte que des composants vers le code serveur. Une constante, une fonction utilitaire ou un objet de configuration partagé entre serveur et client va dans un module neutre, sans directive.

La CSP, dernière ligne de défense

Le site envoie une Content-Security-Policy stricte. Il a fallu l'ouvrir aux seuls domaines dont GA4 a besoin, et rien d'autre :

"script-src 'self' 'unsafe-inline' https://www.googletagmanager.com",
`connect-src 'self' ${API_URL} https://*.google-analytics.com https://*.analytics.google.com https://www.googletagmanager.com`,

La CSP ne remplace pas le chargement conditionnel : elle autorise Google, elle ne dit pas quand. Mais elle garantit qu'aucun autre domaine ne sera contacté si un script tiers s'ajoute un jour par erreur.

Prouver le zéro requête

Relire le code ne suffit pas, le piège précédent le montre. La vérification se fait dans un navigateur headless, en enregistrant toutes les requêtes réseau :

  • ouvrir la page d'accueil avec un profil vierge, attendre le chargement complet, compter les requêtes vers un domaine Google : zéro ;
  • naviguer vers deux autres pages sans répondre au bandeau : toujours zéro ;
  • cliquer sur « Refuser », recharger : zéro, et le bandeau ne revient pas ;
  • sur un profil neuf, cliquer sur « Tout accepter » : le script gtag/js se charge, puis les premières requêtes de collecte partent.

C'est ce test qui fait foi, pas la relecture. Il se refait après chaque changement touchant le layout, les scripts ou la CSP.

Mesurer ce qui compte : generate_lead

Une mesure d'audience ne sert pas à grand-chose si elle ne suit pas l'action qui importe. Sur ce site, c'est l'envoi du formulaire de contact. Le module exporte une petite fonction :

export function trackEvent(name: string, params: Record<string, string | number> = {}) {
  if (typeof window === "undefined") return;
  (window as GtagWindow).gtag?.("event", name, params);
}

Le formulaire l'appelle uniquement après une réponse positive de l'API, avec le type de projet, le budget et le délai, mais jamais le nom ou l'adresse du visiteur. Sans consentement, gtag n'existe pas et l'appel ne fait rien : aucune condition à dupliquer dans le formulaire.

Dernier point souvent oublié : le visiteur doit pouvoir retirer son consentement aussi facilement qu'il l'a donné. La page de confidentialité propose un bouton qui efface le choix et recharge la page, parce qu'un script Google déjà chargé ne se décharge pas proprement.

Ce que j'en retiens

  • Le consentement se prouve dans l'onglet réseau. Un bandeau affiché ne dit rien des requêtes déjà parties.
  • Le défaut est le refus, jusque dans le rendu serveur. L'instantané serveur de useSyncExternalStore est l'endroit exact où ce défaut se décide.
  • Un module "use client" n'exporte que des composants vers le serveur. Le reste devient une référence opaque, toujours vraie, sans erreur de build ni de typage.
  • On mesure une action métier, pas des pages vues. Un seul événement bien placé, sans donnée personnelle, en dit plus qu'un tableau de bord complet.

Un projet similaire, ou un site à mettre en conformité sans perdre la mesure d'audience ? Parlons-en.