Skip to content
← Back to blog
Guides August 1, 2026· 9 min read

Adding analytics to an Astro site without a cookie banner

Cookieless analytics on Astro: integration or component, View Transitions, and why an astro:page-load listener doubles your pageviews.

Contents

Nobody picks Astro by accident. You pick it to ship no JavaScript by default, to hydrate only the islands that need it, to serve prerendered pages that paint before the network has finished catching its breath. Then it’s time to measure traffic, and the first page of results offers the same rig every time: an ad-tech tag, a consent manager to keep it in line, a banner to ask permission. Three third-party scripts and a modal dropped onto a site built to have none. Weeks of holding a performance budget evaporate into a single tag, and they evaporate in exchange for partial numbers, since only the visitors who clicked “Accept” get counted.

The banner isn’t a step in the work, though: it’s the consequence of a tooling decision. This guide walks through the full integration on the Astro side — the integration or the component, client-side navigation and View Transitions, custom events — and dwells on a trap specific to the framework: the most natural reflex Astro teaches you, applied here, silently doubles every one of your pageviews.

Why the banner isn’t inevitable

What triggers the consent requirement isn’t the act of counting visits. It’s writing to or reading from the visitor’s device: a cookie, an entry in local storage, a reconstructed browser fingerprint. Measurement that writes nothing to the browser and never tries to recognise anyone from one visit to the next falls outside that scope. The second source of friction, transferring personal data to servers outside the Union, disappears the same way once collection and storage stay in Europe.

What’s left is exactly what you need to run a site: aggregates. Visits, pages viewed, traffic sources, breakdowns by country and device. No individual profile, no persistent identifier, nobody to recognise from one session to the next. That’s the design bet behind Takt: no cookie, no identification, a managed service hosted in Europe, and a small browser runtime instead of a stack of tags. The full contrast with the Google Analytics approach is on our comparison page.

On an Astro site the consequence is more tangible than elsewhere. A consent banner is by nature an interactive component: it has to be hydrated, given persistent state, and wired to decide what loads before and after the click. On a mostly static site it’s often the only island you’d have to ship, and it does nothing for the visitor. With no banner there’s no island, no layout shift on first paint, and no “before/after consent” branch to test. The payoff isn’t only technical either: nobody declines or dismisses a modal that doesn’t exist, so your numbers describe all of your traffic rather than the fraction that accepted.

Installing analytics in Astro

Before the command line, a step zero people skip cheerfully and pay for later: create the site in Takt and note the domain you register there. That exact value is what belongs in the domain option below, because it’s the declared domain, not the host actually being served, that binds events to a site. A preview deploy served from whatever URL your host generated but declaring example.com will therefore pour straight into your production stats, and you’ll spend a while wondering where that traffic came from.

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

Two packages, because @vskstudio/takt-core is a peer dependency of the integration, exactly like astro itself — which is why the install line names it explicitly rather than leaning on your package manager’s peer resolution. The integration is built on top of it and injects a small browser runtime that boots Takt, fires the initial pageview and tracks client-side navigation.

// astro.config.mjs
import { defineConfig } from 'astro/config'
import takt from '@vskstudio/takt-astro'

export default defineConfig({
  integrations: [takt({ domain: 'example.com' })]
})

That block is a minimal config, not a patch to paste over yours. Your project already has an astro.config.mjs, with integrations, probably an adapter, maybe some Markdown configuration: add the import at the top of the file and the takt(...) call to the existing integrations array. Overwriting the whole file with the above would cost you everything else in it, adapter included.

domain is the option to always set yourself: it defaults to location.hostname, the host serving the page, precisely the value you don’t want on a preview deploy. Every other option has a usable default. spa is true, so client-side navigation is tracked; respectDnt and excludeLocalhost are true as well, and those two account for most cases of “I can’t see anything”; we come back to them at the end. Autocaptures, on the other hand, are off by default: outbound for outbound links, files for downloads, track404 for error pages. There’s no client: directive to pick either: the runtime is SSR- and prerender-safe, it only runs in the browser, which makes it indifferent to your project’s output mode — fully prerendered, rendered on demand, or both in the same build.

Or the component, for per-layout control

The other path is an .astro component, dropped into a layout’s <head>:

