Skip to main content
userTourKit
Guides

Router Integration

Build multi-page tours with Next.js, React Router, or custom routers using route-aware step targeting and persistence

domidex01Published

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/react

Setup

app/providers.tsx
'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

tours/onboarding.ts
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

pages/_app.tsx
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 MyApp

React Router v6+

For single-page applications using React Router.

Setup

App.tsx
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:

ModeDescriptionExample
exactPath must match exactly/settings matches only /settings
startsWithPath must start with pattern/settings matches /settings/profile
containsPath must contain patternsettings 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
})

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

StrategyWhen to useProvider 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 navigatingCalls 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.

StrategyNext.js App RouterNext.js Pages RouterReact Router v6/v7
'auto'router.push() (sync) → MutationObserver waits for targetrouter.push() returns Promise<true> → waituseNavigate() (sync) → wait
'prompt'onNavigationRequired fires; no push until consumer actsSameSame
'manual'No push, no callback. Consumer drives.SameSame

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.

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)
}

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
}