/ astro-integrations / How to Integrate Google Analytics with Astro: Complete Guide
astro-integrations 10 min read

How to Integrate Google Analytics with Astro: Complete Guide

Step-by-step guide to integrating Google Analytics with your Astro website. Setup, configuration, and best practices.

How to Integrate Google Analytics with Astro: Complete Guide

How to Integrate Google Analytics with Astro: Complete Guide

Google Analytics 4 (GA4) is the standard web analytics platform for tracking user behavior, traffic sources, conversions, and engagement metrics. Adding GA4 to your Astro site lets you understand how visitors interact with your content, which pages perform best, and where your traffic comes from. Since Astro outputs static HTML by default, the integration involves adding the GA4 tracking script to your pages and optionally configuring it for more advanced tracking scenarios.

This guide covers adding GA4 to an Astro project, handling view transitions, setting up custom events, and respecting user privacy preferences.

Prerequisites

You will need:

  • Node.js 22.12.0 or higher installed. Astro 6 dropped Node 18 and Node 20 support entirely, so older runtimes will fail to install or build. If you are still on Astro 4 or 5, Node 18.20.8+ or 20.3.0+ is acceptable, but matching the Astro 6 requirement keeps you future-proof.
  • An existing Astro project. The snippets here work on Astro 4, 5, and 6.
  • A Google Analytics 4 property created at analytics.google.com
  • Your GA4 Measurement ID (starts with G-, found in Admin > Data Streams > Web)

No SSR adapter is required. Google Analytics works with the default static output. Note that since Astro 5, output: 'static' is the default and the old output: 'hybrid' mode was removed (its mixed prerender behavior was folded into 'static'). You only need an adapter if you opt specific routes out of prerendering with export const prerender = false, which analytics does not require.

Installation

There is no npm package required to load GA4 itself. GA4 uses a script tag loaded directly from Google's servers. The official, supported way to keep that script off the main thread is the first-party Astro Partytown integration:

npx astro add partytown

That command installs @astrojs/partytown (current version 2.1.7), wires it into astro.config.mjs, and pulls in its runtime dependency @qwik.dev/partytown (resolved version 0.13.2). The Partytown runtime moved from Builder.io to the Qwik team, so the older @builder.io/partytown package is deprecated; the integration now depends on @qwik.dev/partytown. The integration is published by the Astro core team, so prefer it over any third-party analytics plugin.

If you would rather add it by hand, install the package and register the integration yourself:

npm install @astrojs/partytown@2.1.7

Partytown is optional but recommended. It relocates the analytics script into a web worker, preventing it from blocking the main thread during page render. This improves Core Web Vitals such as Total Blocking Time.

Configuration

Basic Setup Without Partytown

The simplest approach is adding the GA4 script directly to your layout component:

---
// src/layouts/BaseLayout.astro
const GA_ID = 'G-XXXXXXXXXX'; // Replace with your Measurement ID
---

<html>
  <head>
    <script async src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}></script>
    <script define:vars={{ GA_ID }}>
      window.dataLayer = window.dataLayer || [];
      function gtag() { dataLayer.push(arguments); }
      gtag('js', new Date());
      gtag('config', GA_ID);
    </script>
  </head>
  <body>
    <slot />
  </body>
</html>

The define:vars directive passes the Astro variable into the inline script without breaking the build.

Setup With Partytown

For better performance, use Partytown to run the analytics script off the main thread:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import partytown from '@astrojs/partytown';

export default defineConfig({
  integrations: [
    partytown({
      config: {
        forward: ['dataLayer.push'],
      },
    }),
  ],
});

Then update your layout to use the type="text/partytown" attribute:

---
// src/layouts/BaseLayout.astro
const GA_ID = 'G-XXXXXXXXXX';
---

<html>
  <head>
    <script
      type="text/partytown"
      async
      src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
    ></script>
    <script type="text/partytown" define:vars={{ GA_ID }}>
      window.dataLayer = window.dataLayer || [];
      function gtag() { dataLayer.push(arguments); }
      gtag('js', new Date());
      gtag('config', GA_ID);
    </script>
  </head>
  <body>
    <slot />
  </body>
</html>

Partytown intercepts the scripts and runs them in a web worker, keeping the main thread free for user interactions.

Using Environment Variables

Store the measurement ID in your .env file to keep it out of source code:

PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX

Then reference it in your layout:

---
const GA_ID = import.meta.env.PUBLIC_GA_MEASUREMENT_ID;
---

Common Patterns

Handling Astro Client-Side Routing (View Transitions)

If your Astro site uses client-side routing, page navigations happen without full page reloads, so GA4 will not automatically register them as new page views. In current Astro the relevant component is <ClientRouter />, imported from astro:transitions. It was named <ViewTransitions /> in Astro 3 and was renamed to <ClientRouter /> in Astro 5, so update any older copy-pasted snippet accordingly. Add the component to your shared head, then listen for Astro's lifecycle event:

---
// src/layouts/BaseLayout.astro
import { ClientRouter } from 'astro:transitions';
---

<head>
  <ClientRouter />
</head>

<script>
  document.addEventListener('astro:page-load', () => {
    if (typeof gtag === 'function') {
      gtag('event', 'page_view', {
        page_title: document.title,
        page_location: window.location.href,
      });
    }
  });
</script>