---
// src/layouts/Layout.astro
import Takt from '@vskstudio/takt-astro/Takt.astro'
---
<head>
  <Takt domain="example.com" />
</head>

The block between the two --- markers is the component script at the very top of the file: the import joins your existing imports there. The <Takt /> goes into the <head> your layout already has — the block above shows a location, it isn’t a fragment to append as-is at the bottom of your file.

There is one rule with no way around it: one path or the other, never both. Both paths boot core’s same default instance, and both start by setting a flag on window: whichever runs second does nothing at all, no second init, no second pageview, no second listener. That guard, and only that guard, is what makes combining them harmless instead of doubling your initial pageview. You still gain nothing by it, and you end up with the configuration declared in two places, the surest way to update only one of them the day the domain changes.

Which one? The integration for nearly every project: one line in the config, valid for every page, including the ones somebody adds later without thinking about it. The component when measurement has to depend on the layout — a site where only part is public, embedded documentation you’d rather not count with the rest. In that case the component lives in the measured layout, and the integration lives nowhere.

The trap: the astro:page-load reflex

Start with what works with no effort at all, because that’s precisely what makes the trap possible. Astro’s client router runs several history operations per navigation: a scroll-restoration replaceState, then the pushState of the navigation itself. Core’s generic SPA tracking patches both of those methods without deduplicating by URL, so it would count the same navigation several times. The Astro integration knows this and deliberately doesn’t use it: it turns that generic tracking off, fires one explicit pageview at boot, then registers an astro:after-swap listener for you — the event Astro fires exactly once per client-router navigation, back and forward buttons included. One navigation, one pageview, View Transitions DOM swaps included. So there’s nothing for you to wire for client-side navigation, but note how it gets done: by a listener on Astro’s lifecycle, not by a pushState patch.

Now here’s what nearly everyone does. You add the client router, you go and read Astro’s View Transitions guide, and it tells you something perfectly true: scripts don’t re-run after a DOM swap, and the astro:page-load event is the hook provided for re-running whatever needs it. The next step looks obvious: wire up an astro:page-load listener that fires a pageview, “so client-side navigation gets tracked”. Except that on this particular site, client-side navigation was already tracked, and tracked in exactly that way. The integration already has its own listener on Astro’s lifecycle; yours just sits down next to it. It doesn’t add the missing measurement: it stacks a second one on top of the one that was already there.

What makes this trap unpleasant isn’t its difficulty — it’s that it rewards a good instinct. Astro’s own documentation trains you to reach for astro:page-load, because it genuinely is the correct hook for any script that must get back to work after a View Transitions swap. The instinct is right; it’s just been applied to the one thing in the project that doesn’t need it. The code sails through review without a comment, it looks exactly like the framework’s documented best practice, and of course it raises no error.

The asymmetry is worth stating outright, because it’s the part that springs the trap. With an analytics tool that only fires a pageview on document load, that listener isn’t optional: it is the entire measurement of client-side navigation, and leaving it out gives you a site where only the landing page is ever counted. Here it’s redundant. Integration recipes therefore don’t transfer from one tool to another: the question isn’t “how do I track navigations in Astro”, it’s “what does my tool already track”.

The doubling is flat, not compounding: every navigation counts two, not three and then four. And it doesn’t spare the first page, since astro:page-load also fires on the initial load and not only after a navigation — a visitor who lands and leaves at once still counts as two. Everything derived from pageview counts follows: pages per session doubled, and a single-page visit that no longer looks like one, which is exactly what a bounce rate counts.

The trouble with those symptoms is that they only show up in aggregate, a few days later, and they look like a good week. There’s a far faster check. Open the deployed site, open the Network tab of your devtools, filter on event, clear the log, then click four or five internal links in a row and hit the back button once. Count the requests to the ingest endpoint: there must be exactly one per navigation, back button included. Two per navigation and you’ve got your duplicate; the listener is the first place to look. The check takes a minute and it repeats at every deploy.

A count that grows — one request on the first navigation, two on the second, three on the third — describes a different bug: a runtime being re-initialised on every navigation, whose mechanics the Symfony piece works through in detail. A duplicate caused by a listener stays stubbornly at two.

