We launched the new Garrett Digital site in Astro this week. It’s not the right choice for most of our clients — anyone who needs a non-developer to edit pages, swap images, or publish blog posts is better served by WordPress.

But for a team where everyone has front-end development experience, Astro is a good fit. No database, no plugins to update, no CMS overhead. You write files, push to GitHub, and the site builds. We also wanted to use Tailwind CSS for more precise design control, and Astro’s tight integration with Claude Code makes development and debugging faster.

There’s another use case. If you’re already on a custom platform like ASP.NET and you want to add a blog without building one yourself, Astro handles that well. It supports blog posts, categories, and SEO best practices out of the box, and the file-based workflow feels familiar if you’re already working directly with code.

That said, migrating to Astro surfaces some gotchas. Analytics is one of them. The site went live, everything looked good, and then we checked Google Analytics. Only one pageview was being counted per session conversion events were not firing.

This isn’t a configuration mistake you might catch during development. It’s a fundamental mismatch between how Astro handles navigation and how Google Tag Manager (GTM) and GA4 were designed to work. Once you understand what’s happening, the fix is straightforward — but there are several layers to it.

This post talks about why tracking breaks, how to fix pageview counting, how to handle click and conversion events across page transitions, which tools to load outside GTM entirely, and how to verify everything.

Why GA4 Stops Tracking After the First Page

Traditional websites do a full page reload on every navigation. The browser fires a new page load, GTM reinitializes, and the “All Pages” trigger sends a pageview to GA4. That’s the model GTM was built for.

Astro’s View Transitions feature works differently. When someone clicks a link, Astro swaps the page content using JavaScript, updates the URL, and animates the transition. From the visitor’s perspective, navigation is instant. From GTM’s perspective, nothing happened.

The result: GTM fires one pageview when the visitor first lands, then goes silent for every page they visit after that. You won’t catch this during development because the first pageview always fires correctly. The problem only shows up when someone actually navigates around your live site.

This isn’t unique to Astro. Next.js, SvelteKit, Nuxt, and any framework using client-side routing have the same problem. With Astro, developers often add analytics last and discover the issue after launch.

Setting Up GTM in Astro

Before anything else: GTM’s standard snippet uses inline scripts, and you need to add is:inline to the script tags in Astro. Without it, Astro’s bundler will try to process the snippet and break it silently.

This was one of the first bugs we ran into on the Garrett Digital site. The GTM script was wrapped in a template literal, which caused Astro to treat the entire thing as a discarded string expression rather than executable code. GTM appeared to load without errors, but never ran. Adding is:inline and removing the wrapper fixed it.

Add this to the <head> of your Layout.astro:

<!-- Initialize dataLayer before GTM loads -->
<script is:inline>window.dataLayer = window.dataLayer || [];</script>

<!-- GTM snippet (replace GTM-XXXXXXX with your container ID) -->
<script is:inline>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');</script>

Add the GTM noscript fallback immediately after the opening <body> tag:

<noscript>
  <iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
  height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>

The window.dataLayer = window.dataLayer || [] line before the GTM snippet matters. If your analytics code pushes events to the dataLayer before GTM finishes loading, that data won’t get lost.

Fixing Pageview Tracking with astro:page-load

Astro fires a built-in event called astro:page-load every time a page transition completes, including the initial load. That’s the hook you need to push pageview data to GA4 after each navigation.

There’s one catch: because it fires on the initial load too, you’ll double-count the first page if you’re not careful. GTM’s “All Pages” trigger already handles that first pageview. You only want astro:page-load to push subsequent navigations.

The fix is a simple flag. Because Astro keeps your JavaScript modules loaded in memory across page transitions (rather than re-executing them each time), a variable set at the top of the module stays set for the entire session:

let isFirstLoad = true;

document.addEventListener('astro:page-load', () => {
  if (isFirstLoad) {
    isFirstLoad = false;
    return; // GTM's All Pages trigger handles this one
  }

window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    event: 'virtual_pageview',
    page_path: window.location.pathname,
    page_title: document.title,
    page_url: window.location.href,
  });
});

isFirstLoad starts as true, gets set to false after the initial load, and stays that way. Every navigation after the first pushes a virtual_pageview event to the dataLayer, which GTM picks up and forwards to GA4.

Deferring GTM for Performance

The standard GTM snippet loads synchronously during page parse, which contributes to Total Blocking Time (TBT) — the amount of time the main browser thread is blocked and unable to respond to user input. High TBT directly impacts your Interaction to Next Paint (INP) score, which Google uses as a Core Web Vitals signal for page responsiveness.

For performance-sensitive sites, you can delay GTM from loading until the visitor’s first interaction with the page. When we set up the Garrett Digital site, performance tooling flagged the blocking GTM script as a TBT contributor. Instead of loading GTM immediately, we load it on the first scroll, click, keystroke, touch, or mouse movement — whichever happens first — with a 3-second timeout as a fallback so GTM always loads even if the visitor just sits still:

<script is:inline>
window.dataLayer = window.dataLayer || [];
(function () {
  var fired = false;
  function loadGTM() {
    if (fired) return;
    fired = true;
    clearTimeout(fallback);

dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
    var s = document.createElement('script');
    s.async = true;
    s.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX';
    document.head.appendChild(s);

events.forEach(function (v) {
      document.removeEventListener(v, loadGTM, true);
    });
  }

var events = ['scroll', 'click', 'keydown', 'touchstart', 'mousemove'];
  events.forEach(function (v) {
    document.addEventListener(v, loadGTM, { capture: true, once: true, passive: true });
  });

var fallback = setTimeout(loadGTM, 3000);
})();
</script>

mousemove In that list is important. We initially left it out. A desktop visitor who reads a page without scrolling or clicking won’t trigger GTM until the 3-second timeout fires. That created a blind spot for Microsoft Clarity — it was missing session data for homepage visitors who just read the page. Adding mousemove catches passive desktop readers much earlier.

One limitation of this approach: if a visitor leaves the page before any interaction and before the 3-second timeout, GTM won’t load, and the visit won’t be tracked. This approach doesn’t solve that — it’s a known tradeoff. For most analytics purposes, those very short sessions aren’t a meaningful loss. But if you’re running Google Ads campaigns where every conversion must be captured, or you have a chat widget that should appear immediately, either load those outside GTM or defer entirely.