Place the listener in your base layout so it runs on every page. The astro:page-load event fires on both the initial page load and every subsequent client-side navigation (forward or backward), which makes it the right hook for a single-page-application style page view.

Custom Event Tracking

Track specific user interactions by sending custom events:

<button id="signup-btn">Sign Up Free</button>

<script>
  document.getElementById('signup-btn')?.addEventListener('click', () => {
    gtag('event', 'sign_up_click', {
      event_category: 'engagement',
      event_label: 'hero_section',
    });
  });
</script>

To comply with GDPR and similar regulations, use Google Consent Mode. The key rule from Google's own guidance is that gtag('consent', 'default', ...) must run before any config or event command and before the gtag.js library loads, so the tag knows to hold storage until the user decides:

<script define:vars={{ GA_ID }}>
  window.dataLayer = window.dataLayer || [];
  function gtag() { dataLayer.push(arguments); }

  // Set defaults to denied BEFORE loading gtag.js
  gtag('consent', 'default', {
    ad_storage: 'denied',
    ad_user_data: 'denied',
    ad_personalization: 'denied',
    analytics_storage: 'denied',
  });

  // Now load gtag.js
  const script = document.createElement('script');
  script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`;
  script.async = true;
  document.head.appendChild(script);

  gtag('js', new Date());
  gtag('config', GA_ID);

  // Call this function when the user accepts in your cookie banner
  window.grantAnalyticsConsent = function () {
    gtag('consent', 'update', {
      analytics_storage: 'granted',
    });
  };
</script>

This loads GA4 in a cookieless, consent-pending state and only enables analytics storage after the user grants consent. When the user clicks "Accept" in your cookie banner, call window.grantAnalyticsConsent(). If you also run Google Ads, include the ad_storage, ad_user_data, and ad_personalization keys in the update call as well, matching the defaults you declared above.

Troubleshooting

No data appearing in GA4 dashboard: GA4 has a processing delay of up to 24-48 hours for standard reports. Use the Realtime report to verify the script is working. Also check that you are using the correct Measurement ID (starts with G-, not UA-).

Partytown script not executing: Partytown requires the forward config to include dataLayer.push. Without this, gtag calls silently fail. Also make sure your build output includes the Partytown library files in the ~partytown directory.

Duplicate page views: If you are using <ClientRouter /> along with the standard GA4 setup, you can get double counting on the first load (gtag's automatic page view plus your manual astro:page-load event). Either pass send_page_view: false inside gtag('config', GA_ID, { send_page_view: false }) so the manual listener is the only source, or drop the custom listener and let GA4 handle it. Do not do both at full strength.

GA4 blocked by ad blockers: Many users run ad blockers that prevent GA4 from loading. Consider a server-side analytics solution like Plausible or Umami as a supplement if you need more accurate numbers. GA4 data should be treated as a sample rather than an exact count.

Core Web Vitals impact: Without Partytown, the GA4 gtag.js script is heavy, on the order of 135KB gzipped over the wire per Plausible's measurement, and it runs on the main thread. If your Lighthouse scores drop after adding analytics, enable Partytown or load the script with a setTimeout to defer it after page interaction.

Common Errors and Fixes

npx astro add @astrojs/partytown does nothing or errors: The astro add command takes the short integration name, not the full package name. Run npx astro add partytown. The command resolves and installs @astrojs/partytown for you and edits astro.config.mjs.

gtag is not defined inside a Partytown script: Partytown runs scripts in a web worker, so the worker cannot reach the main-thread dataLayer unless you forward it. Make sure your config includes forward: ['dataLayer.push']. Without it the gtag calls are dropped silently.

Partytown library files return 404 in production: The integration writes its runtime into a ~partytown directory at build time. If your host rewrites or strips that path, the worker fails to boot. Confirm ~partytown/ is present in your build output and served as static files. During local debugging, Partytown's debug logging is on by default in dev and preview, so check the console there before deploying.

output: 'hybrid' throws "invalid option": That mode was removed in Astro 5. The default output: 'static' already supports mixing prerendered and on-demand pages, so simply remove the output: 'hybrid' line. Analytics needs no output change at all.

Build fails right after upgrading to Astro 6: Astro 6 requires Node 22.12.0 or higher and drops Node 18 and 20. Check node -v locally and confirm your deploy platform's runtime. Pinning 22.12.0 in an .nvmrc keeps local and CI in sync.

<ViewTransitions /> import not found: The component was renamed to <ClientRouter /> (imported from astro:transitions) in Astro 5. Update the import and the tag.

Consent Mode not holding cookies back: The gtag('consent', 'default', ...) call must run before gtag.js loads and before any config/event call. If it runs after, the defaults arrive too late and the tag may already have written storage.

No data in GA4: Standard reports lag up to 24 to 48 hours. Use the Realtime report to confirm hits, and double-check you pasted a G- Measurement ID, not a legacy UA- Universal Analytics ID (Universal Analytics stopped processing data on 2023-07-01).

Official Docs and Examples

Conclusion

Google Analytics 4 integrates cleanly with Astro through a simple gtag.js script tag in your base layout. Use the official Partytown integration for better performance, handle client-side routing with the astro:page-load event and the <ClientRouter /> component, and implement Consent Mode for privacy compliance. For most Astro sites, the environment variable approach with the basic script tag provides everything you need. Start with the Realtime report to verify your setup before diving into the full analytics dashboard.

Sources

All package versions and technical claims were verified on 2026-05-29 against the following sources: