Original post: Web analytics on a static Astro blog
Series: Part of How this blog was built — documenting every decision that shaped this site.
A blog without analytics is guesswork. You have no idea whether anyone is reading,
which posts land, or where people drop off. For a personal site that's fine for
a while, but eventually you want data.
The question for a statically-generated blog isn't whether to add analytics,
it's which ones, and how to avoid polluting your development logs with noise.
Picking a tool
There are several free options that work well with an Astro/Netlify stack:
| Tool | Cost | Notes |
|---|---|---|
| PostHog | 1M events/month free | Product analytics + web analytics; generous free tier |
| GoatCounter | Free for public sites | Lightweight, no cookies, no self-hosting needed |
| GA4 | Fully free | Full-featured but requires a cookie consent banner |
| Cloudflare Web Analytics | Free | Zero JS, but requires your DNS on Cloudflare |
| Netlify Analytics | $9/month | Server-side, zero JS impact on the page |
| Umami | Free (self-hosted) | Privacy-first, but needs a server to run on |
I went with PostHog for a few reasons:
- The free tier covers 1 million events per month.
- It tracks page views, but also gives you session recordings, funnels, and feature flags if you ever want them.
- The EU cloud (
eu.i.posthog.com) means data stays in Europe, which simplifies GDPR considerations. - The setup is a small script, no npm package, no build step changes.
The implementation
The analytics snippet lives in two places: a conditional <script> tag in
src/layouts/BaseLayout.astro, which wraps every page on the site, and a
small vendor bootstrap file in public/scripts/posthog-bootstrap.js.
Production-only loading
The most important constraint: the snippet should only load in production. In
local development, PostHog would record your own page views, skew the data, and
clutter the network panel with requests to an external service.
Astro exposes import.meta.env.PROD, a boolean that is true during a
production build and false during astro dev. Wrapping the script tag in a
conditional expression gates it cleanly:
{
import.meta.env.PROD && (
<script
src="/scripts/posthog-bootstrap.js"
data-posthog-key={import.meta.env.PUBLIC_POSTHOG_KEY}
data-posthog-host={
import.meta.env.PUBLIC_POSTHOG_HOST || "https://eu.i.posthog.com"
}
defer
/>
)
}
In development the entire block evaluates to nothing, so no script tag is
emitted and no network requests are made.
Why an external file instead of define:vars
The first version of this wired PostHog's project key straight into an inline
script using Astro's define:vars directive, which injects server-side
variables into an inline <script> block at build time. That worked, but
PostHog's loader snippet is a wall of minified vendor JavaScript, and Prettier
reformats every .astro file on save. Having that vendor blob embedded
directly inside the component meant every save reflowed it along with the
rest of the file, turning a one-line stub into unreadable diffs and risking a
subtle edit to code nobody should be hand-editing.
Moving the snippet into its own .js file under public/ fixes the
build-time half of the problem: Astro serves anything in public/ as a
static asset without bundling or transforming it, so there's no chance of the
Astro compiler mangling the vendor code. It also confines Prettier's
formatting to a single vendor file that isn't meant to be hand-edited, rather
than fighting with the surrounding .astro template on every save. Since an
external script has no access to the Astro component's scope, configuration
has to travel through data-* attributes instead of define:vars, read back
out with document.currentScript.dataset:
const currentScript = document.currentScript;
if (
currentScript instanceof HTMLScriptElement &&
currentScript.dataset.posthogKey
) {
const posthogKey = currentScript.dataset.posthogKey;
const posthogHost =
currentScript.dataset.posthogHost || "https://eu.i.posthog.com";
/* eslint-disable */
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+" (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init(posthogKey, { api_host: posthogHost });
/* eslint-enable */
}
The minified stub is PostHog's own array.js
loader, copied verbatim from their documentation. It bootstraps a minimal
stub synchronously, then loads the full library asynchronously from the
PostHog CDN. The eslint-disable comment matters too: the vendor code
doesn't follow this project's lint rules, and it shouldn't be rewritten to
satisfy them.
Variables prefixed with PUBLIC_ in Astro are safe to expose this way.
PostHog's project key is designed to be public: it appears in the page
source of every site using PostHog. The only real requirement is that it
never gets hardcoded in source control, which the environment variables
below take care of.
Environment variables
Two environment variables are required:
| Variable | Value |
|---|---|
PUBLIC_POSTHOG_KEY |
Your PostHog project API key (phc_...) |
PUBLIC_POSTHOG_HOST |
https://eu.i.posthog.com (EU) or https://us.i.posthog.com (US) |
Getting the key
Sign up at posthog.com, create a project, and copy the
API key from Project Settings → Project API Key. It starts with phc_.
Local development
Create a .env file in the project root (already in .gitignore via
Astro's default setup):
PUBLIC_POSTHOG_KEY=phc_your_key_here
PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
Even with these set locally, the snippet won't fire. The import.meta.env.PROD
check takes care of that.
GitHub Actions
This site doesn't build on Netlify. CI builds the static site in GitHub
Actions, and Netlify just deploys the pre-built dist/ folder with
netlify deploy --no-build, so the environment variables that get baked into
the HTML have to live as GitHub Actions repository secrets
(Settings → Secrets and variables → Actions), not in the Netlify
dashboard. The build step in .github/workflows/ci.yml passes them through
explicitly:
- run: pnpm build
env:
SHOW_DRAFTS: "false"
PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.PUBLIC_STRIPE_PUBLISHABLE_KEY }}
PUBLIC_POSTHOG_KEY: ${{ secrets.PUBLIC_POSTHOG_KEY }}
PUBLIC_POSTHOG_HOST: ${{ secrets.PUBLIC_POSTHOG_HOST }}
One thing this setup doesn't solve yet: the preview build
(SHOW_DRAFTS: "true", deployed to the passcode-protected preview alias)
uses the exact same PostHog secrets as the production build. Every preview
and branch deploy reports into the same PostHog project as production, so
draft traffic and my own review sessions on the preview alias mix in with
real visitor data. Splitting them would mean a second PostHog project and a
second pair of secrets scoped per build job, which isn't worth the overhead
yet for a personal blog.
What you get out of the box
Once deployed, PostHog automatically captures:
- Page views: URL, referrer, and UTM parameters
- Sessions: grouping page views by visitor session
- Device and browser: OS, browser, screen resolution
- Geography: country and city, derived from IP, not stored
The dashboard is ready to use from the first page view with no configuration
beyond the snippet.
A note on cookie consent
PostHog uses cookies and local storage by default to identify returning
visitors across sessions. Depending on your jurisdiction and audience, this
may require a cookie consent banner.
This site doesn't have one yet. posthog.init() runs with PostHog's
defaults, no persistence override, which sets a cookie on the first page
view. For a personal blog with a UK/EU audience, the strictest interpretation
of GDPR would require consent before setting analytics cookies, so this is a
compliance gap I'm accepting for now rather than one I've solved.
A pragmatic middle ground, if I revisit this, is configuring PostHog to use
persistence: 'memory' to avoid setting any cookies, at the cost of losing
cross-session identity:
posthog.init(posthogKey, {
api_host: posthogHost,
persistence: "memory",
});
Page views and event counts would still be accurate, I'd just lose the
ability to track individual user journeys across multiple sessions.
Top comments (0)