These two setups are where most broken GA4 implementations live: doubled page views, self-referrals and split sessions.
Single-page applications change the URL with the History API, so the Google tag's initial page_view fires once and then nothing. Two fixes:
- Enhanced measurement's Page changes based on browser history events fires
page_viewonpushState/replaceState/popstate. It is easy but naive: it fires on query-string-only changes, andpage_titleis read immediately, before your framework updates<title>. - Set
send_page_view: falsein the config and sendpage_viewyourself from the router's navigation hook, after the title and content are ready. Sendpage_location,page_titleandpage_referrerexplicitly; the referrer must be the previous virtual page, notdocument.referrer, or every view appears to come from the entry page.
Never do both or every route change is counted twice.
Cross-domain tracking is needed when one journey spans domains (example.com to checkout.example-pay.com). Cookies are per domain, so the second domain would start a new user and a new session whose source is the first domain: a self-referral. Fix it in Admin > Data streams > stream > Configure tag settings > Configure your domains: list every domain. The tag then appends a _gl linker parameter to links and form posts between those domains, and the receiving page reads it to adopt the same client ID and session.
Checklist for cross-domain: all domains in the list, the same measurement ID on all of them, links are real <a>/<form> elements (JS-built navigation needs gtag('get', ...) to read the client ID and append _gl yourself), and the second domain is also in List unwanted referrals so it never counts as a source. Subdomains of one registered domain share cookies and need none of this.
Example
// gtag('config', 'G-ABC123XYZ', { send_page_view: false }) in the snippet, then:
let previousUrl = document.referrer; // first virtual view keeps the real referrer
function trackVirtualPage() {
const url = location.href;
gtag('event', 'page_view', {
page_location: url,
page_title: document.title, // call after the router has set the title
page_referrer: previousUrl
});
previousUrl = url;
}
// Example with a generic router "afterEach" hook (React Router, Vue Router, etc.)
router.afterEach(() => {
// wait a tick so <title> and content are updated
setTimeout(trackVirtualPage, 0);
});
trackVirtualPage(); // the initial loadRemember
- SPA: either the history-change switch or manual page_view with send_page_view:false, never both
- Send page_referrer as the previous virtual page
- Cross-domain: list all domains in tag settings; the _gl linker carries the client ID
Watch out for
- Keeping the enhanced-measurement history switch on while also sending page_view manually.