Aller au contenu
← Retour au blog
Guides 1 août 2026· 9 min de lecture

Ajouter des analytics à une app SvelteKit sans bandeau cookies

Analytics sans cookie dans SvelteKit : composant sûr au SSR dans le layout, events via useTakt, et pourquoi vous ne voyez rien en développement.

Sommaire

Une application SvelteKit a une forme reconnaissable : un layout racine qui enveloppe tout, un rendu serveur suivi d’une hydratation, un routeur client qui prend la main dès le premier clic. Puis vient le moment de mesurer l’audience, et la première page de résultats propose invariablement le même attelage : une balise publicitaire, un gestionnaire de consentement pour l’encadrer, un bandeau pour demander la permission. Sur une application rendue au serveur, ce bandeau n’est pas une bannière décorative : c’est un composant à état, qu’il faut hydrater, dont le choix doit être connu au moment du rendu (donc lu dans un cookie envoyé avec la requête) et qui conditionne le chargement d’un script tiers. Vous n’ajoutez pas une ligne à votre layout : vous ajoutez une branche à votre rendu.

Cet article a l’avantage rare de pouvoir se vérifier depuis la page que vous êtes en train de lire. taktlytics.com tourne sur SvelteKit, et se mesure avec Takt. Ouvrez les outils de développement, onglet Réseau, filtrez sur event, rechargez : vous verrez la requête de mesure partir. Rien ne vous a demandé la permission, aucune fenêtre modale ne s’est ouverte, et vous êtes compté. Ce n’est pas une démonstration montée pour l’occasion, c’est le site que vous lisez.

Ce guide déroule l’intégration côté SvelteKit — le composant dans le layout racine, les events, le partage des rôles entre les deux styles d’intégration — et s’arrête longuement sur le seul endroit où l’on perd réellement du temps : deux mécanismes parfaitement indépendants produisent exactement le même symptôme, « je ne vois rien », et un seul des deux laisse une trace.

Pourquoi le bandeau n’est pas une fatalité

Ce qui déclenche l’obligation de consentement, ce n’est pas le fait de compter des visites. C’est l’écriture ou la lecture d’informations dans le terminal du visiteur : un cookie, une entrée en stockage local, une empreinte de navigateur reconstituée. Une mesure qui n’écrit rien dans le navigateur et ne cherche à reconnaître personne d’une visite à l’autre sort de ce périmètre. Le second point de friction, le transfert de données personnelles vers des serveurs situés hors de l’Union, disparaît de la même façon dès lors que la collecte et le stockage restent en Europe.

Ce qu’il reste est exactement ce dont on a besoin pour piloter un produit : des agrégats. Nombre de visites, pages consultées, sources de trafic, répartition par pays, région, appareil, navigateur, système. Aucun profil individuel, aucun identifiant persistant, personne à reconnaître d’une session à la suivante. C’est le parti pris de conception de Takt : pas de cookie, pas d’identification, un service géré et hébergé en Europe, et un petit runtime navigateur au lieu d’une pile de balises. Le contraste complet avec l’approche Google Analytics est détaillé sur notre page comparative.

Côté SvelteKit, la conséquence est agréablement concrète. Pas de cookie à lire dans hooks.server.ts, pas de +layout.server.ts dont l’unique raison d’être serait de transmettre un choix de consentement jusqu’au layout, pas de branche « avant/après acceptation » à rendre deux fois et à tester deux fois. Une page dont le rendu dépend d’un cookie n’est d’ailleurs plus prérendable : le bandeau ne coûte pas seulement du JavaScript, il retire des pages entières du champ du prérendu. Et le gain n’est pas que technique : personne ne refuse ni n’ignore une fenêtre qui n’existe pas, donc vos chiffres décrivent l’ensemble de votre trafic et non la fraction qui a cliqué sur « Accepter ».

Poser le composant dans le layout

pnpm add @vskstudio/takt-svelte @vskstudio/takt-core

