Astro and GA4: Why Pageview Tracking Breaks & How to Fix It
We launched the Garrett Digital site on Astro in early 2026. It is 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.
Analytics is where Astro surprised us, and not in the way most write-ups suggest. Astro does not break GA4 tracking. Whether tracking changes at all depends on one choice, and many teams make that choice without thinking about analytics.
Tested on Astro 5.18.1, with the GTM and GA4 interfaces available in August 2026. Astro renamed <ViewTransitions /> to <ClientRouter /> in Astro 5.0 and removed the old component in Astro 6, so projects on Astro 6 and later must use <ClientRouter />.
This post covers how to tell which navigation model your site uses, how to send one pageview per route change without counting it twice, how to track clicks and conversions, which tools to load outside GTM, and how to verify all of it.
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 and then the manual GTM steps.
Does Your Site Use Astro Client-Side Routing?
Start here, because it decides whether the rest of this guide applies to you.
The virtual-pageview setup below is only needed when Astro’s client router is enabled, and it is opt-in. Check your shared layout for this:
---
import { ClientRouter } from 'astro:transitions';
---
<head>
<ClientRouter />
</head>If <ClientRouter /> is absent, Astro uses ordinary browser navigations. Every link loads a new document, GTM initializes again, and a standard Google tag sends a pageview on each load. You do not need the astro:page-load listener in this guide. Adding it will not help, because the event never fires without the router. Skip ahead to Setting Up GTM in Astro.
If <ClientRouter /> is present, Astro intercepts internal links and swaps the page without a conventional document reload. GTM does not reinitialize, so route changes go uncounted unless you send them yourself. Keep reading.
Astro’s own documentation is explicit that <ClientRouter /> is what dispatches the astro:page-load event, on the initial load and on every client-side navigation after it. It is a router lifecycle event, not a default Astro behavior.
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 client router 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.
This is easy to miss, because the landing page tracks normally and only later navigations do not. The check takes a minute. Open GTM Preview, click through two or three internal pages, and watch the event stream. If you see one page_view and nothing after it, your client-router setup needs a route-change event.
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
Use is:inline on vendor snippets that need to be emitted exactly as authored. Astro processes component scripts by default, and for GTM, consent tools and session recorders, preserving the vendor’s snippet keeps your implementation comparable to their documentation.
This is a project-tested safeguard rather than a claim that every Astro GTM setup fails without it. What happened here: the GTM script was wrapped in a template literal, which caused Astro to treat the whole thing as a discarded string expression rather than executable code. GTM appeared to load, threw no errors, and never ran. Adding is:inline and removing the wrapper fixed it. We hit a second version of the same class of problem later, when a head snippet without is:inline had its external src bundled and a required attribute on the tag stopped being read.
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
When Astro’s <ClientRouter /> is enabled, it dispatches astro:page-load after the initial page load and after every client-side navigation. That’s the hook for pushing pageview data to GA4 on each route change.
Before you add this code, check for duplicate pageviews. A single route change can be measured by more than one mechanism, and layering them is the most common way this setup goes wrong:
- GA4 Enhanced Measurement’s page changes based on browser history events
- A GTM History Change trigger
- A manually fired Google tag pageview
- The custom
virtual_pageviewevent below
Pick one, then confirm in GTM Preview and GA4 DebugView that one internal navigation produces exactly one page_view.
This implementation assumes three things: <ClientRouter /> is enabled, your Google tag sends the initial pageview, and you have turned off or accounted for any other history-based pageview tracking.
There’s one catch in the code itself. Because astro:page-load 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 one. 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 whole 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 + window.location.search,
page_title: document.title,
page_location: 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.
The key is page_location, which is GA4’s standard parameter name for the full URL. Keeping the dataLayer key and the GA4 parameter identical removes one place for a mapping to drift. Include the query string in page_path only if those parameters are meaningful reporting dimensions on your site.
If you implemented an earlier version of this guide, it used page_url as the dataLayer key. Either leave your existing GTM Data Layer Variable pointing at page_url, or rename both together. Changing the code without updating the matching GTM variable and GA4 Event tag field gives you an empty parameter and no error.
Delayed GTM Loading and What It Costs
The inline GTM bootstrap runs during parsing, but it creates an async request for gtm.js. The performance question is not whether that file is fetched synchronously. It is the total tag workload that runs once GTM loads, which shows up as Total Blocking Time, the time the main thread spends unable to respond to input.
High TBT is a lab signal that often indicates JavaScript work capable of hurting real-user responsiveness, including Interaction to Next Paint. It is not a direct measure of INP, which is a field metric.
One way to reduce that is to delay GTM until the visitor’s first interaction. When we set up this site, performance tooling flagged GTM as a TBT contributor, so we load it on the first scroll, click, keystroke, touch or mouse movement, whichever comes first, with a 3-second timeout so GTM always loads even if the visitor sits still.
It has a measurement cost, and we did not understand it until we measured. The loader can run immediately before a visitor navigates away. On touch devices there is no mouse movement to trigger it early, so touchstart may fire only moments before the browser follows a link. In our testing, a queued dataLayer.push() did not always result in the tag firing before the document unloaded. The event reaches the dataLayer and GTM never gets to evaluate its trigger.
That means delayed loading can undercount short sessions and fast click-to-conversion paths. Decide deliberately:
| Situation | Recommended approach |
|---|---|
| Organic content site, analytics can be delayed | Consider delaying, after testing |
| Google Ads or other paid landing page | Load measurement normally |
| Form, scheduler, phone or chat conversion can happen quickly | Do not defer conversion-critical tags |
| Session recording is not essential | Delay that tool on its own, not the whole container |
| Poor Core Web Vitals caused by tags | Audit tags and firing rules before deferring the container |
Before reaching for this, the safer first move is usually to remove tags you no longer use, narrow the firing rules on the ones you keep, and make sure conversion tracking sits on the confirmed business action rather than on a button click.
Here is the loader we use:
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 introduced two problems for this setup. First, we found the combination of Partytown and Astro’s client router unreliable in our own testing, particularly around fallback navigation. Treat it as an integration that needs browser-by-browser QA before production rather than something you can assume works.
Browser support has moved since we first looked at this, so check it yourself rather than trusting a blanket claim. Firefox added same-document view transitions in version 144, released October 2025, which is the kind Astro’s client router uses. Cross-document transitions remain a separate and less uniform capability.
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 tag setups: a GA4 tag, maybe an ad pixel, nothing that needs DOM access. If you’re not using client-side routing heavily and your GTM container is minimal, it’s worth testing.
For this site’s tag mix, which covers client-side click tracking, lead events and session recording, we chose delayed loading over Partytown after testing the tradeoffs. Your result may differ, and both the library and Astro’s router have changed since. Test before adopting either.
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.
One caution before you import them. Not every form submission is worth the same to the business, and bidding optimizes toward whatever you tell it counts. Where you can, import qualified or closed-won outcomes from your CRM alongside the form event, so bidding learns from lead quality rather than lead volume.
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.
Version note: this implementation was tested on Astro 5.18.1 in August 2026. Check Astro’s upgrade guides before applying it to Astro 6 or later, where <ViewTransitions /> no longer exists and <ClientRouter /> is the only supported component name.