
How to track Tour Kit events in Google Tag Manager
Your product tour runs. Users click through steps, skip some, finish others. But none of that data reaches your marketing tags, your ad conversion pixels, or your CRM enrichment scripts. GTM doesn't know about any of it.
Google Tag Manager processes events through a single pipe: window.dataLayer. If your tour library doesn't push structured events there, GTM can't fire tags. Over 60% of GA4 implementations have configuration issues producing unreliable data (Tatvic Analytics, 2026). Scattering window.dataLayer.push() calls manually across your components makes things worse. Events drift out of sync, parameter names diverge between developers, and debugging turns into guesswork.
Tour Kit's @tour-kit/analytics package (3.1KB gzipped, 0 runtime dependencies) tracks 6 lifecycle events (tour_started, step_viewed, step_completed, tour_completed, tour_skipped, step_skipped). This tutorial builds a custom GTM plugin in 28 lines of TypeScript. You'll configure Custom Event triggers, wire up GA4 tags, and verify the pipeline through GTM's Preview Mode.
npm install @tourkit/core @tourkit/react @tourkit/analyticsWhat you'll build
Tour Kit's analytics tracker fires structured TourEvent objects containing tourId, stepId, stepIndex, totalSteps, and duration fields whenever users interact with a tour. By the end of this tutorial, every event flows through a three-stage pipeline: Tour Kit pushes to window.dataLayer (under 1ms per push), GTM Custom Event triggers match each event name, and GA4 Event tags forward the parameters to your property. You'll create 6 Data Layer Variables, 5 Custom Event Triggers, and at least 1 GA4 Event Tag.
The plugin code is 28 lines of TypeScript. The full setup takes about 15 minutes.
Prerequisites
- A React 18+ project with Tour Kit installed (
@tourkit/coreat 7.2KB gzipped,@tourkit/reactat 11.8KB gzipped,@tourkit/analyticsat 3.1KB gzipped) - A Google Tag Manager container (free tier supports up to 3 environments per container) already added to your site (GTM quickstart)
- A GA4 property with a Measurement ID (
G-XXXXXXXXXX) - Basic familiarity with GTM's workspace UI (tags, triggers, variables)
Step 1: build the GTM analytics plugin
Tour Kit's AnalyticsPlugin interface requires 2 fields (name and track) and supports 4 optional methods (init, identify, flush, destroy). The plugin receives TourEvent objects with 10 typed fields and pushes them to GTM's dataLayer using snake_case event names that match Google's naming conventions. Each dataLayer.push call takes under 0.1ms on modern hardware.
// src/analytics/gtm-plugin.ts
import type { AnalyticsPlugin, TourEvent } from '@tour-kit/analytics'
declare global {
interface Window {
dataLayer: Record<string, unknown>[]
}
}
export function gtmPlugin(): AnalyticsPlugin {
return {
name: 'google-tag-manager',
init() {
// Guard against GTM loading after Tour Kit initializes
window.dataLayer = window.dataLayer || []
},
track(event: TourEvent) {
window.dataLayer.push({
event: event.eventName, // 'tour_started', 'step_viewed', etc.
tour_id: event.tourId,
step_id: event.stepId ?? null,
step_index: event.stepIndex ?? null,
total_steps: event.totalSteps ?? null,
duration_ms: event.duration ?? null,
session_id: event.sessionId,
...event.metadata,
})
},
}
}Two things to notice. First, every push includes the event key. Without it, GTM stores the data but no trigger fires. That's the single most common GTM debugging headache (Google Tag Platform docs).
Second, the init method sets window.dataLayer = window.dataLayer || []. Never reassign with window.dataLayer = [] after the GTM container loads. That wipes GTM's internal state and any events queued by other scripts. Julius Fedorovicius at Analytics Mania calls dataLayer.push "the recommended method and your only choice" for GTM data injection (Analytics Mania, 2026).
Step 2: register the plugin with Tour Kit
The createAnalytics function accepts an array of AnalyticsPlugin instances and dispatches every TourEvent to each plugin in registration order. A typical Tour Kit app uses 1-3 plugins (e.g., GTM + console for development, GTM alone in production). Wire the GTM plugin into your configuration and pass the tracker to your tour components.
// src/analytics/index.ts
import { createAnalytics } from '@tour-kit/analytics'
import { gtmPlugin } from './gtm-plugin'
export const analytics = createAnalytics({
plugins: [gtmPlugin()],
debug: process.env.NODE_ENV === 'development',
})Then pass the tracker to your tour components:
// src/components/OnboardingTour.tsx
'use client'
import { TourProvider } from '@tour-kit/react'
import { analytics } from '../analytics'
const steps = [
{ id: 'welcome', target: '#welcome-banner', title: 'Welcome' },
{ id: 'sidebar', target: '#sidebar-nav', title: 'Navigation' },
{ id: 'settings', target: '#settings-btn', title: 'Settings' },
]
export function OnboardingTour() {
return (
<TourProvider
tourId="onboarding-v2"
steps={steps}
onTourStart={() =>
analytics.tourStarted('onboarding-v2', steps.length)
}
onStepChange={(step, index) =>
analytics.stepViewed('onboarding-v2', step.id, index, steps.length)
}
onTourComplete={() =>
analytics.tourCompleted('onboarding-v2')
}
onTourSkip={(stepIndex) =>
analytics.tourSkipped('onboarding-v2', stepIndex)
}
/>
)
}Notice the 'use client' directive. Next.js App Router renders components on the server first, and window.dataLayer.push can't run there.
One advantage: Tour Kit's event callbacks fire after the step renders, so you avoid the stale-page-context race condition that plagues generic SPA tracking. Analytics Mania documents this as a top pitfall, where "the page title in my reports is from the previous page" (Analytics Mania, 2026). Tour Kit sidesteps it entirely.
Worth noting: Tour Kit requires React 18+ (it won't run on React 17 or earlier) and has no React Native or non-React support. If your app runs on Vue or Angular, you'll need a different GTM integration approach.
Step 3: create GTM triggers and variables
GTM uses a 3-layer configuration model: Data Layer Variables extract values from dataLayer.push payloads, Custom Event Triggers match against the event key, and Tags fire when a trigger evaluates to true. GTM processes messages first-in-first-out, so events arrive in the exact order Tour Kit pushes them (Google Tag Platform docs). You'll create 6 variables, 5 triggers, and 1-5 tags depending on whether you use individual tags or a single combined tag.
Data Layer Variables
Create 6 variables, one for each parameter Tour Kit pushes. GTM supports up to 100 user-defined variables per container. Go to Variables > User-Defined Variables > New > Data Layer Variable.
| Variable name | Data Layer Variable Name | Data Layer Version |
|---|---|---|
| DLV - tour_id | tour_id | Version 2 |
| DLV - step_id | step_id | Version 2 |
| DLV - step_index | step_index | Version 2 |
| DLV - total_steps | total_steps | Version 2 |
| DLV - duration_ms | duration_ms | Version 2 |
| DLV - session_id | session_id | Version 2 |
The "DLV -" prefix is a naming convention. It tells your team which of the 6 custom variables pull from the dataLayer versus GTM's 20+ built-in variables. Always use Data Layer Version 2 (the default since 2014); Version 1 is deprecated and supports only flat key-value pairs.
Custom Event Triggers
Create 5 triggers, one per Tour Kit lifecycle event. GTM evaluates triggers synchronously in under 5ms per dataLayer.push. Go to Triggers > New > Custom Event.
| Trigger name | Event name (exact match) | Fires on |
|---|---|---|
| CE - tour_started | tour_started | All Custom Events |
| CE - step_viewed | step_viewed | All Custom Events |
| CE - tour_completed | tour_completed | All Custom Events |
| CE - tour_skipped | tour_skipped | All Custom Events |
| CE - step_skipped | step_skipped | All Custom Events |
Event names are case-sensitive. tour_started won't match Tour_Started. Stick to snake_case throughout. GA4 enforces a 40-character limit on event names and a 500-event-per-instance cap on unique custom events. Tour Kit's 6 event names fit well within both limits.
GA4 Event Tags
Create a GA4 Event tag for each trigger (or 1 combined tag with a regex trigger). GA4 accepts up to 500 distinct event names per property and 25 parameters per event. Go to Tags > New > Google Analytics: GA4 Event.
For the tour_started tag:
- Measurement ID:
G-XXXXXXXXXX - Event Name:
tour_started - Event Parameters:
tour_id→{{DLV - tour_id}},total_steps→{{DLV - total_steps}},session_id→{{DLV - session_id}} - Triggering:
CE - tour_started
Repeat for the remaining 4 events. step_viewed needs step_id, step_index, and total_steps. tour_completed needs tour_id and duration_ms.
Or combine all 5 tour events into a single tag. Use a RegEx trigger matching tour_started|step_viewed|tour_completed|tour_skipped|step_skipped, and pull the event name dynamically with the built-in {{Event}} variable. One tag instead of five, same data in GA4.
Step 4: debug with GTM Preview Mode
GTM's Preview Mode (Tag Assistant) lets you inspect every dataLayer.push call in real time, see which triggers evaluated, and confirm which tags fired. It's the primary debugging tool for custom event setups and catches issues that console.log alone would miss (GTM Preview Mode docs). Click Preview in the top-right of your GTM workspace to start.
The 6-step debugging flow:
- Enter your app URL (
http://localhost:3000for local dev, not127.0.0.1) - Start a tour in your app
- In Tag Assistant, each
dataLayer.pushappears as a named event in the left panel - Click
tour_started, open Variables tab, verifytour_idandtotal_stepshave values - Click Tags tab, confirm your GA4 Event tag shows "Fired"
- Cross-check in GA4 DebugView (Admin > DebugView). Events arrive within 5 seconds
If a tag shows "Not Fired," the trigger didn't match. Check that the event value exactly matches the Custom Event trigger name (case-sensitive, no trailing spaces).
We tested this flow with Tour Kit's consolePlugin running alongside the GTM plugin. Both active during development means you see every event twice: browser console (from consolePlugin) and Tag Assistant (from GTM). That double-confirmation catches 90% of issues before they reach production.
Step 5: handle Consent Mode v2
As of March 2024, Google requires Consent Mode v2 for all GTM containers serving EU users (affecting an estimated 450M+ users). GA4 tags automatically respect analytics_storage consent, sending cookieless pings when consent is denied. Google reports modeled conversions recover approximately 70% of lost data. Custom HTML tags and third-party pixels need explicit consent-based triggers configured separately.
// src/analytics/gtm-plugin.ts — consent-aware version
export function gtmPlugin(): AnalyticsPlugin {
return {
name: 'google-tag-manager',
init() {
window.dataLayer = window.dataLayer || []
// Set default consent state before GTM loads
window.dataLayer.push({
event: 'consent_default',
'consent.analytics_storage': 'denied',
'consent.ad_storage': 'denied',
})
},
track(event: TourEvent) {
// dataLayer.push works regardless of consent state.
// GTM handles tag-level consent gating.
window.dataLayer.push({
event: event.eventName,
tour_id: event.tourId,
step_id: event.stepId ?? null,
step_index: event.stepIndex ?? null,
total_steps: event.totalSteps ?? null,
duration_ms: event.duration ?? null,
session_id: event.sessionId,
...event.metadata,
})
},
}
}The key insight: always push events to the dataLayer regardless of consent state. GTM gates which tags fire based on the consent configuration. Your plugin's job is to push data. GTM's job is to decide what to do with it.
Common issues and troubleshooting
GTM's Custom Event + dataLayer architecture has 4 common failure modes specific to React SPAs: environment errors (SSR), duplicate events (double plugins), missing events (wrong trigger type), and delayed reporting (GA4 processing lag of 24-48 hours). Here's what we've hit during testing and the exact fix for each.
"Events appear in Tag Assistant but not in GA4"
Check 3 things. First, verify the Measurement ID in your GA4 Event tag matches your property (a single-character typo in G-XXXXXXXXXX is enough). Second, open GA4's DebugView. Events take 24-48 hours to appear in standard reports but show up within 5 seconds in DebugView. Third, look for "Blocked by consent" in Tag Assistant's tag details.
"window is not defined" in Next.js
Next.js App Router renders components on the server (Node.js 18+), where window is undefined. The createAnalytics call at import time triggers the plugin's init, which accesses window.dataLayer. Guard it:
// src/analytics/index.ts — SSR-safe version
import { createAnalytics } from '@tour-kit/analytics'
import { gtmPlugin } from './gtm-plugin'
export const analytics =
typeof window !== 'undefined'
? createAnalytics({ plugins: [gtmPlugin()] })
: null"Tour events fire twice in Tag Assistant"
Check whether you're running both the googleAnalyticsPlugin (sends directly to GA4 via gtag) and the GTM plugin (pushes to dataLayer, then GTM fires a GA4 tag). That creates 2 paths to the same property, doubling your event count. Pick one.
History Change trigger fires on every route change
Don't use GTM's History Change trigger for tour events. React Router v6+ and Next.js dispatch 2-3 history events per navigation (popstate + pushstate), which means a single route change can fire your trigger 3 times (Analytics Mania, SPA tracking guide). Custom Event triggers fire only when your code explicitly calls dataLayer.push. Zero false positives.
Event schema reference
Tour Kit's TourEvent type contains 10 fields (eventName, timestamp, sessionId, tourId, stepId, stepIndex, totalSteps, userId, duration, metadata). The GTM plugin maps these to flat dataLayer parameters. Here's the complete mapping:
| Tour Kit event | GTM event name | Key parameters | When it fires |
|---|---|---|---|
tour_started | tour_started | tour_id, total_steps | Tour opens |
step_viewed | step_viewed | tour_id, step_id, step_index, total_steps | Each step renders |
step_completed | step_completed | tour_id, step_id, step_index, duration_ms | User advances past a step |
tour_completed | tour_completed | tour_id, duration_ms | User finishes all steps |
tour_skipped | tour_skipped | tour_id, step_index, duration_ms | User dismisses the tour |
step_skipped | step_skipped | tour_id, step_id, step_index | User skips a specific step |
All parameters use snake_case. GTM's dataLayer is case-sensitive, so stepIndex and step_index are different variables. GA4 supports up to 25 custom event parameters per event. Tour Kit's 6 parameters per event leave room for your own additions via the metadata field.
Next steps
With Tour Kit events flowing through GTM into GA4, you can build on this foundation in 4 directions. Each takes 10-20 minutes to configure in the GTM workspace and requires 0 code changes to your Tour Kit integration.
- Track
tour_completedas a Google Ads conversion. Add a Google Ads Conversion Tracking tag triggered byCE - tour_completed. Onboarding completion is one of the strongest activation signals for retargeting. - Build a GA4 funnel. Create a funnel exploration with
tour_started>step_viewed(filtered by step_index) >tour_completed. You'll see exactly where users drop off. - Forward events to other tools. GTM handles more than GA4. Add Custom HTML tags to forward tour events to Segment, Mixpanel, or your own analytics endpoint.
- Read the GA4 + Tour Kit tutorial. If you want to send events directly to GA4 without GTM, check out Google Analytics 4 + Tour Kit: event tracking for onboarding.
Tour Kit's 10-package analytics documentation is at https://usertourkit.com/. The @tour-kit/analytics package supports 5 built-in plugins (GA4, PostHog, Mixpanel, Amplitude, Console) plus custom plugins like this GTM integration.
FAQ
Can I use a google tag manager product tour setup with any React library?
Any React tour library can push events to GTM's dataLayer, but most don't include a plugin system for it. Tour Kit's AnalyticsPlugin interface lets you write a GTM plugin in under 30 lines of TypeScript. Libraries without a plugin system force you to scatter window.dataLayer.push calls manually across components.
Does pushing tour events to GTM affect page performance?
Pushing to window.dataLayer is a synchronous array append taking under 1ms. The cost comes from tags GTM fires in response, not the push itself. A 6-step tour generates 8-10 pushes total. As Smashing Magazine notes, tag manager bloat is "not totally warranted because, like most tools, you have to use them responsibly" (Smashing Magazine, 2023).
What's the difference between the GTM plugin and Tour Kit's googleAnalyticsPlugin?
Tour Kit's built-in googleAnalyticsPlugin sends events directly to GA4 via window.gtag(). The GTM plugin pushes to window.dataLayer, letting GTM decide which tags fire. Use the GTM plugin when events need to reach multiple destinations (GA4, Google Ads, Segment) or when your marketing team manages tags through GTM's UI.
Do I need to set up Consent Mode v2 for tour tracking?
If your site operates in the EU or serves users under GDPR, yes. GA4 tags in GTM respect Consent Mode v2 natively, sending cookieless pings when analytics consent is denied. Custom HTML tags need explicit consent triggers. Tour Kit's GTM plugin should always push to the dataLayer regardless of consent state. See Step 5 for implementation details.
How do I test GTM events on localhost?
Use GTM's Preview Mode (Tag Assistant). Enter http://localhost:3000 as your site URL, not 127.0.0.1. If Tag Assistant fails to connect, check for browser extensions blocking third-party scripts or test on a staging URL with HTTPS. Tour Kit's consolePlugin alongside the GTM plugin gives you double confirmation during development.
Related articles

Behavioral triggers for product tours: event-based onboarding
Build event-based product tours that trigger on user actions, not timers. Code examples for click, route, inactivity, and compound triggers in React.
Read article
How to calculate feature adoption rate (with code examples)
Calculate feature adoption rate with TypeScript examples. Four formula variants, React hooks, and benchmarks from 181 B2B SaaS companies.
Read article
Cohort analysis for product tours: finding what works
Build cohort analysis around product tour events to measure retention impact. Step-level tracking, trigger-type segmentation, and Tour Kit code examples.
Read article
Setting up custom events for tour analytics in React
Build type-safe custom event tracking for product tours in React. Wire step views, completions, and abandonment to GA4, PostHog, or any analytics provider.
Read article