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ã.
- Ative as variáveis incorporadas de History (New/Old URL Fragment, State, History Source, etc.).
- Trigger Alteração no Histórico → Algumas alterações →
History SourceequalspushState(filtra ruído de estado que não é navegação real). - Google Tag de atualização (sem trigger próprio): nas configurações,
page_location→{{Page URL}},page_title→{{Page Title}}, e a chaveupdatecom valortrue. Isso mescla params na tag sem forçar um page_view solto indesejado. Não mandepage_referrernas updates: o GA4 usa a página virtual anterior como referrer interno. - 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
- 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. - 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 paraproduct_id,form_id, etc., se forem chaves soltas no modelo. - Se precisar wrappear
pushpara 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)
- Variáveis de Camada de Dados para
page_location,page_titleepage_path(nomes exatos do push). - Trigger Evento personalizado =
virtual_page_view. - Google Tag de atualização:
update=true, mapeandopage_locationepage_titlepara as DLVs. Sem trigger próprio. - Tag GA4 Event
page_view: sequenciamento com a Google Tag de update antes; trigger = Custom Eventvirtual_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
- Preview no GTM (Tag Assistant) no ambiente da SPA. Navegue: cada troca de rota deve mostrar
virtual_page_viewno timeline. Método: Debugging com Tag Assistant. - No mesmo evento: DLVs de
page_locationepage_titlebatem com a tela atual — sem sobra da rota anterior. - DebugView do GA4: exatamente um
page_viewpor 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.