Deux paquets, parce que @vskstudio/takt-core est une dépendance de pair du wrapper, au même titre que svelte lui-même — d’où la ligne d’installation qui le nomme explicitement plutôt que de s’en remettre à la résolution des pairs de votre gestionnaire de paquets. Le wrapper est écrit avec les runes et demande Svelte 5 ; il n’ajoute aucune logique de collecte, il rend simplement le cœur natif dans une application Svelte.

<!-- +layout.svelte -->
<script>
  import { Takt } from '@vskstudio/takt-svelte'

  let { children } = $props()
</script>

<Takt domain="exemple.fr" outbound files />
{@render children()}

Ce bloc montre la forme du fichier, pas un fichier à écraser par-dessus le vôtre. Votre layout racine existe déjà, avec ses imports, sa navigation, ses styles : les seules lignes à y ajouter sont l’import et la balise <Takt … />. La façon dont vous rendez vos enfants reste la vôtre et n’a pas à changer : {@render children()} est la forme attendue avec Svelte 5, que le wrapper réclame de toute manière, et un layout resté sur <slot /> continue de fonctionner, avec pour seule conséquence un avertissement de dépréciation à la compilation. Le composant ne rend rien et ne se soucie pas de ce qui l’entoure.

Une seule fois, et dans le layout racine : src/routes/+layout.svelte, pas le layout d’un groupe de routes. La raison n’est pas cosmétique. Le composant démonte proprement ce qu’il a câblé : à sa destruction, il détache ses écouteurs et remet l’instance partagée à zéro. Un <Takt> posé dans src/routes/(app)/+layout.svelte cesse donc de mesurer à la seconde où le visiteur quitte le groupe, et vos statistiques ne décrivent plus qu’une moitié du site — sans la moindre erreur pour vous le signaler.

Le composant est sûr au rendu serveur : il n’initialise le runtime qu’au montage, côté navigateur. Rien ne touche window, document ni history pendant le SSR, il n’y a donc ni plantage au rendu, ni écart d’hydratation, et rien à envelopper dans une garde de votre côté.

domain est l’option qu’il faut poser soi-même en production. Son défaut est location.hostname, c’est-à-dire l’hôte qui sert la page, et l’ingestion ne connaît que les domaines enregistrés dans votre compte : déclarez le domaine exact que vous avez enregistré dans Takt, et le même sur tous vos déploiements de production. Le cas de la préproduction est traité plus bas, avec la vérification : le défaut y a une propriété utile plutôt que gênante. Vous ne trouverez en revanche pas d’adresse d’ingestion dans ce bloc, et c’est voulu : l’option endpoint existe, mais son défaut vise le service Takt hébergé, jamais un serveur à vous, et il fonctionne tel quel. N’inventez pas sa valeur : une valeur fausse produit un 404 parfaitement silencieux, parce que le transport passe par navigator.sendBeacon(), qui rend true dès la mise en file d’attente et n’a aucun moyen de vous rapporter le statut de la réponse. Une option voisine, scriptOrigin, sert à dériver l’adresse d’ingestion d’une origine à vous, quand la mesure passe par un proxy sur votre propre domaine.

outbound et files activent deux captures automatiques : les clics vers l’extérieur et les téléchargements. Comme toutes les captures, elles sont désactivées par défaut — track404 pour les pages d’erreur et tagged pour les éléments marqués dans le HTML complètent la série. La navigation, elle, est suivie sans rien activer : le cœur enveloppe pushState et replaceState, et écoute popstate et hashchange. Un clic du routeur client, un retour arrière, un changement d’ancre : chacun émet sa page vue. Le corollaire mérite d’être connu, parce qu’il est propre à SvelteKit : tout ce qui écrit une entrée d’historique est compté comme une page vue, y compris la navigation superficielle que l’on utilise pour ouvrir une modale sans changer d’écran. Si vous vous en servez beaucoup, vos pages vues comptent aussi ces ouvertures. C’est l’un des écueils classiques de la mesure d’une application à navigation client : compter des changements d’URL qui ne changent pas d’écran.

Le piège : deux silences, un seul message

