To add analytics to Astro, put the tracker’s script tag in the <head> of your base layout, or import an npm tracker inside an Astro <script>. If that layout uses <ClientRouter />, the script runs once, on the first page loaded, so later page changes are counted only when the tracker follows the browser’s History API on its own.
We tested that on November 3, 2026: one Astro 7.4.5 site with <ClientRouter />, the same 12 steps for every tool, in headless Chrome 155. invisit’s script stays quiet in a browser driven by automation, so its runs used the debug option. invisit, the cookieless analytics tool we build, and Plausible counted all 9 page changes. Umami missed every Back and Forward, GA4 missed Back and Forward after a same-page #anchor jump, and GA4 inside Partytown counted 2.
This post continues our Next.js guide, which covers which events to send from the server, identify(), payment webhooks and funnels. Here is only what Astro changes. Our post on single-page apps covers how trackers follow client-side routing in general.
Do Astro view transitions break analytics?
CSS view transitions do not, and <ClientRouter /> can. Astro offers two kinds of view transition, and an analytics script sees them very differently:
| Cross-document view transitions | <ClientRouter /> |
|
|---|---|---|
| How you turn it on | @view-transition { navigation: auto; } in CSS |
<ClientRouter /> from astro:transitions in the head |
| What a link click does | A normal page load, animated by the browser | A fetch, a DOM swap and history.pushState() |
| Your analytics script | Runs on every page | Runs once; every later page is the same document |
| Page views | Counted by every tool | Counted only by tools that follow the History API |
Astro’s view transitions guide says cross-document transitions don’t “affect any existing scripts”, while <ClientRouter /> turns the site into a single-page app, with “needing to manually reinitialize scripts or state after navigation” as the stated drawback. invisit.dev is an Astro 7 site that animates its page changes with the CSS rule, not the router. If you want the animation and not the single-page app, that rule is the whole fix.
What does ClientRouter do with your analytics script?
<ClientRouter /> runs each script once and keeps it, unless you ask for more. From Astro’s docs for the current major version:
- Bundled
<script>tags, the default in.astrofiles, “are only ever executed once”. - A
<script>with any attribute other thansrcis not processed, the same asis:inline(scripts guide). During navigation, “scripts are left in if they exist on the new page”, so a tracker tag is not run again. data-astro-rerunmakes an inline script run again “after every transition”.transition:persistkeeps an element across pages. A tracker that runs once anyway gains nothing from it.- The
astro:page-loadevent fires on the first load and after every navigation, once the new page is visible.
Two details in Astro’s router source decide what a tracker records, and the docs don’t mention either. On a link click, Astro swaps the DOM first and calls pushState() after, with document.title set back to the old page’s title for that call, so the history entry keeps the right name. On Back and Forward, the browser fires popstate before Astro has even fetched the page. A tracker that reads the title inside those hooks records the previous page’s title.
Which analytics tools count Astro ClientRouter page views?
invisit and Plausible counted every page change; Umami and GA4 each missed some, and GA4 in Partytown missed nearly all. Every setup went through the same steps: load /, click to /a, /b and /c?tab=2, scroll, jump to #details on the same page, press Back four times and Forward once, click /a, reload. That makes 9 real page changes; the scroll, the anchor jump and the Back out of it are not page changes. Vendor requests were answered on our machine, so no data was sent anywhere.
| Setup, as of November 2026 | Page views for 9 page changes | Back and Forward (3) | #details jump |
Title on client-side page views |
|---|---|---|---|---|
invisit script tag or @invisitdev/web 0.2.1 |
9 | 3 | Not counted | Correct |
@plausible-analytics/tracker 0.4.7 |
9 | 3 | Not counted | Not sent |
Umami Cloud script.js |
7 | 0 | Counted as a page | Correct |
GA4 gtag.js, history events on |
6 | 0 after the jump, 3 without it | Not counted | Correct |
GA4 gtag.js in Partytown |
2 | 0 | Not checked | Correct on the 2 it sent |
Umami’s script wraps pushState and replaceState and has no popstate listener, which is why all three Back and Forward page views were lost. It also recorded /c?tab=2#details as a page, because Umami keeps the hash unless you add data-exclude-hash="true".
Plausible wraps pushState, listens to popstate and compares only the path, so a change from /c to /c?tab=2 alone would not count. Plausible says its npm package “runs the same tracking code as our script” (npm package docs).
GA4 counts history changes when the enhanced measurement setting “Page changes based on browser history events” is on; it listens for pushState, popstate and replaceState (Google’s enhanced measurement docs). It counted Back and Forward in every run without the anchor jump. After a jump to #details and a Back past it, it recorded no Back or Forward page view for the rest of that page’s life, in all 7 of our runs that did this, while link clicks still counted. We served a real GA4 container with that setting on, under a made-up measurement ID.
invisit counts each page change once, Back and Forward included. Our first run found every client-side page view carrying the title of the page being left, because the tracker read document.title inside its pushState and popstate hooks. The tracker now holds a client-side page view until the title changes, for up to two seconds, and sends it at once when an event, another navigation or leaving the page comes first; a rerun of the same nine steps recorded every title correctly.
How to fix Umami page views with Astro view transitions
Turn off Umami’s automatic page views and send one from astro:page-load, with the address read at that moment. Umami’s tracker functions document both data-auto-pageview="false" and a track() call that takes a function over the default payload:
<script
defer
src="https://cloud.umami.is/script.js"
data-website-id="<website id>"
data-auto-pageview="false"
></script>
<script is:inline>
document.addEventListener('astro:page-load', () =>
window.umami?.track((props) => ({
...props,
url: location.pathname + location.search,
title: document.title,
})),
)
</script>In our run this sent 9 page views for 9 page changes, each with the right address and title. Pass url yourself: without it, Umami reused the last address it had seen through pushState, and all three Back and Forward page views came out as /c?tab=2#details.
Should you send a page view on astro:page-load?
Only for a tracker that does not follow the History API itself. On top of a tracker that does, a page view sent from astro:page-load doubles your numbers: Plausible’s npm tracker plus track('pageview') on astro:page-load sent 18 page views for 9 page changes in our test.
Calling init() inside that listener is a related mistake. Plausible’s init() “can only be called once”, and in our test it threw on all 7 client-side navigations; counting still worked, from the first call. invisit’s init() returns the running tracker on later calls, and its pageview() skips an address it has already counted, so the same listener on invisit stayed at 9.
data-astro-rerun on a tracker’s script tag does the most damage. Umami ran again on every navigation and wrapped pushState again each time, so the third link click sent 4 page views and the last one 7: 25 page views for 9 page changes. The invisit script checks for a running tracker before starting, so with data-astro-rerun it stayed at 9.
Where to put the analytics script in an Astro layout
Put it in the <head> of the one layout every page uses, next to <ClientRouter /> if you have it. The spot is the same for static and server output: the tag is plain HTML, written into each prerendered page at build time.
---
import { ClientRouter } from 'astro:transitions'
const { title } = Astro.props
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{title}</title>
<ClientRouter />
<script
async
src="https://api.invisit.dev/invisit.js"
data-site="<site id>"
data-collector="https://api.invisit.dev"
></script>
</head>
<body>
<slot />
</body>
</html>Astro leaves the tag alone because it has attributes besides src. Leave out data-astro-rerun. Plausible’s snippet from your site settings and GA4’s two gtag.js tags go in the same spot, unchanged.
Script tag or bundled npm tracker in Astro?
For invisit, the script tag and the bundled package counted ClientRouter page views identically in our test. A script tag loads the vendor’s current version from the vendor’s host. A bundled <script> puts a pinned version in your own build, served from /_astro/ on your domain, and Astro runs it once, not on every navigation:
<script>
import { init } from '@invisitdev/web'
init({ site: import.meta.env.PUBLIC_INVISIT_SITE })
</script>Render <Analytics /> once in the base layout. PUBLIC_ variables are written into the bundle at build time, which suits a site id: it is public. The bundled tracker still sends its events to api.invisit.dev until you set baseUrl, as in the proxy section below.
Plausible’s package has the same shape, init({ domain: 'example.com' }) from @plausible-analytics/tracker, and the same rule: call it once, at the top of the script, never inside an astro:page-load listener.
How to serve analytics from your own domain on a static Astro site
On a static host, a rewrite in the host’s config proxies tools that accept forwarded requests as they are. Astro copies public/ into the build output, so a Netlify _redirects file lives there:
/_hx/script.js https://plausible.io/js/pa-XXXXX.js 200
/_hx/event https://plausible.io/api/event 200That is Plausible’s Netlify guide with a path of your own. Cloudflare Pages reads the same file, but its proxying “will only support relative URLs on your site” (Cloudflare’s docs), so an external proxy there needs a Worker. On Vercel, rewrites go in vercel.json at the project root instead. Our analytics proxy guide covers forwarding the visitor’s address for each host.
invisit needs a server instead of a rewrite. A plain rewrite reaches invisit’s collector with your host’s address in place of each visitor’s, because the collector trusts a forwarded visitor address only from a proxy that signs in with your site’s ingest key. On a fully static Astro site, load the tracker from api.invisit.dev, or add an adapter for the middleware below.
How to proxy analytics through Astro middleware
With an adapter, @invisitdev/server in src/middleware.ts serves invisit’s tracker and endpoints under /_i on your domain, while your pages stay prerendered. This is the documented Astro setup, which gained the first-use client and the on-demand 404 page after this test.
import { createInvisit, type Invisit } from '@invisitdev/server'
// Created on first use: Astro also runs middleware while it prerenders pages
// at build time, where INVISIT_API_KEY is usually not set.
let client: Invisit | undefined
export const invisit = (): Invisit =>
(client ??= createInvisit({ key: process.env.INVISIT_API_KEY! }))import { defineMiddleware } from 'astro:middleware'
import { invisit } from './lib/invisit'
export const onRequest = defineMiddleware(async (context, next) => {
if (context.isPrerendered) return next()
const proxied = await invisit().proxy(context.request, { ip: context.clientAddress })
if (proxied) return proxied
const response = await next()
void invisit().crawlers(context.request, { status: response.status })
return response
})---
import Base from '../layouts/Base.astro'
// Rendered on demand, so unknown paths, /_i/* included, reach the middleware.
export const prerender = false
---
<Base title="Page not found"><h1>Page not found</h1></Base>import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
export default defineConfig({
adapter: node({ mode: 'standalone' }),
// Your public host: behind a proxy, the Node adapter otherwise rejects the tracker's POSTs (CSRF 403).
security: { allowedDomains: [{ hostname: 'example.com' }] },
})Then change the tag in your layout to <script async src="/_i/invisit.js" data-site="<site id>" data-collector="/_i"></script>. The ingest key is a secret: keep it in a server variable, never a PUBLIC_ one (server SDK).
We ran this on Astro 7.4.5 with @astrojs/node 11.1.9 against a stand-in collector on our machine. Every ClientRouter page view went through /_i/v1/events and reached the collector with the key and the visitor’s address. Three things broke it before that:
- A prerendered 404 page. With
src/pages/404.astroprerendered, Astro’s default, the Node adapter answered/_i/invisit.jswith the static 404 page and never ran the middleware.export const prerender = falseon the 404 page fixed it. - The build. Astro runs middleware while it prerenders pages, so creating the client at the top of the module failed
astro buildwith “no key given” when the key was only set at runtime. Creating it on first use fixed it. - A route instead of middleware. Astro excludes files and folders whose names start with
_insrc/pages, so asrc/pages/_i/[...path].tsendpoint never becomes a route.
Crawler reads come only from requests that run the middleware. Prerendered pages served as files never do, so on a mostly static site those reads cover your on-demand pages only.
How to send server-side events from Astro actions and API endpoints
Call track() from the action or endpoint once the thing has happened, with context.request and context.clientAddress. Astro’s action context includes both, and the address matters: on the Node adapter no proxy header carries it, and track() rejects without one.
import { defineAction } from 'astro:actions'
import { z } from 'astro/zod'
import { invisit } from '../lib/invisit'
import { createUser } from '../lib/users'
export const server = {
signup: defineAction({
accept: 'form',
input: z.object({ email: z.string().email() }),
handler: async (input, context) => {
const user = await createUser(input)
// The account exists, so count it now. A lost event must never fail a signup.
await invisit()
.track('signup_completed', {
request: context.request,
ip: context.clientAddress,
userId: user.id,
})
.catch(console.error)
return { id: user.id }
},
}),
}import type { APIRoute } from 'astro'
import { invisit } from '../../lib/invisit'
export const prerender = false
export const POST: APIRoute = async ({ request, clientAddress }) => {
// ...save the subscription first
await invisit()
.track('newsletter_joined', { request, ip: clientAddress, properties: { list: 'weekly' } })
.catch(console.error)
return new Response(null, { status: 204 })
}Astro’s actions guide says a page must be rendered on demand to call an action from an HTML form. In our test, both events reached the collector. With <ClientRouter />, submitting that form was also a navigation to /signup?_action=signup, which invisit recorded as a page view; Plausible would not count it, since it compares paths only. Which events belong on the server, and why, is in the Next.js guide; the reasoning carries over unchanged.
How to add analytics to a Starlight docs site
Add the script tag through Starlight’s head option. Starlight 0.42.8 does not use <ClientRouter />, so every docs page is a full page load and any tracker counts it.
import { defineConfig } from 'astro/config'
import starlight from '@astrojs/starlight'
export default defineConfig({
integrations: [
starlight({
title: 'Docs',
head: [
{
tag: 'script',
attrs: {
src: 'https://api.invisit.dev/invisit.js',
'data-site': '<site id>',
'data-collector': 'https://api.invisit.dev',
async: true,
},
},
],
}),
],
})Starlight’s configuration reference says head entries “do not pass through Astro’s script or style processing”, so a bundled npm tracker needs an override of Starlight’s Head component instead.
The proxy has the same 404 trap. With an adapter and the middleware above, Starlight’s built-in 404 route is prerendered, and /_i/invisit.js answered 404 in our test. Set disable404Route: true in the Starlight config and add an on-demand 404 page:
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'
export const prerender = false
---
<StarlightPage frontmatter={{ title: 'Page not found', template: 'splash' }}>
<p>Check the address, or search the docs.</p>
</StarlightPage>With both, the tracker loaded with a 200, unknown paths still showed the Starlight-styled 404, and docs pages stayed static.
Should you use Partytown for GA4 with Astro view transitions?
Not with <ClientRouter />. With @astrojs/partytown 2.1.9, GA4 running in Partytown’s worker did not see the router’s navigations: 2 page views for 9 page changes in our full run, and no more than 2 in any of six shorter runs. Forwarding gtag to the worker and sending page_view from astro:page-load did not help in our test; only the first page arrived.
The integration does keep Partytown’s iframe across page swaps, so this is not a missing setup step. Partytown’s own docs already warn that GA4’s user_engagement event “is not reliable from a web worker” (Partytown’s GTM guide). When we tested over plain http on a hostname other than localhost, Partytown could not start its service worker and GA4 quietly ran on the main thread, so test it over https. Keep GA4 on the main thread with ClientRouter.
Which Astro analytics tools work without cookies?
invisit, Plausible and Umami set no cookies; GA4 does. As of November 2026, GA4 sets its _ga cookies for two years by default (Google’s cookie usage page), and in the EU and UK that means a consent banner in practice. Plausible counts visitors with a hash and a salt it deletes daily; Umami’s salt rotates monthly by default.
invisit stores nothing on the visitor’s device. It tells visitors apart with a keyed hash of the site, the date, the device and browser type and the IP address, mixed with a random salt for each UTC day that is deleted about 25 hours after the day ends, so nobody is followed across days. Whether that is enough to drop your banner depends on where your visitors are: see our cookie banner guide and how cookieless analytics counts visitors.
Checklist before you ship analytics on Astro
- The tracker sits once in the base layout’s
<head>, with nodata-astro-rerun. - With
<ClientRouter />, you clicked three pages, pressed Back twice, and saw one page view per page change in your tool’s realtime view. - No
astro:page-loadpage view on top of a tracker that already follows history. - Umami:
data-auto-pageview="false"plus theastro:page-loadcall that passesurl. - GA4 runs on the main thread, not in Partytown.
- Behind the invisit proxy: the 404 page is on demand,
security.allowedDomainsnames your host, and/_i/invisit.jsanswers 200. - The server client is created on first use, so
astro buildworks without the key. - Actions and endpoints pass
clientAddresstotrack().
