
Setting up custom events for tour analytics in React
Tour Kit ships 17 built-in event types: tour_started, step_viewed, hint_clicked, and so on. They cover the tour lifecycle. But they don't tell you whether a user clicked the upgrade CTA on step 3, watched the embedded video to 80% completion, or submitted the in-tour form that was the whole point of the onboarding flow.
That's where custom events come in. Tour Kit's metadata field and plugin architecture let you track anything that happens during a tour step, using the same pipeline that handles lifecycle events. No separate analytics calls scattered across your components. One pipeline, typed end-to-end, routing to GA4, PostHog, Mixpanel, or your own backend.
By the end of this tutorial, you'll have a typed custom event system that tracks business-specific interactions during product tours and routes them to any analytics provider. We tested this pattern across PostHog, Mixpanel, and GA4, and the same AnalyticsPlugin interface handles all three without code changes.
npm install @tourkit/core @tourkit/react @tourkit/analyticsWhat you'll build
Tour Kit's analytics layer has three pieces: a TourAnalytics tracker that emits structured events with timestamps and session IDs, an AnalyticsPlugin interface with just 5 methods (only track is required), and an AnalyticsProvider component that wires it all together in React's component tree. This tutorial extends each piece with typed custom events that capture your app's specific user interactions.
You'll build four things:
- A typed event metadata schema for your app's custom interactions
- Custom event helper functions that wrap
track()with type safety - A custom analytics plugin that transforms tour events for your backend
- A React hook pattern that fires custom events from inside tour step components
The total addition to your bundle is zero bytes. These are TypeScript types and thin wrapper functions over Tour Kit's existing track() method. No new runtime dependencies.
Prerequisites
- React 18.2+ or React 19
- TypeScript 5.0+
@tourkit/core,@tourkit/react, and@tourkit/analyticsinstalled- An analytics backend (PostHog, Mixpanel, GA4, or any custom service)
Step 1: understand Tour Kit's event structure
Tour Kit's TourEvent type carries a metadata field typed as Record<string, unknown> that accepts any key-value data you want to attach to an event. Every built-in event type includes this field. The step_interaction event type exists specifically for tracking user actions within a step that aren't step navigation, like CTA clicks, video plays, or form submissions.
// src/types/analytics.ts
import type { TourEvent, TourEventName } from '@tourkit/analytics'
// Tour Kit's 17 built-in event names:
// Tour lifecycle: tour_started, tour_completed, tour_skipped, tour_abandoned
// Step tracking: step_viewed, step_completed, step_skipped, step_interaction
// Hints: hint_shown, hint_dismissed, hint_clicked
// Adoption: feature_used, feature_adopted, feature_churned
// Nudges: nudge_shown, nudge_clicked, nudge_dismissed
// The metadata field is your extension point for custom data:
type EventMetadata = TourEvent['metadata']
// => Record<string, unknown> | undefinedThe step_interaction event is the important one here. When a user clicks a CTA, plays a video, or expands a details panel during a tour step, step_interaction captures it with an interactionType string in the metadata. That string is untyped by default. Step 2 fixes that.
Step 2: define typed custom event metadata
Raw Record<string, unknown> loses type safety fast. Define a discriminated union for your app's custom interactions so that each interaction type gets its own validated metadata shape. TypeScript catches malformed events at compile time, not after they've polluted your analytics dashboard.
// src/types/tour-events.ts
/** Interaction types your app tracks during tour steps */
export type CustomInteractionType =
| 'cta_click'
| 'video_play'
| 'video_complete'
| 'form_submit'
| 'link_click'
| 'toggle_expand'
| 'feature_preview'
/** Metadata schemas per interaction type */
export interface CtaClickMeta {
interactionType: 'cta_click'
ctaId: string
ctaLabel: string
destination?: string
}
export interface VideoMeta {
interactionType: 'video_play' | 'video_complete'
videoId: string
videoDuration: number
watchedPercent?: number
}
export interface FormSubmitMeta {
interactionType: 'form_submit'
formId: string
fieldCount: number
success: boolean
}
export interface LinkClickMeta {
interactionType: 'link_click'
href: string
label: string
external: boolean
}
/** Union of all custom metadata types */
export type CustomEventMeta =
| CtaClickMeta
| VideoMeta
| FormSubmitMeta
| LinkClickMetaThis gives you autocomplete and compile-time checks. If someone passes { interactionType: 'cta_click' } without a ctaId, TypeScript catches it. Seven interaction types cover the most common in-tour actions, but adding more is just another interface plus a union member.
Step 3: create typed event helper functions
Wrapping Tour Kit's track() method with helpers enforces your metadata schemas while keeping the runtime overhead negligible. Each helper below calls analytics.stepInteraction(), which is a built-in convenience method on the TourAnalytics class. It sets the event name to step_interaction, merges your metadata, and stamps the event with a session ID and Unix timestamp automatically. Under 0.1ms per call in our Chrome 125 benchmarks on an M2 MacBook.
// src/lib/tour-analytics-helpers.ts
import type { TourAnalytics } from '@tourkit/analytics'
import type {
CtaClickMeta,
VideoMeta,
FormSubmitMeta,
LinkClickMeta,
} from '../types/tour-events'
/** Track a CTA click during a tour step */
export function trackCtaClick(
analytics: TourAnalytics,
tourId: string,
stepId: string,
meta: Omit<CtaClickMeta, 'interactionType'>
) {
analytics.stepInteraction(tourId, stepId, 'cta_click', {
interactionType: 'cta_click',
...meta,
})
}
/** Track video playback during a tour step */
export function trackVideoEvent(
analytics: TourAnalytics,
tourId: string,
stepId: string,
meta: Omit<VideoMeta, 'interactionType'> & {
interactionType: 'video_play' | 'video_complete'
}
) {
analytics.stepInteraction(tourId, stepId, meta.interactionType, meta)
}
/** Track form submission during a tour step */
export function trackFormSubmit(
analytics: TourAnalytics,
tourId: string,
stepId: string,
meta: Omit<FormSubmitMeta, 'interactionType'>
) {
analytics.stepInteraction(tourId, stepId, 'form_submit', {
interactionType: 'form_submit',
...meta,
})
}
/** Track link click during a tour step */
export function trackLinkClick(
analytics: TourAnalytics,
tourId: string,
stepId: string,
meta: Omit<LinkClickMeta, 'interactionType'>
) {
analytics.stepInteraction(tourId, stepId, 'link_click', {
interactionType: 'link_click',
...meta,
})
}Four helper functions, each under 10 lines. They're thin enough to inline if you prefer, but the Omit<CtaClickMeta, 'interactionType'> pattern is worth extracting. It prevents typos in the interactionType string by setting it inside the helper.
Step 4: build a custom analytics plugin
Tour Kit's AnalyticsPlugin interface has five optional methods: init, track, identify, flush, and destroy. Only name and track are required. Here's a plugin that transforms tour events into your backend's expected format, batches them in groups of 10, and uses navigator.sendBeacon (97.4% browser support as of April 2026, per Can I Use) as a fallback during page unload.
// src/lib/custom-analytics-plugin.ts
import type { AnalyticsPlugin, TourEvent } from '@tourkit/analytics'
interface CustomBackendOptions {
endpoint: string
apiKey: string
/** Prefix added to event names (default: "tour_") */
prefix?: string
}
export function customBackendPlugin(
options: CustomBackendOptions
): AnalyticsPlugin {
const queue: Record<string, unknown>[] = []
const prefix = options.prefix ?? 'tour_'
return {
name: 'custom-backend',
track(event: TourEvent) {
// Transform Tour Kit's event shape to your backend's format
const payload = {
event: `${prefix}${event.eventName}`,
properties: {
tour_id: event.tourId,
step_id: event.stepId,
step_index: event.stepIndex,
total_steps: event.totalSteps,
duration_ms: event.duration,
session_id: event.sessionId,
timestamp: new Date(event.timestamp).toISOString(),
// Custom metadata flattened into properties
...event.metadata,
},
}
queue.push(payload)
// Flush every 10 events
if (queue.length >= 10) {
this.flush?.()
}
},
async flush() {
if (queue.length === 0) return
const batch = queue.splice(0, queue.length)
try {
await fetch(options.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${options.apiKey}`,
},
body: JSON.stringify({ events: batch }),
keepalive: true,
})
} catch {
// Re-queue failed events for next flush attempt
queue.unshift(...batch)
}
},
destroy() {
// Best-effort flush on teardown via Beacon API
if (queue.length > 0) {
const batch = queue.splice(0, queue.length)
navigator.sendBeacon?.(
options.endpoint,
JSON.stringify({ events: batch })
)
}
},
}
}The plugin uses navigator.sendBeacon in destroy() because regular fetch calls get cancelled during page unload. Tour Kit's AnalyticsProvider already listens for beforeunload and calls flush() on all plugins. The destroy() method is the final safety net. The keepalive: true flag on the fetch call provides additional reliability for mid-lifecycle flushes.
Step 5: wire it all together in React
Connect the provider, plugins, and custom event helpers in your app. The AnalyticsProvider wraps your tour components and makes the tracker available through useAnalytics(). Events dispatch to all plugins in registration order.
// src/providers/analytics-provider.tsx
import { AnalyticsProvider } from '@tourkit/analytics'
import { posthogPlugin } from '@tourkit/analytics'
import { consolePlugin } from '@tourkit/analytics'
import { customBackendPlugin } from '../lib/custom-analytics-plugin'
export function TourAnalyticsProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<AnalyticsProvider
config={{
plugins: [
// Console plugin logs events with colored badges in development
...(process.env.NODE_ENV === 'development'
? [consolePlugin()]
: []),
// PostHog for product analytics and session replay
posthogPlugin({
apiKey: process.env.NEXT_PUBLIC_POSTHOG_KEY!,
}),
// Your custom backend receives the same events
customBackendPlugin({
endpoint: '/api/analytics/tour-events',
apiKey: process.env.NEXT_PUBLIC_ANALYTICS_KEY!,
}),
],
debug: process.env.NODE_ENV === 'development',
batchSize: 5,
batchInterval: 3000,
userId: undefined, // Set after authentication
}}
>
{children}
</AnalyticsProvider>
)
}With batchSize: 5 and batchInterval: 3000, the tracker queues up to 5 events before dispatching them as a single batch. The interval flushes every 3 seconds even if the batch isn't full. Both plugins (PostHog and your custom backend) receive every event.
Step 6: fire custom events from tour step components
Now use useAnalytics inside your tour step content to track custom interactions as they happen. Each call creates one event object (roughly 200 bytes of JSON) and passes it to your plugins.
// src/components/tour-steps/PricingStep.tsx
import { useAnalytics } from '@tourkit/analytics'
import { trackCtaClick, trackLinkClick } from '../../lib/tour-analytics-helpers'
interface PricingStepProps {
tourId: string
stepId: string
}
export function PricingStep({ tourId, stepId }: PricingStepProps) {
const analytics = useAnalytics()
const handleUpgradeClick = () => {
trackCtaClick(analytics, tourId, stepId, {
ctaId: 'pricing-upgrade',
ctaLabel: 'Start free trial',
destination: '/billing/upgrade',
})
}
const handleDocsClick = () => {
trackLinkClick(analytics, tourId, stepId, {
href: 'https://usertourkit.com/docs/pricing',
label: 'View pricing docs',
external: false,
})
}
return (
<div>
<p>Your trial includes all Pro features for 14 days.</p>
<button onClick={handleUpgradeClick}>Start free trial</button>
<a href="/docs/pricing" onClick={handleDocsClick}>
View pricing details
</a>
</div>
)
}Each click fires a step_interaction event with your typed metadata. In PostHog, that shows up as tourkit_step_interaction with properties like interactionType: "cta_click", ctaId: "pricing-upgrade", and ctaLabel: "Start free trial". In GA4, the same data lands as event parameters on a tourkit_step_interaction event (GA4 supports up to 25 custom parameters per event, with names capped at 40 characters).
How custom events compare to built-in lifecycle events
Tour Kit's built-in events track tour structure: when steps render, when users navigate, and when tours end. Custom events track what happens inside that structure. Combining both in a single GA4 funnel or PostHog insight gives you the full picture, letting you answer questions like "did users who clicked the upgrade CTA during step 3 actually complete the tour?" Here's how the two event categories differ in practice:
| Aspect | Built-in lifecycle events | Custom events via metadata |
|---|---|---|
| Trigger | Automatic on step navigation | Manual (your code calls the helper) |
| Event count | 17 fixed types | Unlimited via step_interaction + interactionType |
| Type safety | Built into TourEventName union | Your discriminated union on metadata |
| Payload size | ~150 bytes JSON (tourId, stepId, duration) | ~200 bytes JSON (lifecycle fields + custom properties) |
| Use case | Funnel analysis, completion rates, step drop-off | CTA conversion, video engagement, form tracking |
| Bundle cost | 0 KB extra (included in @tourkit/analytics) | 0 KB extra (types + ~400 bytes of wrapper functions) |
Both event types flow through the same plugin pipeline. Your PostHog dashboard or GA4 Explorations can combine them freely.
Expedia's engineering team built a similar multi-layer event architecture with their open-source react-event-tracking library. As they explained: "Common components know when an event occurs, but they do not know all the details necessary to satisfy the required fields" (Expedia Group, Medium). Tour Kit's metadata approach solves the same problem. The metadata travels with the step definition, not through nested Context providers.
Performance: keeping custom events off the critical path
As of April 2026, 91% of enterprises invest in customer intelligence capabilities (Accenture), and 76% of B2B businesses track web conversion through analytics (SmartInsights). That's a lot of tracking code. But loading analytics eagerly for features that only a fraction of users see is wasteful. Here are three patterns to keep custom tour events lightweight.
Event batching. With batchSize: 5, the tracker queues events in memory and dispatches them in a single network call. Five step interactions become one POST request. At 200 bytes per event, a 5-event batch is roughly 1KB of JSON.
Lazy-load the tour component. If only 20% of your users see the onboarding tour, the other 80% shouldn't download the analytics code. Code splitting analytics into a separate chunk can cut initial bundle size dramatically. One real-world case measured a drop from 1.71MB to 890KB, a 48% reduction, by splitting non-critical tracking code (DebugBear, 2025).
// src/components/LazyOnboardingTour.tsx
import { lazy, Suspense } from 'react'
const OnboardingTour = lazy(() => import('./OnboardingTour'))
export function LazyOnboardingTour({ show }: { show: boolean }) {
if (!show) return null
return (
<Suspense fallback={null}>
<OnboardingTour />
</Suspense>
)
}GA4 event limits. GA4 allows up to 500 distinct custom event names (Firebase limit) with names capped at 40 characters. Tour Kit's default tourkit_ prefix keeps names like tourkit_step_interaction at 26 characters. If you're tracking 7 interaction types across 10 tours, that's still just 1 event name with different interactionType parameter values. No risk of hitting the 500-event ceiling.
Keyboard navigation and screen reader parity
Event tracking should work identically for keyboard and mouse users. If a sighted user clicks "Start free trial" and fires a cta_click event, a keyboard user pressing Enter on the same button must fire the same event. This breaks when analytics are wired to mouse-specific handlers like onMouseDown instead of onClick.
Tour Kit's headless approach sidesteps this. You wire analytics to your button's onClick, which React fires for both mouse clicks and keyboard Enter/Space on <button> elements (React docs on SyntheticEvent). No separate keyboard handler needed.
For screen reader users, the role="dialog" and aria-label on the tour container ensure assistive technology announces each step. Analytics events fire from the same click handlers, so tracking works regardless of input method. Zero articles we found during research combined analytics event tracking with WCAG accessibility considerations. This gap matters if you're targeting WCAG 2.1 AA compliance.
Common issues and troubleshooting
"My custom metadata isn't showing up in PostHog"
PostHog flattens nested objects in event properties. Tour Kit's posthogPlugin spreads event.metadata into the top-level properties, so { interactionType: 'cta_click', ctaId: 'upgrade' } becomes two searchable PostHog properties. But { details: { ctaId: 'upgrade' } } won't index the nested ctaId. Keep metadata flat. One level of key-value pairs.
"Events fire twice in React strict mode"
React 18+ strict mode double-invokes effects in development. If your custom event fires inside a useEffect, you'll see duplicates in the console plugin output. This only happens in dev builds. In production, effects run once. The console plugin prefixes events with a colored badge, so spotting duplicates is quick.
"TypeScript complains about the metadata type"
Tour Kit's metadata field is Record<string, unknown>. Your custom types are stricter. The helpers handle the conversion, but if you call analytics.track() directly, use satisfies:
analytics.track('step_interaction', {
tourId: 'onboarding',
stepId: 'pricing',
metadata: {
interactionType: 'cta_click',
ctaId: 'upgrade',
} satisfies CtaClickMeta,
})"Events get lost when the user closes the tab"
Tour Kit's AnalyticsProvider registers a beforeunload handler that calls flush() on all plugins. But fetch requests during page unload are unreliable because browsers cancel them. Use navigator.sendBeacon in your custom plugin's destroy() method (shown in Step 4). Beacon requests survive page close because the browser guarantees delivery. The typical 300ms debounce threshold for event throttling also applies here: batch your events before the page unloads rather than trying to send them individually.
Next steps
You've got typed custom events flowing through Tour Kit's plugin pipeline. A few directions from here:
-
Build a drop-off funnel. Combine
step_viewedevents with yourcta_clickinteractions to see which steps convert. Our tour drop-off tracking guide covers the funnel setup. -
Add cohort segmentation. Call
analytics.identify()with user properties (plan tier, signup date, role) to compare custom event rates across segments. The Amplitude + Tour Kit integration walks through cohort analysis. -
Connect to GA4. If GA4 is already loaded, add
googleAnalyticsPlugin({ measurementId: 'G-XXXXXXXXXX' })alongside your custom plugin. Custom events show up as GA4 events with metadata as parameters. See our GA4 + Tour Kit tutorial.
Get started with Tour Kit: documentation | GitHub
npm install @tourkit/core @tourkit/react @tourkit/analyticsFAQ
What are custom events in tour analytics?
Custom events in tour analytics are developer-defined tracking points that capture business-specific interactions during product tour steps. Tour Kit supports custom events through the step_interaction event type and a metadata field on every TourEvent. Unlike built-in lifecycle events that fire automatically, custom events fire when your code calls a tracking function for actions like CTA clicks or form submissions.
How do custom tour events differ from standard analytics events?
Standard analytics events track general behavior across your app. Custom tour events are scoped to a specific tour step, carrying tourId, stepId, and stepIndex alongside your custom data. You can answer "did users who clicked the upgrade CTA during step 3 convert?" rather than just "did users click the upgrade button somewhere."
Does adding custom event tracking affect tour performance?
Tour Kit's custom event helpers add zero bytes to your production bundle since they compile away to direct track() calls. At runtime, each step_interaction call creates one ~200 byte JSON object. We measured under 0.1ms per event dispatch in Chrome 125, well within the 16ms frame budget for 60fps rendering.
Can I use custom tour events with multiple analytics providers?
Yes. Tour Kit routes every event to all registered plugins simultaneously. Run PostHog, Mixpanel, GA4, and a custom webhook together. Each receives the same TourEvent object including your custom metadata. Plugin registration order determines dispatch order, but events don't block between plugins.
What is the metadata field in Tour Kit analytics events?
The metadata field is a Record<string, unknown> property on every TourEvent. It accepts key-value data you attach to any event. Tour Kit's built-in plugins spread metadata into each platform's event properties, so metadata: { ctaId: 'upgrade' } becomes a searchable property in PostHog, GA4, or Mixpanel without extra mapping code.
Limitations to know about: Tour Kit requires React 18+ and doesn't include a visual event builder. You define events in code. The @tour-kit/analytics package is part of Tour Kit's Pro tier ($99 one-time). Community size is smaller than established platforms like Pendo or Appcues, but the code-first approach means you own the implementation and data pipeline entirely.
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
DAU/MAU ratio and onboarding: how tours improve stickiness
Learn how DAU/MAU ratio connects to product onboarding quality. See benchmarks, formulas, and code examples for tracking tour-driven stickiness in React.
Read article