Voici la scène. Vous avez posé le composant, ajouté un bouton qui émet un event, lancé pnpm dev, et le tableau de bord est vide. Vous relisez la configuration, vous vérifiez le domaine, vous ajoutez un console.log dans le gestionnaire de clic — il s’exécute. Rien ne casse, rien ne remonte.

Le problème, c’est qu’il existe deux mécanismes indépendants capables de produire ce vide, avec des causes, des périmètres et des correctifs qui n’ont rien à voir. Déboguer le mauvais des deux coûte facilement une heure.

Silence n°1 — vous êtes sur localhost. L’option excludeLocalhost vaut true par défaut. En développement, rien n’est envoyé, et « localhost » se lit largement : localhost, ::1, 0.0.0.0, les noms en .local, et les plages d’IP privées 127.x, 10.x, 192.168.x, 172.16 à 172.31. Un vite dev --host ouvert depuis votre téléphone sur le réseau local est donc concerné lui aussi. Ce n’est pas une panne : c’est ce qui évite que trois semaines de développement finissent dans les statistiques de production. Le point important pour la suite : ce filtre coupe tout, les pages vues comme les events, et il ne dit rien.

Silence n°2 — l’instance a été résolue trop tôt. useTakt() ne lève jamais. Quand aucun <Takt> n’a encore été monté, il renvoie une instance factice sur laquelle track et pageview ne font rien. Or le composant n’initialise le runtime qu’au montage, alors que le script d’un composant s’exécute avant tout montage. Le code suivant, qui est celui qu’on écrit spontanément, capture donc la factice et la garde :

<script>
  import { useTakt } from '@vskstudio/takt-svelte'

  // Piège : l'instance est résolue au tout premier rendu, avant le montage.
  const takt = useTakt()
</script>

<button onclick={() => takt.track('Signup', { props: { plan: 'pro' } })}>
  S'inscrire
</button>

Ce qui rend ce piège coûteux, c’est qu’il ne se manifeste pas toujours. Sur la page d’atterrissage, celle qui est présente au premier rendu et par laquelle arrive votre trafic organique, le script du composant tourne avant le montage du layout : l’instance est factice, le clic n’envoie rien. Naviguez vers cette même page depuis une autre page du site, et le composant se monte alors que le runtime est déjà en place : le même code fonctionne. Vous testez donc en cliquant dans votre application, tout marche, et le visiteur qui arrive directement depuis un moteur de recherche n’est jamais compté.

Contrairement au n°1, ce silence-là laisse une trace : un avertissement en console, [takt] useTakt() called before <Takt /> mounted — returning a no-op instance.. Sa portée mérite une seconde d’attention, parce que le drapeau qui l’empêche de se répéter vit au niveau du module et non de la page. Dans le navigateur, il ne part donc qu’une fois par chargement, quel que soit le nombre de composants concernés. Au rendu serveur, il ne repart plus tant que le module n’est pas réévalué, c’est-à-dire en pratique pour toute la vie du process pnpm dev : après une tentative de correctif, son silence ne prouve rien tant que vous n’avez pas relancé le serveur. Une fois qu’on le connaît, il tranche la question en deux secondes. Encore faut-il regarder la console, ce que personne ne fait quand le symptôme est « le tableau de bord est vide », parce qu’on cherche du côté du serveur.

Le discriminant. Les deux causes ne coupent pas la même chose, et c’est ce qui permet de les séparer sans rien deviner :

  • Rien du tout — aucune requête au chargement, aucune à la navigation, console propre : c’est le n°1. Vous êtes sur localhost ou sur une IP privée.
  • Les pages vues arrivent, l’event manque — et un [takt] traîne dans la console : c’est le n°2. Le composant fautif a résolu son instance trop tôt.

La vérification est courte et se fait dans le navigateur. Ouvrez l’onglet Réseau, filtrez sur event, rechargez : une requête doit partir. Cliquez trois ou quatre liens internes : une requête doit partir à chaque fois. Puis cliquez le bouton qui doit émettre votre conversion et ouvrez la charge utile de la dernière requête : le nom de l’event y figure sous la clé n, à côté du domaine déclaré.

