One-off tip

Support checkout

  1. 1Choose amount
  2. 2Payment
  3. 3Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Web analytics on a static Astro blog
Cover image: Graphical user interface showing data analytics charts and graphs
astroengineeringanalytics

Web analytics on a static Astro blog

Picking PostHog, wiring up the snippet, and keeping it out of local dev

Published
2 September 2026
Read time
7 min read
SeriesPart 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:

ToolCostNotes
PostHog1M events/month freeProduct analytics + web analytics; generous free tier
GoatCounterFree for public sitesLightweight, no cookies, no self-hosting needed
GA4Fully freeFull-featured but requires a cookie consent banner
Cloudflare Web AnalyticsFreeZero JS, but requires your DNS on Cloudflare
Netlify Analytics$9/monthServer-side, zero JS impact on the page
UmamiFree (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:

src/layouts/BaseLayout.astro
{
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:

public/scripts/posthog-bootstrap.js
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:

VariableValue
PUBLIC_POSTHOG_KEYYour PostHog project API key (phc_...)
PUBLIC_POSTHOG_HOSThttps://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):

Terminal window
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:

.github/workflows/ci.yml
- 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.

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.

Working on something similar?

Need help raising the bar?

I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.

  • Engineering practice review
  • Hands-on delivery
  • Team mentoring
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

Free · Practical · One email per post

Get practical engineering notes

One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.