The fix is a deletion: remove the listener and replace it with nothing. While you’re there, check that spa wasn’t flipped to false at some point (say, while somebody was trying to work out the double counting), because that option, left at its true default, is what decides whether the astro:after-swap listener gets registered at all. And keep astro:page-load for what it’s genuinely for: re-attaching a click handler, restarting a map, re-initialising a widget after a DOM swap. The event isn’t the problem; what it was asked to do is.

One corollary is worth knowing, because it lands on exactly the opposite side. Since the integration doesn’t use generic history tracking, a navigation you drive yourself without going through Astro’s client router (a filter, a tab, a paginator pushed into history by hand) fires no astro:after-swap, and therefore sends no pageview. Nothing flags it: the request simply never goes out. If those views matter to you, call pageview() yourself at the moment you change the URL. That’s the one place in this section where adding code is the right answer. The opposite trade-off exists too: when the core keeps its history patch, anything that writes a history entry gets counted, shallow routing included — the SvelteKit piece describes that side of it.

Custom events

Pageviews ask nothing of you. For conversions — a signup, a purchase, a submitted form — the core re-exports its functions through the integration:

import { track } from '@vskstudio/takt-astro'

track('Signup', { props: { plan: 'pro' }, revenue: { amount: '9.00', currency: 'USD' } })

The first argument is the event name, and that name is data, not interface copy: it’s the exact value you’ll find in the dashboard. Keep it stable and identical everywhere. A Signup translated to Inscription on the French side of a bilingual site doesn’t rename a conversion: it creates a second one and cuts yours in half, with nothing anywhere to flag the split. Properties are strings, and so is the amount: '9.00', not 9. The currency is a three-letter code.

Where should the call live? track, like pageview, optOut and optIn, runs in the browser. So it belongs in a client <script> inside an .astro file, or in a hydrated UI-framework component — the one you marked client:load or client:visible.

And this is where Astro sets a second trap, milder than the first but from the same family. The block between the two --- markers at the top of an .astro file is not browser code: it runs at build time, or server-side on every request if the page is rendered on demand. A track() placed there measures nothing on the visitor’s side — and since the runtime only runs in the browser, the call breaks nothing either: nothing at all happens. Which is awkward, because that block is where an Astro developer writes most of their code. If a custom event never shows up while pageviews arrive normally, start by checking which side of those three dashes your call lives on.

Checking that measurement works

First thing to rule out: under pnpm dev you’ll see nothing come through, and that’s intended. excludeLocalhost defaults to true, so localhost and private IPs are ignored. That covers astro dev --host opened from your phone on the local network too: 192.168.x.x is a private address. It isn’t a broken config, it’s what keeps three weeks of development out of your production stats.

A second perfectly legitimate silence: respectDnt defaults to true, so a browser sending Do Not Track isn’t measured. If you’re testing from a hardened profile bristling with privacy extensions, you may be precisely that visitor. Try another browser before concluding something is broken.

Real verification therefore happens online, on a deploy; a preview deploy is enough, as long as it declares the right domain. Network tab, filter on event: one request on page load, then exactly one per internal navigation, back button included. It’s the same count as in the previous section, and it tells you two things at once: that events are going out, and that only one goes out per navigation.

If the request goes out but the dashboard stays empty, the browser is no longer what you should be looking at — the declared domain is. A value that matches no site in your account produces exactly that picture: a perfectly healthy Network tab on one side, no data on the other. Otherwise the pageview shows up in near real time in the dashboard, alongside your custom events, and you can move on to something else.

Wrapping up

Two packages, one declared domain, one line in astro.config.mjs — or the <Takt /> component in a layout’s <head>, but never both: an Astro site is measured with no cookie and no banner, View Transitions included, and with nothing to wire for client-side navigation. The one genuinely sharp edge is the astro:page-load reflex: Astro trains you to reach for that hook because it’s the right one for your own scripts, but the integration already listens for astro:after-swap on your behalf, so a listener added “so client-side navigation gets tracked” doubles every pageview, first page included, without raising a single error or leaving anything behind but a flattering curve. Clear the network log and count the requests to the ingest endpoint across a few navigations: one per navigation, and there’s nothing to fix. The full option reference is in the Astro documentation.

Take the next step

Measure your audience without a consent banner.

See Takt in action, then install cookieless analytics on your site.

Share