Como Taguear Single Page Applications (SPA) React e Vue.js no GTM

Pageview virtual em React e Vue: virtual_page_view no router, History Change com ressalvas e Enhanced Measurement sem duplicar no GA4.

Contexto

Mídia olha o funil e vê tráfego na home. Checkout, pricing e settings “não existem”. No site React ou Vue, o usuário navegou por trinta telas — o GA4 registrou uma visualização de página.

Em site clássico, cada clique full-reload reinicia o ciclo do GTM: Container Loaded, DOM Ready, Window Loaded e a tag de page_view dispara de novo. Em SPA, o HTML carrega uma vez. Rotas mudam com pushState / replaceState / popstate. O documento não recarrega. Os triggers de load não voltam a rodar.

Este guia é o contrato mínimo de mensuração de navegação virtual: o que quebra, as três abordagens possíveis e a arquitetura que funciona em produção — virtual_page_view no router depois do DOM, com Enhanced Measurement de histórico desligado.

Problema

Sintoma Causa típica
Só a 1ª URL aparece no GA4 Page_view amarrado a load; ninguém emite na troca de rota
page_title da tela anterior History Change disparou antes do React/Vue atualizar o <title>
Pageview em dobro em cada clique de menu Enhanced Measurement “histórico” e tag manual no GTM
Produto / ID “fantasma” entre telas Modelo do GTM reusa params da rota A na rota B
Origem de campanha “reinicia” no meio da sessão UTM / gclid reenviados em page_location de rotas internas
Preview sem eventos nas navegações App não faz push; só carrega GTM no boot

A mídia reclama de atribuição. O QA de front diz “o app funciona”. Os dois estão certos — e o pageview virtual não existe.

Por que a SPA some do GTM

O container GTM sobe no primeiro paint e anexa listeners. Os gatilhos nativos de carregamento (gtm.js, gtm.dom, gtm.load) disparam só nesse momento. Trocas de rota no React Router ou Vue Router atualizam a barra de endereço e o DOM sem novo documento — e sem novo ciclo de load.

O array dataLayer e o modelo de dados interno do GTM também permanecem. Qualquer chave empurrada na página A continua no modelo até ser sobrescrita ou anulada. Em multi-page clássico o reload limpa a memória. Em SPA, você limpa — ou contamina métricas.

Detalhe de ecommerce e null entre eventos: Data Layer para devs.

Três abordagens (e qual escolher)

Critério Medição otimizada (history no GA4) History Change no GTM dataLayer.push no router
Esforço de eng Só config no GA4 Só GTM Hook no React / afterEach no Vue
Sync com DOM / title Fraca Moderada (race frequente) Boa (dispara após render)
Metadados ricos Quase só URL Variáveis de history + DOM Payload JSON à vontade
Consent / CMP Pode furar em configs ruins Tags no GTM respeitam Consent Mode Mesmo: tags subordinadas ao consent
Risco de duplicata Alto se coexiste com tag manual Controlável se EM history off Baixo se só este caminho emite page_view

Recomendado: evento personalizado na Data Layer no ciclo do roteador. History Change serve de atalho sem PR — saiba que o title pode sair errado. Enhanced Measurement de history sozinho é o pior dos dois mundos quando você também tem page_view no GTM.

Pré-requisito: desligar history no GA4

Se o GTM (ou o app) for responsável pelas visualizações virtuais, a medição otimizada não pode continuar emitindo page_view em eventos de histórico do browser.

No GA4: Admin → Fluxos de dados → fluxo web → Medição otimizada → engrenagem de visualizações de página → desmarque Alterações de páginas com base em eventos de histórico do browser.

Sem isso: um clique de rota → dois page_view. Funil e engajamento mentem para cima.

Caminho GTM-only: History Change

Útil quando eng ainda não liberou o push e você precisa de algo amanhã.

  1. Ative as variáveis incorporadas de History (New/Old URL Fragment, State, History Source, etc.).
  2. Trigger Alteração no HistóricoAlgumas alteraçõesHistory Source equals pushState (filtra ruído de estado que não é navegação real).
  3. Google Tag de atualização (sem trigger próprio): nas configurações, page_location{{Page URL}}, page_title{{Page Title}}, e a chave update com valor true. Isso mescla params na tag sem forçar um page_view solto indesejado. Não mande page_referrer nas updates: o GA4 usa a página virtual anterior como referrer interno.
  4. Tag GA4 Event, nome do evento page_view, sequenciamento: dispara a Google Tag de atualização antes; trigger = History Change.

O limite que importa

No pushState, a URL muda no instante. O <title> e o conteúdo do component muitas vezes ainda são da tela anterior (fetch, suspense, guards). A tag que lê {{Page Title}} naquele milissegundo captura o título antigo. setTimeout(500) “para resolver” falha em rede lenta e em clique rápido entre rotas.

História desse race e táticas de delay: o mercado discute muito; em app sério, a solução estável é o app avisar quando o DOM da rota nova está pronto.

Autonomia de mídia sem código tem teto — o guia de eventos sem PR já aponta isso. SPA de verdade fecha no router.

Caminho recomendado: Data Layer no router

A app emite virtual_page_view depois do paint da rota. GTM só reage a um Custom Event. Uma fonte de verdade. Título e path batem com a tela do usuário.

Payload mínimo

