Astro and GA4: Why Pageview Tracking Breaks & How to Fix It
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.
Jump to a section:
- Why GA4 stops tracking after the first page
- Setting up GTM in Astro
- Deferring GTM and the Partytown tradeoff
- Click and conversion event tracking
- GTM and GA4 manual setup steps
- Verifying your setup
If you’re comfortable with code, work through the sections in order. If you’d rather hand the code side to Claude Code and handle GTM manually, skip to the Claude Code Prompt section and the GTM Setup section.
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:
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:
astro
<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:
js
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:
astro
<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.
The Partytown Approach
If you’ve looked into performance and third-party scripts in Astro while solving this problem, you may have come across Partytown. It takes a different approach, and it’s worth understanding the tradeoffs before deciding.
The browser’s main thread handles rendering, user input, layout calculations, and JavaScript execution simultaneously. When a heavy third-party script like GTM runs on the main thread, it competes with all of that, which shows up as poor TBT and INP scores. Partytown moves those scripts off the main thread entirely into a Web Worker — a background process that runs separately from the page. The main thread stays free for rendering and interaction, which can produce real improvements in Largest Contentful Paint (LCP) and INP.
In Astro, installation is simple:
bash
npx astro add partytownThen configure it in astro.config.mjs to forward dataLayer calls back to the main thread:
js
import partytown from '@astrojs/partytown';
export default defineConfig({
integrations: [
partytown({
config: {
forward: ['dataLayer.push'],
},
}),
],
});In Layout.astro, change the GTM script type to text/partytown:
astro
<script is:inline type="text/partytown">
(function(w,d,s,l,i){ /* GTM snippet */ })(window,document,'script','dataLayer','GTM-XXXXXXX');
</script>The main thread performance gains are real, but Partytown introduces two problems for this specific setup. First, it has a documented compatibility issue with Astro’s View Transitions — it breaks the fallback navigation behavior in Firefox, which doesn’t yet support the View Transitions API natively. On a site using View Transitions throughout, that’s a real problem, not a theoretical one.
Second, because GTM runs in a Web Worker rather than the main thread, it can’t directly access the DOM. Tags that need to read page content, respond to user interactions, or run synchronously may not behave as expected. That covers a fair amount of common GTM usage — click listeners, form tracking, chat widgets.
Partytown makes the most sense on sites with lean, simple tag setups: a GA4 tag, maybe an ad pixel, nothing that needs DOM access. If you’re not using View Transitions heavily and your GTM container is minimal, it’s worth testing. For a site using View Transitions throughout with active click and form tracking in GTM, the delayed-load approach avoids these failure modes with less risk.
Partytown is worth revisiting as both the library and Astro’s View Transitions implementation mature — but as of early 2026, it introduces more risk than it removes for this setup.
Click and Navigation Tracking That Survives Page Transitions
GTM’s built-in click triggers attach a listener when GTM loads. On a traditional site, that’s fine. On Astro with View Transitions, the DOM updates after each navigation, but GTM doesn’t re-run. For simple cases, the listener usually still works, but for dynamic elements rendered after a page swap, it can break silently.
A more reliable approach is event delegation with data attributes, managed in your own code. This also gives you a clean record of which navigation elements and CTAs drive conversions — data that’s hard to reconstruct after the fact.
Setting Up Click Tracking
Event delegation means attaching a single listener to the entire document and checking which element was clicked, rather than attaching an individual listener to each element. Combined with a window.__analyticsInit guard, it only registers once regardless of how many page transitions happen:
js
if (!window.__analyticsInit) {
window.__analyticsInit = true;
document.addEventListener('click', (e) => {
const cta = e.target.closest('[data-track-cta]');
if (cta) {
window.dataLayer.push({
event: 'cta_click',
cta_location: cta.dataset.trackCta,
cta_label: cta.dataset.trackLabel || cta.textContent.trim(),
cta_url: cta.getAttribute('href'),
});
}
const navLink = e.target.closest('[data-track-nav]');
if (navLink && navLink !== cta) {
window.dataLayer.push({
event: 'navigation_click',
nav_location: navLink.dataset.trackNav,
nav_label: navLink.dataset.trackLabel || navLink.textContent.trim(),
nav_url: navLink.getAttribute('href'),
});
}
}, { passive: true });
}The window.__analyticsInit guard matters. Depending on how your script tags are structured, Astro can re-run scripts across navigations. Without this guard, you end up with duplicate listeners after a few page transitions.
Add tracking attributes directly to elements you want to track:
html
<a href="/contact" data-track-cta="header" data-track-label="Book a Consult">
Book a Consult
</a>
<a href="/services/seo" data-track-nav="footer" data-track-label="SEO Services">
SEO Services
</a>Tracking navigation clicks tells you which paths users actually take through your site versus where they drop off. If your footer SEO link gets more clicks than your header CTA, that’s a layout or messaging problem worth knowing about. Without this data, you’re guessing at what users do between pageviews.
Tracking Form Submissions and Conversion Events
Conversion events need to fire when a specific action succeeds — and the timing matters more than you might expect.
For form submissions, push a dataLayer event from your success handler, not from the submit button click. Firing on click counts every submission attempt, including ones that fail validation. You want confirmed submissions only.
js
// In your form success handler — after the API call returns success
window.dataLayer.push({
event: 'generate_lead',
form_id: 'contact',
});generate_lead is GA4’s recommended event name for lead form completions on service and informational sites. It populates conversion reports automatically and feeds Google Ads Smart Bidding when you import GA4 conversions into Google Ads.
That said, generate_lead is one example. Your Key Events (what GA4 calls conversions) depend on your site’s goals. Common ones include:
purchasefor e-commerce transactionsbegin_checkoutas a micro-conversion for checkout startsbook_appointmentfor practices and service businessesfile_downloadfor lead magnets- A custom event name that maps to whatever action defines success for your business
The pattern is the same regardless of the event name: push to the dataLayer from a success handler, not a click or navigation event. Then configure the corresponding GA4 Event tag and trigger in GTM, and mark it as a Key Event in GA4. That’s covered in the setup sections below.
If your form redirects to a thank-you page after submission, read the Google Ads section carefully. Client-side navigation to /thank-you won’t trigger a standard page-URL-based conversion tag the way a traditional form redirect does.
What Happens to Your Other Tracking Tools
Moving to Astro doesn’t just affect GA4. If you were running session recording tools, Google Ads conversion tracking, or anything else through GTM on your previous site, all of it needs to be re-verified after launch.
Session Recorders: Load Them Directly, Not Through GTM
If you use Microsoft Clarity, Hotjar, or FullStory, load the snippet directly in your <head> instead of through GTM.
Session recorders are designed to capture a visit from the moment the page loads. Routing them through GTM, even without deferral, introduces a startup delay. With deferred GTM, the problem is worse: on the Garrett Digital site, Clarity was missing complete session data for visitors who left before triggering GTM’s load condition.
We pulled Clarity out of GTM and loaded it directly in Layout.astro. Sessions start recording immediately with no dependency on GTM’s load timing.
astro
<!-- Load directly in <head>, before GTM -->
<script is:inline>
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)
})(window,document,"clarity","script","YOUR_PROJECT_ID");
</script>The same logic applies to Hotjar. Load it with their direct snippet in <head>, not through GTM.
On the tools themselves: Microsoft Clarity is free with no recording limits and integrates with GA4. Hotjar’s free tier caps at 35 sessions per day but includes surveys, feedback widgets, and better collaboration features. For most small business sites, Clarity is a solid starting point at no cost.
Google Ads Conversion Tracking
Google Ads conversion tracking through GTM generally survives the move to Astro, with one exception: if your confirmation page uses client-side navigation instead of a full page load, the conversion tag won’t fire.
In a traditional setup, GTM fires the conversion tag when the confirmation page loads. On Astro with View Transitions, navigating to /thank-you after a form submission is a client-side swap, not a full page load. If your conversion tag fires on a Page View trigger for that URL, it won’t fire.
The fix: push a dataLayer event from your form success handler (the generate_lead push above), then trigger both your GA4 Event tag and your Google Ads Conversion tag from that event instead of a page URL. That way, the conversion fires reliably regardless of how the page transition happens.
Also verify the Google Ads Conversion Linker tag. This tag stores click ID data in first-party cookies so Google can match ad clicks to conversions. It needs to fire on every page of your site, not just the conversion page. Check in GTM Preview that it’s present and firing consistently across multiple pages.
Cookie Consent
If your site has visitors who require cookie consent, plan for that before launch rather than retrofitting later. GA4 can operate in a consent-aware mode that adjusts what data is collected based on the user’s choice — this is handled through GA4’s Consent Mode v2, configured via GTM.
Set the default consent state with a dataLayer push before GTM loads:
js
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'consent': 'default',
'ad_storage': 'denied',
'analytics_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
});When a user accepts, push an update:
js
window.dataLayer.push({
'consent': 'update',
'analytics_storage': 'granted',
});GTM reads these signals and adjusts what GA4 collects. Even when analytics storage is denied, GA4 can send cookieless pings that Google uses to model traffic — so you still get estimated data for visits you can’t measure directly.
Several open-source and free consent management tools handle the banner and GTM Consent Mode v2 integration cleanly. All work with Astro via a direct <head> snippet. Load the consent tool before GTM so the consent state is in the dataLayer before GTM initializes and starts firing tags.
Where to Put the Analytics Code in Astro
Scripts with is:inline are included in the HTML exactly as written and run on every page. Scripts without is:inline are bundled by Astro and deduplicated across the site.
Use is:inline for third-party snippets — GTM, Clarity, Hotjar, cookie consent — where you need the script to run exactly as provided. Use bundled scripts for your own analytics code so it loads once regardless of how many pages share your layout.
A clean structure in Layout.astro:
astro
<head>
<!-- Cookie consent: load before GTM so consent state is set first -->
<script is:inline>/* consent snippet */</script>
<!-- Session recorder: load directly, not through GTM -->
<script is:inline>/* Clarity or Hotjar snippet */</script>
<!-- GTM: deferred loader -->
<script is:inline>/* GTM snippet */</script>
</head>
<body>
<noscript><!-- GTM noscript fallback --></noscript>
<slot />
<!-- Your analytics module: bundled and deduplicated by Astro -->
<script>
import '../scripts/analytics.js';
</script>
</body>Using Claude Code to Set This Up
If you’re not comfortable editing Astro files directly, you can use Claude Code to handle the code side. Here’s a prompt you can run in Claude Code that covers everything in this post. You’ll still need to set up GTM and GA4 manually — that’s covered in the next section.
Before running the prompt, have these ready:
- Your GTM container ID (format: GTM-XXXXXXX)
- Your Microsoft Clarity project ID (if you use Clarity)
- Your Hotjar site ID (if you use Hotjar)
Claude Code prompt:
I have an Astro site using View Transitions and need to set up Google Tag Manager (GTM) and GA4 pageview tracking correctly. Here's what needs to happen:
1. In src/layouts/Layout.astro (or the main layout file used across all pages):
In the <head>:
- Add `<script is:inline>window.dataLayer = window.dataLayer || [];</script>` as the first script
- Add a deferred GTM loader as an is:inline script that fires GTM on the first of: scroll, click, keydown, touchstart, or mousemove — whichever happens first — with a 3-second setTimeout fallback so GTM always loads even if the visitor doesn't interact. GTM container ID is GTM-XXXXXXX. Include the mousemove event in the trigger list.
- Add Microsoft Clarity directly as an is:inline script (project ID: YOUR_CLARITY_ID) — do NOT load this through GTM
- If a cookie consent snippet needs to go here, leave a clearly labeled comment placeholder before GTM
After the opening <body> tag:
- Add the GTM noscript iframe fallback
Before the closing </body> tag:
- Add a <script> tag (without is:inline) that imports '../scripts/analytics.js'
2. Create src/scripts/analytics.js with the following:
- A virtual pageview tracker using astro:page-load with an isFirstLoad flag so the first pageview is skipped (GTM's All Pages trigger handles that one). Push event: 'virtual_pageview' with page_path, page_title, and page_url to window.dataLayer on every subsequent navigation.
- A click tracker using event delegation on document, wrapped in a window.__analyticsInit guard so it only registers once across all page transitions. Track elements with data-track-cta attributes (push event: 'cta_click' with cta_location, cta_label, cta_url) and elements with data-track-nav attributes (push event: 'navigation_click' with nav_location, nav_label, nav_url).
- A form success handler pattern: export a function called trackLead(formId) that pushes event: 'generate_lead' with form_id to window.dataLayer. I'll call this from my form components when a submission succeeds.
3. Show me how to add data-track-cta and data-track-nav attributes to a link element — one example of each.
4. Show me where to call trackLead() in a basic form component after a successful submission.
Make sure all script tags that contain third-party snippets use is:inline. Use plain JavaScript (not TypeScript) for the analytics module unless the project is already using TypeScript throughout.After Claude Code runs this prompt, review the output before deploying. Check specifically that:
is:inlineis on the GTM and Clarity script tags, not on the analytics module import- The
isFirstLoadflag is in module scope, not inside the event listener - The
window.__analyticsInitguard wraps the entire click listener registration, not just the dataLayer push
GTM Changes: What to Configure Manually
Once the code is in place, you need to configure GTM to receive the events your site is pushing. Log in at tagmanager.google.com and select your container.
Step 1: Confirm Your Google Tag Is Set Up
In the left sidebar, click Tags. Look for a tag named “Google Tag” or “GA4 Configuration.” If you migrated from an existing site, this was auto-upgraded by Google and should already be there. If you’re starting fresh:
- Click New in the top right
- Under Tag Configuration, choose Google Tag
- Enter your GA4 Measurement ID (format: G-XXXXXXXX — find this in GA4 under Admin > Data Streams > your stream)
- Set the trigger to Initialization – All Pages
- Name it “Google Tag – GA4” and save
Step 2: Create Three DataLayer Variables
In the left sidebar, click Variables. Scroll to User-Defined Variables and click New for each of the following:
Variable 1
- Variable name: DLV – page_path
- Variable type: Data Layer Variable
- Data Layer Variable Name: page_path
- Save
Variable 2
- Variable name: DLV – page_title
- Variable type: Data Layer Variable
- Data Layer Variable Name: page_title
- Save
Variable 3
- Variable name: DLV – page_url
- Variable type: Data Layer Variable
- Data Layer Variable Name: page_url
- Save
Step 3: Create the Virtual Pageview Trigger
In the left sidebar, click Triggers, then New.
- Trigger name: Custom Event – virtual_pageview
- Trigger type: Custom Event
- Event name: virtual_pageview (must match exactly what your code pushes)
- This trigger fires on: All Custom Events
- Save
Step 4: Create the GA4 Pageview Event Tag
In the left sidebar, click Tags, then New.
- Tag name: GA4 Event – virtual_pageview
- Tag type: Google Analytics: GA4 Event
- Measurement ID: your G-XXXXXXXX
- Event name: page_view (use this exact name — it maps to GA4’s standard pageview reports)
- Under Event Parameters, click Add Row for each:
- Parameter name: page_path / Value: {{DLV – page_path}}
- Parameter name: page_title / Value: {{DLV – page_title}}
- Parameter name: page_location / Value: {{DLV – page_url}}
- Triggering: select Custom Event – virtual_pageview
- Save
Step 5: Create Tags for Click Tracking
Create two Custom Event triggers first:
- Custom Event – cta_click (event name: cta_click)
- Custom Event – navigation_click (event name: navigation_click)
Then create two GA4 Event tags:
Tag: GA4 Event – cta_click
- Tag type: Google Analytics: GA4 Event
- Event name: select_content (GA4 recommended event for internal link clicks)
- Trigger: Custom Event – cta_click
Tag: GA4 Event – navigation_click
- Tag type: Google Analytics: GA4 Event
- Event name: navigation_click
- Trigger: Custom Event – navigation_click
Step 6: Create Tags for Conversion Events
Create a Custom Event trigger for each Key Event your site tracks. For lead generation:
- Trigger name: Custom Event – generate_lead
- Trigger type: Custom Event
- Event name: generate_lead
- Save
Then create a GA4 Event tag:
Tag: GA4 Event – generate_lead
- Tag type: Google Analytics: GA4 Event
- Event name: generate_lead
- Trigger: Custom Event – generate_lead
- Save
Repeat this for any other conversion events specific to your site (purchase, book_appointment, file_download, etc.).
Step 7: Set Up the Google Ads Conversion Linker (if running Google Ads)
In Tags, click New.
- Tag name: Google Ads Conversion Linker
- Tag type: Conversion Linker
- Trigger: All Pages
- Save
This needs to fire on every page, not just your conversion page.
Step 8: Publish
Click Submit in the top right corner. Add a version name like “Astro virtual pageview setup” so you can roll back if needed. Click Publish.
GA4 Setup: Marking Key Events
By default, generate_lead shows up in GA4’s Events report but won’t appear in conversion reporting or feed Smart Bidding in Google Ads until you mark it as a Key Event.
In GA4, go to Admin (gear icon, bottom left) > Events. Find generate_lead in the list. Toggle “Mark as key event” on.
In 2024, GA4 renamed “Conversions” to “Key Events”. Key Events appear in the Conversions report, feed Google Ads Smart Bidding when you link accounts, and can be imported as conversion actions in Google Ads.
Do the same for any other conversion events you’re tracking — purchase, book_appointment, or whatever maps to your site’s goals.
If you don’t see an event in the list yet, submit a test conversion on your live site with DebugView open (see the next section) to trigger it, then mark it as a Key Event once it appears.
Verifying Everything Works
Don’t trust the setup until you’ve confirmed it in two places: GTM Preview and GA4 DebugView. They check different things.
GTM Preview Mode is the blue Preview button in your GTM container. It opens your site alongside a debug panel showing every tag that fires, every trigger that matched, and every dataLayer push. Navigate through several pages and confirm:
- gtm.js fires on initial load
- virtual_pageview fires on each subsequent navigation, not on the first page
- Your Key Event (generate_lead or whatever you’ve configured) fires when a conversion completes — not when the user initiates it, but when it succeeds
- Your GA4 Event tags fire in response to the correct triggers
- The Conversion Linker fires on every page (if you’re running Google Ads)
GA4 DebugView is at Admin > DebugView in GA4. This is separate from GTM Preview and essential. GTM Preview only confirms the tags that are fired in your browser. DebugView confirms the events reached GA4. A tag can fire in GTM Preview and still fail to reach GA4 if the Measurement ID is wrong, the GA4 property is misconfigured, or consent mode is blocking it.
To activate DebugView, keep GTM in Preview mode or add ?debug_mode=1 to any URL on your site.
What to look for in DebugView:
- page_view fires once on initial load, then again on each navigation
- generate_lead (or your Key Event) appears when you complete a test conversion
- cta_click and navigation_click show up when you click tracked elements
- virtual_pageview does not appear in DebugView — that’s your internal dataLayer event name, not what gets sent to GA4
If page_view fires twice on the first page, both the GTM trigger and the astro:page-load handler are firing for the initial load. Check the isFirstLoad flag in your analytics module.
For form submission testing: use a real submission on your staging or live site. GTM Preview won’t simulate the success handler unless an actual form post completes. Check DebugView immediately after submitting — the generate_lead event should appear within a few seconds.
Quick Reference
- Add
is:inlineto all GTM and third-party script tags in Astro, or they won’t run astro:page-loadfires on every navigation, including the first — use an isFirstLoad flag to skip the initial event and let GTM’s All Pages trigger handle it- Push virtual_pageview to the dataLayer on all subsequent navigations, then configure a Custom Event trigger in GTM to receive it
- Name the GA4 event page_view so it populates standard reports automatically
- Defer GTM loading until first user interaction to reduce Total Blocking Time (TBT), but include mousemove and a 3-second fallback, or passive desktop readers won’t be tracked
- Partytown moves GTM off the main thread for better Largest Contentful Paint (LCP) and Interaction to Next Paint (INP) scores, but has known issues with Astro View Transitions and Firefox, and prevents GTM tags from accessing the DOM — not recommended for this setup as of early 2026
- Load Clarity, Hotjar, and any session recording tool directly in
<head>, not through GTM - Push conversion events (generate_lead, purchase, etc.) from success handlers, not click or navigation events — if you’re running Google Ads, trigger your conversion tag from the same dataLayer push, not a page URL
- If you’re running Google Ads, verify that the Conversion Linker fires on every page
- Use window.__analyticsInit to register click listeners exactly once across all page transitions
- Mark your Key Events in GA4, so they appear in conversion reporting
- Verify in GA4 DebugView, not just GTM Preview — both need to confirm before the data is reliable
The Astro ecosystem moves fast, and most analytics documentation is still written for traditional server-rendered sites. Getting this right at launch saves a lot of retroactive data cleanup.