Lisez aussi la colonne Statut, qu’on oublie systématiquement. Une requête qui part ne dit rien de son sort : un endpoint erroné en fait partir une, en 404, et le transport ne vous le rapportera jamais. Un 202 est donc le minimum à exiger. Il ne suffit pas non plus, parce que l’ingestion répond 202 et écarte l’event sans rien dire quand le domaine déclaré n’est pas enregistré dans votre compte. Ce que cette procédure établit, c’est que le côté client fonctionne : l’instance résolue est la bonne, l’event porte le bon nom, la requête est bien formée et bien reçue. Que la donnée soit ensuite créditée au bon site se lit ailleurs, dans le tableau de bord.

Sortir du n°1. Deux voies, et chacune se paie. La première, excludeLocalhost={false} le temps d’un test, fait partir les requêtes depuis votre machine ; oubliée dans un commit, elle déverse votre trafic de développement dans les statistiques du site déclaré. La seconde, un déploiement de préproduction, n’est pas gratuite non plus : une préproduction qui porte en dur le domaine de production produit exactement le même déversement, à chaque rechargement et à chaque clic de la vérification ci-dessus, et sans la moindre ligne suspecte à retrouver dans le diff.

Ce qui rend la préproduction sûre, c’est d’y laisser domain sur son défaut. L’hôte servi n’est alors pas un domaine enregistré dans votre compte : l’ingestion répond 202 et écarte l’event. Les requêtes partent quand même, la vérification ci-dessus les observe et conclut sur tout le côté client, et vos statistiques de production ne bougent pas. C’est un isolement, pas une panne. En contrepartie, il ne vous dira rien de la fin du trajet, que seul un déploiement de production sous le bon domaine permet de vérifier. Et si vous préférez un interrupteur explicite à un filtre implicite, le composant expose enabled : un enabled={!dev}, avec dev importé de $app/environment, rend le silence local délibéré et visible dans le code.

Sortir du n°2 : résolvez l’instance au moment de l’émission plutôt qu’à l’initialisation du composant — c’est l’objet de la section suivante — et vérifiez au passage que <Takt> est bien dans le layout racine, pour qu’il couvre tout l’arbre et pas une partie.

Émettre des events

Les pages vues ne demandent rien. Pour les conversions — inscription, achat, formulaire envoyé — useTakt() renvoie l’instance partagée, et on l’appelle dans le gestionnaire :

<script>
  import { useTakt } from '@vskstudio/takt-svelte'
</script>

<button onclick={() => useTakt().track('Signup', { props: { plan: 'pro' } })}>
  S'inscrire
</button>

<button
  onclick={() =>
    useTakt().track('Purchase', {
      props: { plan: 'pro' },
      revenue: { amount: '29.00', currency: 'EUR' }
    })}
>
  Acheter
</button>

La différence avec le bloc de la section précédente tient à un seul déplacement : l’appel a lieu au clic, pas au premier rendu. À ce moment-là, le layout est monté depuis longtemps et l’instance résolue est la vraie, que le visiteur soit arrivé sur la page ou y ait navigué. C’est une habitude à prendre une fois : dans un composant Svelte, ce qui est écrit dans le <script> s’exécute à un instant où le runtime de mesure n’existe pas encore.

Le premier argument est le nom de l’événement, et ce nom est une donnée, pas du texte d’interface : c’est la valeur exacte que vous retrouverez dans vos statistiques. Gardez-le stable et identique partout. Sur un site bilingue, un Signup traduit en Inscription du côté français ne renomme pas une conversion : il en crée une seconde et coupe la vôtre en deux, sans que rien ne signale la scission. La même règle vaut pour les clés de propriétés — plan reste plan.

