/ astro-integrations / How to Use PostHog with Astro: Complete Guide
astro-integrations 11 min read

How to Use PostHog with Astro: Complete Guide

Step-by-step guide to integrating PostHog with your Astro website.

How to Use PostHog with Astro: Complete Guide

PostHog is an all-in-one product analytics platform that goes far beyond pageview tracking. It includes session replay, feature flags, A/B testing, surveys, and a data warehouse, all in one tool. The free tier gives you 1 million events per month, which is generous enough that most startups and side projects never need to pay. For Astro developers building products (not just content sites), PostHog provides the kind of deep user insights that help you make better product decisions.

The integration involves adding PostHog's JavaScript snippet to your Astro site. From there, it automatically captures pageviews, clicks, and session data. You then add custom events for specific user actions you want to track.

Prerequisites

  • Node.js 22.12.0 or newer. Astro 6 dropped support for Node 18 and 20 (both reached or are nearing end of life), so an older runtime will fail to install. Confirm with node --version.
  • An Astro project (npm create astro@latest). These instructions are written against Astro 6.4.2.
  • A PostHog account (free for 1 million events/mo, then usage-based pricing)
  • Your PostHog project API key and instance host (US or EU cloud, or your self-hosted URL)

Installation

Install the PostHog JavaScript client. The current published version on npm is posthog-js@1.376.4:

npm install posthog-js

PostHog also ships a one-command setup wizard that drops the snippet in for you and can run inside Cursor, Bolt, or your terminal. It is the fastest path if you would rather not wire things by hand:

npx @posthog/wizard@latest

If you prefer a packaged Astro integration over a hand-rolled component, the community astro-posthog integration (astro-posthog@1.0.2, GPL-3.0, maintained by jop-software) wraps the same client behind npx astro add astro-posthog. It is not an official PostHog or Astro package, so weigh that before adopting it. This guide uses the official hand-rolled approach below, which PostHog documents as the recommended manual path.

Configuration

Get your API key and instance address from PostHog's project settings.

Add them to .env:

PUBLIC_POSTHOG_KEY=phc_your_api_key_here
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

If you are self-hosting PostHog, replace the host with your instance URL.

Create a PostHog initialization component. PostHog's official Astro docs are specific about one thing: the snippet must run inside a <script is:inline> block. Without is:inline, Astro processes and bundles the script, which strips the global posthog reference and produces TypeScript and build errors. Inline scripts cannot read import.meta.env after bundling, so PostHog's documented pattern hardcodes the token and host directly in the inline snippet. If you want to keep the key in an env var, expose it through define:vars on a non-inline script instead, but note that inline is the approach PostHog itself recommends.

---
// src/components/PostHogAnalytics.astro
---

<script is:inline>
  !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.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)+".people (stub)"},o="init capture register register_once register_for_session unregister opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing startSessionRecording stopSessionRecording sessionRecordingStarted loadToolbar get_property getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSurvey getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep".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('phc_your_api_key_here', {
    api_host: 'https://us.i.posthog.com',
    defaults: '2026-01-30',
  });
</script>

A note on the defaults option. PostHog's current docs describe it as "a date, such as 2026-01-30, for a configuration snapshot used as defaults to initialize PostHog." It pins a point-in-time set of sensible defaults (including how pageviews and person profiles are handled) so future SDK releases do not silently change your tracking behavior. Any option you set explicitly overrides the snapshot, so you can still pass person_profiles: 'identified_only' or capture_pageview if you want to be explicit. Swap the host for https://eu.i.posthog.com if your project lives on EU Cloud, or your own URL if you self-host.

Add this to your base layout:

---
// src/layouts/BaseLayout.astro
import PostHogAnalytics from "../components/PostHogAnalytics.astro";
---

<html>
  <head>
    <PostHogAnalytics />
  </head>
  <body>
    <slot />
  </body>
</html>

Basic Usage

With PostHog initialized, it automatically captures pageviews and basic interactions. For custom event tracking, use the PostHog client in your components:

---
// src/components/PricingCard.astro
const { plan, price } = Astro.props;
---

<div class="pricing-card">
  <h3>{plan}</h3>
  <p class="price">${price}/mo</p>
  <button class="subscribe-btn" data-plan={plan} data-price={price}>
    Subscribe
  </button>
</div>

<script>
  document.querySelectorAll(".subscribe-btn").forEach((btn) => {
    btn.addEventListener("click", (e) => {
      const target = e.currentTarget as HTMLElement;
      window.posthog?.capture("pricing_click", {
        plan: target.dataset.plan,
        price: target.dataset.price,
      });
    });
  });
</script>

Using feature flags to conditionally show content:

---
// src/pages/index.astro
---

<div id="hero-section">
  <h1 id="hero-title">Default Hero</h1>
  <p id="hero-subtitle"></p>
</div>

<script>
  window.posthog?.onFeatureFlags(() => {
    const isNewHero = window.posthog?.isFeatureEnabled("new-hero-design");
    if (isNewHero) {
      const title = document.getElementById("hero-title");
      const subtitle = document.getElementById("hero-subtitle");
      if (title) title.textContent = "New Hero Design";
      if (subtitle) subtitle.textContent = "This is the A/B test variant";
    }
  });
</script>

If you use Astro's client-side router (<ClientRouter />, the component formerly called <ViewTransitions />), the page never fully reloads on navigation, so the inline init snippet can run more than once and double-count. PostHog's docs recommend guarding initialization with a one-time flag and letting the SDK capture route changes itself:

---
// src/components/PostHogAnalytics.astro (ClientRouter variant)
---