window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
  event: 'virtual_page_view',
  page_location: window.location.href,
  page_path: location.pathname + location.search, // ou fullPath no Vue
  page_title: document.title
});

Gancho opcional: route_name, user_status, app_section — o que marketing pediu no contrato, tipado e estável.

React (React Router v6)

Centralize num hook e monte no layout raiz (<App /> / shell autenticado), cobrindo todas as rotas filhas:

import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

export function useGtmPageTracking() {
  const location = useLocation();

  useEffect(() => {
    const timer = setTimeout(() => {
      window.dataLayer = window.dataLayer || [];
      window.dataLayer.push({
        event: 'virtual_page_view',
        page_location: window.location.href,
        page_path: location.pathname + location.search,
        page_title: document.title
      });
    }, 0);

    return () => clearTimeout(timer);
  }, [location]);
}

O setTimeout(0) empurra o push para depois do commit do React naquele ciclo — ainda dependente de você garantir que document.title (react-helmet, middleware de rota, etc.) já foi atualizado. Se o title é assíncrono com a API do produto, dispare o push depois desse update, não só no change de location.

Vue.js (Vue Router 3 / 4)

import router from './router';
import { nextTick } from 'vue';

router.afterEach((to) => {
  nextTick(() => {
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'virtual_page_view',
      page_location: window.location.href,
      page_path: to.fullPath,
      page_title: document.title,
      route_name: to.name || 'unnamed_route'
    });
  });
});

nextTick espera a árvore Vue refletir no DOM. Mesma ressalva: se o title depende de fetch na página de detalhe, emita o page_view quando o title final existir (watch do recurso, não só afterEach cego).

Regras de ouro na Data Layer

  1. Nunca window.dataLayer = [] (nem = [{...}] no meio da sessão). Isso troca a referência do array e mata o listener do GTM. Sempre: window.dataLayer = window.dataLayer || []; + push.
  2. Limpe estado contextual que não vale na próxima rota — no ecommerce, dataLayer.push({ ecommerce: null }) antes do próximo evento (guia Data Layer). O mesmo vale para product_id, form_id, etc., se forem chaves soltas no modelo.
  3. Se precisar wrappear push para log/auditoria, guarde a função original e chame com .apply — senão o GTM para de ouvir.
(function () {
  window.dataLayer = window.dataLayer || [];
  var originalPush = window.dataLayer.push;
  window.dataLayer.push = function () {
    // auditoria opcional
    return originalPush.apply(this, arguments);
  };
})();

Configuração no GTM (após o push do app)

  1. Variáveis de Camada de Dados para page_location, page_title e page_path (nomes exatos do push).
  2. Trigger Evento personalizado = virtual_page_view.
  3. Google Tag de atualização: update = true, mapeando page_location e page_title para as DLVs. Sem trigger próprio.
  4. Tag GA4 Event page_view: sequenciamento com a Google Tag de update antes; trigger = Custom Event virtual_page_view.

1º load da SPA: a Google Tag “padrão” no All Pages / configuração inicial ainda cobre a entrada. As navegações seguintes vivem no virtual_page_view. Se você também disparar virtual_page_view no mount da home, acerte com o time para não duplicar a entrada — ou filtre o primeiro push.

Atribuição e consentimento

Campanha: parâmetros UTM / gclid de entrada devem viver no primeiro load. Em pageviews virtuais, page_location limpo (sem reempilhar query de campanha). Reenviar UTM em cada rota quebra sessão e “nova campanha” no meio do funil.

Consent: tags que disparam no Custom Event passam pelo Consent Mode do container. Medição automática de history no GA4, em várias implantações com CMP, é mais fácil de parecer “solta” do banner. Página virtual controlada no GTM fica mais alinhada a analytics_storage granted/denied — e ao fluxo de Consent Mode v2.

Validação

  1. Preview no GTM (Tag Assistant) no ambiente da SPA. Navegue: cada troca de rota deve mostrar virtual_page_view no timeline. Método: Debugging com Tag Assistant.
  2. No mesmo evento: DLVs de page_location e page_title batem com a tela atual — sem sobra da rota anterior.
  3. DebugView do GA4: exatamente um page_view por transição (EM history off; um único caminho emitindo).

Checklist

# Verificação Feito quando…
1 EM history Opção de histórico do browser desligada se GTM manda page_view
2 Fonte de pageview Só Enhanced ou só GTM/Data Layer — não os dois
3 Router React hook / Vue afterEach emitem após title/DOM da rota
4 Payload event + location + path + title; sem reatribuir o array
5 Estado Params e ecommerce limpos entre telas
6 Atribuição UTM/gclid não reaparecem em page_location interno
7 Consent Tags page_view respeitam storage no Preview
8 QA Timeline + DebugView: 1 page_view por rota

Próximo passo

Pageview virtual estável é o piso. Funil de produto (signup, trial, checkout steps) pede o mesmo padrão: evento de negócio no dataLayer, trigger no GTM, zero scrape de seletor CSS que morre no redesign.

Se a SPA já tem dezenas de rotas, title assíncrono, ecommerce e ninguém mapeou “load vs virtual vs conversão”, o trabalho deixa de ser um hook e vira contrato de instrumentação — quem emite o quê, em qual lifecycle, e critérios de QA no PR.

Se quiser, partimos do seu React Router ou Vue Router e fechamos o mapa: entrada + rotas críticas + o que o GTM não deve mais tentar adivinhar sozinho.