Adding Privacy-First Analytics to an Astro Site
Astro's View Transitions break standard analytics scripts silently. Here is the correct pattern for tracking every route change without cookies or a consent banner.
Astro's View Transitions swap page content in place without a full browser reload — which means any analytics script relying on DOMContentLoaded or load will only fire once, on the initial visit. Every subsequent client-side navigation is invisible. This is not a configuration error on your part; it is a fundamental consequence of how the ClientRouter works.
The same silent-tracking problem exists in SvelteKit 2 and Remix, but the fix in each framework is different. In Astro, the answer is the astro:page-load lifecycle event.
Why the usual script tag fails
When <ClientRouter /> is active (opt-in via astro:transitions in Astro 4, enabled by default in Astro 5), navigations do not unload the page. Astro fetches the next page in the background, swaps the DOM, and updates the URL — all without destroying the current JavaScript context.
A tracker script loaded via a plain <script> tag is treated as a bundled module script by Astro's build pipeline. Bundled module scripts execute exactly once per page session. The tracker initializes, fires a pageview for the landing page, and then never runs again as the user navigates.
To fire on every navigation, initialization logic must be wired to Astro's navigation lifecycle. The right hook is astro:page-load, which fires after the new page is visible and all blocking scripts are loaded — both on the initial load and on every subsequent transition.
The correct integration
Add the tracker script once in your root layout (src/layouts/Layout.astro or equivalent), then attach a listener for astro:page-load:
---
// Layout.astro
---
<html lang="en">
<head>
<!-- your head content -->
</head>
<body>
<slot />
<script
is:inline
src="https://api.monoid.website/tracker.min.js"
data-site-id="YOUR_SITE_ID"
async
></script>
</body>
</html>
The is:inline directive tells Astro's bundler to leave this script exactly as-is. Without it, Astro would process the script as a module, deduplicate it, and suppress re-execution. With is:inline, the script tag is emitted verbatim in every page's HTML output.
For sites not using View Transitions, this is all you need. The tracker fires once on load and you are done.
Handling View Transitions navigation
If your site uses <ClientRouter />, the tracker's built-in history.pushState hook fires correctly after each transition — the tracker listens for Astro's navigation events internally. No additional configuration is required.
To verify, open DevTools Network tab and filter by collect. Navigate between two pages. You should see one POST request per navigation, including the first load.
If you are injecting the tracker conditionally and need it to reinitialize after each swap (for example, after a dynamic island mounts), you can manually trigger a re-run:
<script data-astro-rerun>
// This block re-executes after every View Transition.
// Use sparingly — most tracker logic should not live here.
if (window.__monoid) {
window.__monoid.trackPageview();
}
</script>
The data-astro-rerun attribute forces inline scripts to re-execute after every transition. Note that it implies is:inline, so it only applies to non-bundled scripts.
Separating environments
Astro projects typically have local dev, preview, and production environments. Use an environment variable to hold the site_id and suppress tracking in development:
---
const siteId = import.meta.env.PUBLIC_MONOID_SITE_ID
---
{siteId && (
<script
is:inline
src="https://api.monoid.website/tracker.min.js"
data-site-id={siteId}
async
></script>
)}
Set PUBLIC_MONOID_SITE_ID in .env for production and leave it unset locally. Astro exposes variables prefixed with PUBLIC_ to the client; server-only variables (without the prefix) are not visible in browser-executed code.
What you do not need
No cookie consent banner. No CMP integration. The collection endpoint computes a daily visitor hash from the IP address, User-Agent, a server-side secret, and the current date:
visitor_hash = SHA-256(IP + UA + SALT_SECRET + YYYY-MM-DD)
The hash resets every 24 hours. Nothing is stored on the visitor's device. Broad browser family and device type are derived server-side from the request User-Agent for aggregate reporting — full User-Agent strings, browser versions, and persistent identifiers are never stored.
Astro sites are often fully static or edge-rendered with minimal JavaScript. Adding a sub-2 KB analytics script with no cookies and no consent requirement fits naturally into that philosophy. There is no npm package to maintain, no SDK to update, and no GDPR legal basis to document for analytics processing.