Le revenue se déclare event par event, dans les options passées à track(), comme ci-dessus : rien ne se configure une fois pour toutes sur l’instance. Le montant est une chaîne qui doit matcher \d+(\.\d{1,2})?, la devise un code à trois lettres. Ce n’est pas une préférence de style : un montant mal formé (un nombre au lieu d’une chaîne, trois décimales, un symbole monétaire) est écarté, et l’événement part quand même, sans son montant. Vous obtenez alors des conversions correctement comptées et un chiffre d’affaires à zéro, ce qui serait le pire des deux mondes si le rejet ne laissait rien derrière lui. Il laisse un avertissement en console, [takt] revenue dropped: amount must match \d+(.\d{1,2})?, une seule fois pour toute la page quel que soit le nombre de montants concernés. Formatez donc les montants au moment de les envoyer, en particulier si vous les tenez d’un calcul en virgule flottante. Ces envois partent du navigateur, donc restent à la portée d’un bloqueur ou d’un onglet fermé pendant une redirection de paiement : si votre application parle à un back PHP, l’article sur l’intégration Laravel montre comment émettre la même conversion depuis le serveur.

Composant ou actions : une seule voie

Le paquet expose deux styles d’intégration côté Svelte, et il faut choisir. Le composant <Takt> est le chemin idiomatique : il accepte l’ensemble des réglages du cœur — enabled, sampleRate, trackQuery, queryParams, exclude, scrubUrl compris — et la seule option de configuration qu’il n’expose pas est debug.

L’autre voie est le sous-chemin /actions, qui vous laisse appeler init() vous-même :

<script>
  import { onMount } from 'svelte'
  import { init } from '@vskstudio/takt-svelte/actions'

  // init() touche l'History API : jamais pendant le rendu serveur.
  onMount(() => {
    init({ domain: 'exemple.fr', debug: true, sampleRate: 0.5 })
  })
</script>

Le onMount n’est pas une précaution de confort. Le composant vous protégeait du rendu serveur ; ici, c’est à vous de le faire. Un init() écrit directement dans le corps du <script> s’exécute aussi au serveur, où il touche l’History API et lève une ReferenceError — votre page ne s’affiche plus du tout. Ce que vous gagnez en échange est mince : debug, qui journalise en console chaque charge utile réellement envoyée. Utile pour confirmer un départ, inutile pour expliquer un silence, puisqu’un event filtré n’atteint jamais cette journalisation.

Reste la règle qui compte : jamais les deux à la fois. Le composant construit sa propre instance ; init() en installe une autre, celle que partagent les fonctions du cœur. Monter <Takt> et appeler init() dans la même application, ce n’est pas configurer une mesure à deux endroits, c’est en faire tourner deux — chacune avec son suivi de navigation, donc chaque page vue comptée deux fois. Et la réciproque vaut aussi : si vous ne montez que <Takt>, les fonctions importées depuis /actions ne parlent à personne et ne font rien. Le doublement qui en résulte est plat — deux à chaque navigation, jamais trois : c’est la signature d’une mesure installée deux fois, quelle qu’en soit la forme, et l’article Astro en détaille une autre variante, l’écouteur de navigation ajouté en trop.

En résumé

Un paquet et sa dépendance de pair, un domaine à déclarer, une balise <Takt /> dans le layout racine : une application SvelteKit est mesurée sans cookie ni bandeau, navigation client comprise, sans écouteur à câbler et sans rien à protéger du rendu serveur. Le point réellement piégeux n’est pas l’installation, c’est le diagnostic : deux mécanismes indépendants produisent le même vide. excludeLocalhost, à true par défaut, coupe absolument tout tant que vous êtes sur localhost ou sur une IP privée, et ne dit rien ; useTakt() appelé à l’initialisation d’un composant renvoie une instance factice qui n’envoie rien, ne coupe que les events de ce composant, et laisse un unique avertissement en console. Pages vues absentes et console muette, c’est le premier ; pages vues présentes et event manquant, c’est le second. Appelez useTakt() dans le gestionnaire plutôt que dans le <script>, et le second n’existe plus. La référence complète des options est dans la documentation Svelte.

Passer à l'action

Mesurez votre audience sans bannière.

Voyez Takt en action, puis installez une analytics sans cookies sur votre site.

Partager