<script is:inline>
  if (!window.__posthog_initialized) {
    window.__posthog_initialized = true;
    // ...the same PostHog loader snippet as above...
    posthog.init('phc_your_api_key_here', {
      api_host: 'https://us.i.posthog.com',
      defaults: '2026-01-30',
      capture_pageview: 'history_change',
    });
  }
</script>

Setting capture_pageview: 'history_change' tells PostHog to fire a $pageview whenever the browser history changes, which is exactly what the client router triggers. This is cleaner than manually re-capturing on astro:after-swap and avoids the risk of double pageviews if both fire.

Identify users after authentication:

// After login
window.posthog?.identify(userId, {
  email: user.email,
  name: user.name,
  plan: user.subscription,
});

// After logout
window.posthog?.reset();

Production Tips

  1. Use person_profiles: 'identified_only' to save on events. By default, PostHog creates anonymous person profiles for every visitor. Setting this to identified_only means person profiles are only created when you call identify(), which dramatically reduces your event count on the free tier.

  2. Enable session replay selectively. Session replay is powerful for debugging but uses significant event quota. Enable it only for specific pages or user segments using PostHog's configuration options rather than globally across your entire site.

  • Use feature flags for gradual rollouts. Instead of deploying features and hoping for the best, use PostHog feature flags to roll out to 10% of users first. Monitor the impact in analytics, then increase to 100% when confident.

  • Set up key funnels. Define conversion funnels in the PostHog dashboard (e.g., landing page to signup to first action to subscription). This tells you where users drop off and what to fix first.

  • Self-host for data control. PostHog offers a self-hosted option via Docker. If you need full data ownership or have strict compliance requirements, self-hosting gives you all the features without sending data to a third party. Point api_host at your instance URL instead of the US or EU cloud hosts.

  • You do not need on-demand rendering just for PostHog. Because the snippet is a client-side inline script, it runs in the browser on a fully static page. Astro 6 keeps output: 'static' as the default and pre-renders everything, which is ideal for analytics. You only need a server adapter and on-demand rendering if you want to call PostHog server-side (for example, evaluating feature flags before sending HTML). See the rendering note below.

  • A Note on Astro 6 Rendering Modes

    Older PostHog and Astro guides tell you to set output: 'hybrid' to mix static and server-rendered pages. That option no longer exists. Astro removed output: 'hybrid' in Astro 5, and Astro 6 continues with just two modes: 'static' (the default, where every page is pre-rendered to HTML at build time) and 'server' (where pages are rendered on demand by default). On-demand rendering is now controlled per page rather than through a third global mode.

    To server-render a single route while keeping the rest of the site static, install an adapter and opt that page in:

    npx astro add node   # or netlify, vercel, cloudflare
    
    ---
    // src/pages/dashboard.astro
    export const prerender = false;
    ---
    

    In the default 'static' build, pages are pre-rendered unless you set export const prerender = false. If you flip the whole project to output: 'server', the logic inverts and you mark static pages with export const prerender = true. The Astro docs advise staying on the default 'static' mode until you are sure most pages need on-demand rendering. For the client-side PostHog snippet in this guide, you do not need any of this; it works on a fully static build.

    Common Errors and Fixes

    • Build or TypeScript errors about a missing posthog property. This is the single most common mistake. Astro bundles <script> tags by default, which breaks the global window.posthog the snippet relies on. The fix is the is:inline directive on the script tag, which tells Astro to leave the script untouched. PostHog's docs call this out explicitly.
    • import.meta.env is undefined inside the inline snippet. Inline scripts are not processed by Astro's bundler, so they cannot read import.meta.env. Either hardcode the token and host in the inline snippet (PostHog's documented pattern) or pass values in through define:vars on a separate non-inline script.
    • Double-counted pageviews with the client router. When you use <ClientRouter /> (formerly <ViewTransitions />), the document does not reload between pages, so the init snippet can run again and re-fire pageviews. Guard initialization with a window.__posthog_initialized flag and let the SDK capture navigations via capture_pageview: 'history_change' rather than re-running init.
    • Events going to the wrong region or never arriving. Match api_host to your project's data region: https://us.i.posthog.com for US Cloud, https://eu.i.posthog.com for EU Cloud, or your own URL when self-hosting. A US key pointed at the EU host (or vice versa) will silently fail to record events.
    • npm install fails on the Astro project. Astro 6 requires Node 22.12.0 or newer. Node 18 reached end of life in April 2025 and Node 20 is scheduled to follow in April 2026, so upgrade your runtime if installs error out on the engine check.
    • No $pageview events anywhere. Confirm the component is actually mounted in the <head> of your base layout and that the key starts with phc_. Open the browser network tab and look for requests to /static/array.js and to your api_host; if they are absent, the snippet did not run.

    Official Docs and Examples

    Alternatives to Consider

    • Fathom or Plausible if you only need pageview analytics and want a simpler, privacy-focused tool without the product analytics features.
    • Mixpanel if you want a more established product analytics platform with deeper analysis tools and a focus on event-based tracking.
    • Google Analytics if you need free analytics with advertising integration, though at the cost of more complexity and privacy concerns.

    Wrapping Up

    PostHog is the analytics tool for Astro developers building products, not just content sites. The combination of event tracking, session replay, feature flags, and A/B testing in one platform means fewer tools to manage and better data to act on. The free tier at 1 million events per month covers most early-stage projects comfortably, and the self-hosted option means you can scale without worrying about costs or data residency. If you are making product decisions and need more than pageview counts, PostHog gives you the depth to understand what your users actually do.

    Sources

    All versions and facts below were checked on 2026-05-29.