Router Integration
Build multi-page tours with Next.js, React Router, or custom routers using route-aware step targeting and persistence
userTourKit supports multi-page tours that navigate users across different routes. This guide covers integration with popular routers.
Why Router Integration?
Multi-page tours allow you to:
- Guide users through workflows spanning multiple pages
- Automatically navigate to the correct page when a tour step requires it
- Persist tour state across page transitions
- Resume tours when users return to your app
Next.js App Router
The most common setup for Next.js 13+ applications.
Installation
npm install @tour-kit/reactSetup
'use client'
import { TourKitProvider, TourProvider, useNextAppRouter } from '@tour-kit/react'
export function Providers({ children }) {
const router = useNextAppRouter()
return (
<TourKitProvider>
<TourProvider tours={tours} router={router}>
{children}
</TourProvider>
</TourKitProvider>
)
}Multi-Page Tour Example
import { createTour, createStep } from '@tour-kit/core'
export const onboardingTour = createTour({
id: 'onboarding',
steps: [
createStep({
id: 'welcome',
target: '#welcome-banner',
route: '/', // Step requires this route
content: {
title: 'Welcome!',
description: 'Let\'s explore your dashboard.',
},
}),
createStep({
id: 'settings',
target: '#settings-panel',
route: '/settings', // Automatically navigates here
content: {
title: 'Settings',
description: 'Customize your experience.',
},
}),
createStep({
id: 'profile',
target: '#profile-form',
route: '/settings/profile',
content: {
title: 'Your Profile',
description: 'Complete your profile to get started.',
},
}),
],
})Next.js Pages Router
For Next.js 12 and earlier, or apps using the Pages Router.
Setup
import { TourKitProvider, TourProvider, useNextPagesRouter } from '@tour-kit/react'
function MyApp({ Component, pageProps }) {
const router = useNextPagesRouter()
return (
<TourKitProvider>
<TourProvider tours={tours} router={router}>
<Component {...pageProps} />
</TourProvider>
</TourKitProvider>
)
}
export default MyAppReact Router v6+
For single-page applications using React Router.
Setup
import { BrowserRouter } from 'react-router-dom'
import { TourKitProvider, TourProvider, useReactRouter } from '@tour-kit/react'
function AppProviders({ children }) {
const router = useReactRouter()
return (
<TourKitProvider>
<TourProvider tours={tours} router={router}>
{children}
</TourProvider>
</TourKitProvider>
)
}
function App() {
return (
<BrowserRouter>
<AppProviders>
<Routes>
{/* Your routes */}
</Routes>
</AppProviders>
</BrowserRouter>
)
}Custom Router Adapter
For other routing libraries, create a custom adapter implementing the RouterAdapter interface.
Interface
interface RouterAdapter {
// Get the current route path
getCurrentRoute(): string
// Navigate to a route (can be async)
navigate(route: string): void | Promise<boolean | undefined>
// Check if current route matches a pattern
matchRoute(
pattern: string,
mode?: 'exact' | 'startsWith' | 'contains'
): boolean
// Subscribe to route changes
onRouteChange(callback: (route: string) => void): () => void
}Example: Custom Adapter
import { useCallback, useEffect, useRef } from 'react'
import type { RouterAdapter } from '@tour-kit/core'
export function useMyRouter(): RouterAdapter {
const pathnameRef = useRef(window.location.pathname)
const callbacksRef = useRef(new Set<(route: string) => void>())
// Update ref when pathname changes
useEffect(() => {
const handlePopState = () => {
const newPath = window.location.pathname
pathnameRef.current = newPath
callbacksRef.current.forEach(cb => cb(newPath))
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
const getCurrentRoute = useCallback(() => pathnameRef.current, [])
const navigate = useCallback((route: string) => {
window.history.pushState({}, '', route)
pathnameRef.current = route
callbacksRef.current.forEach(cb => cb(route))
}, [])
const matchRoute = useCallback((pattern: string, mode = 'exact') => {
const current = pathnameRef.current
switch (mode) {
case 'startsWith': return current.startsWith(pattern)
case 'contains': return current.includes(pattern)
default: return current === pattern
}
}, [])
const onRouteChange = useCallback((callback: (route: string) => void) => {
// Call immediately with current route
callback(pathnameRef.current)
callbacksRef.current.add(callback)
return () => callbacksRef.current.delete(callback)
}, [])
return { getCurrentRoute, navigate, matchRoute, onRouteChange }
}Route Matching Modes
The matchRoute function supports three modes:
| Mode | Description | Example |
|---|---|---|
exact | Path must match exactly | /settings matches only /settings |
startsWith | Path must start with pattern | /settings matches /settings/profile |
contains | Path must contain pattern | settings matches /app/settings/profile |
Usage in Steps
createStep({
id: 'settings-step',
target: '#settings',
route: '/settings', // Uses exact match by default
routeMatchMode: 'startsWith', // Match any /settings/* route
})Navigation Behavior
Automatic Navigation
When a step requires a different route, userTourKit automatically navigates:
const tour = createTour({
id: 'multi-page',
steps: [
{ id: 'step-1', route: '/page-a', target: '#element-a' },
{ id: 'step-2', route: '/page-b', target: '#element-b' }, // Auto-navigates
],
})Cross-page Tours
When a tour spans multiple routes, the provider has to handle three concerns: trigger the route change, wait for the new step's target to mount, and surface a typed error if the target never appears. The per-step routeChangeStrategy selects the navigation policy.
routeChangeStrategy
| Strategy | When to use | Provider behavior |
|---|---|---|
'auto' (default) | Self-contained tours that should "just work" | Calls router.navigate(step.route), awaits the step's target via a MutationObserver (3000 ms default), then advances. On timeout: fires onStepError(TourRouteError) and stops the tour. |
'prompt' | You want to show a confirmation UI before navigating | Calls onNavigationRequired(route, stepId) and pauses. The consumer drives navigation (typically via <TourRoutePrompt>). |
'manual' | You manage navigation yourself (e.g. a custom transition) | Provider does nothing. Consumer must call useTourRoute().goToStepRoute() explicitly. |
createStep({
id: 'settings-step',
target: '#settings',
route: '/settings',
// Default — set to 'prompt' or 'manual' to opt out.
routeChangeStrategy: 'auto',
})Strategy × Adapter Matrix
All three strategies behave identically across the supported adapters. The only difference is the underlying navigation primitive — none of which the consumer sees.
| Strategy | Next.js App Router | Next.js Pages Router | React Router v6/v7 |
|---|---|---|---|
'auto' | router.push() (sync) → MutationObserver waits for target | router.push() returns Promise<true> → wait | useNavigate() (sync) → wait |
'prompt' | onNavigationRequired fires; no push until consumer acts | Same | Same |
'manual' | No push, no callback. Consumer drives. | Same | Same |
Handling Failures
When 'auto' cannot find the new step's target within 3 seconds, the provider calls onStepError with a typed TourRouteError and stops the tour:
import { TourProvider, type TourRouteError } from '@tour-kit/core'
<TourProvider
tours={tours}
router={router}
onStepError={(err: TourRouteError) => {
if (err.code === 'TARGET_NOT_FOUND') {
analytics.track('tour_step_target_missing', {
route: err.route,
selector: err.selector,
})
}
}}
/>TourRouteError carries code: 'TARGET_NOT_FOUND' | 'NAVIGATION_REJECTED' | 'TIMEOUT', the failing route, and (when applicable) the selector that timed out — enough to log a useful diagnostic without parsing message strings.
Resuming on the Right URL
The flow session blob (see Persistence) records currentRoute so a hard refresh during a multi-page tour lands the user back on the correct URL. On mount, if the persisted currentRoute differs from the current pathname, the provider navigates first, awaits the target, then dispatches START_TOUR. No additional configuration required — enable routePersistence.flowSession and it works.
For a complete walk-through, see the Cross-page tour example.
Navigation Callbacks
Control navigation behavior with callbacks:
<TourProvider
tours={tours}
router={router}
onBeforeNavigate={(route, step) => {
// Return false to prevent navigation
if (hasUnsavedChanges()) {
return confirm('Leave without saving?')
}
return true
}}
onAfterNavigate={(route, step) => {
analytics.track('tour_navigated', { route, stepId: step.id })
}}
/>State Persistence
Tour state is automatically persisted across page loads:
<TourKitProvider
persistence={{
enabled: true,
storage: 'localStorage',
prefix: 'my-app-tours',
}}
>Resume Tours
When a user returns to your app, tours can resume automatically:
const { resumeLastTour } = useTour()
useEffect(() => {
// Check if there's a tour to resume
resumeLastTour()
}, [])Troubleshooting
Step not showing after navigation
Ensure the target element exists on the page. Use waitForTarget to wait for dynamic content:
createStep({
id: 'dynamic-step',
route: '/dashboard',
target: '#dynamic-widget',
waitForTarget: true, // Wait up to 5 seconds for element
waitForTargetTimeout: 5000,
})Route changes not detected
Make sure onRouteChange is called immediately with the current route:
const onRouteChange = (callback) => {
// Important: Call immediately!
callback(getCurrentRoute())
// Then subscribe to changes
return subscribe(callback)
}Navigation not working
Check that your router adapter's navigate function actually changes the route:
const navigate = (route) => {
console.log('Navigating to:', route)
router.push(route) // Make sure this works
}