BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. Production-Ready Driver.js Product Tour for Next.js 16

Production-Ready Driver.js Product Tour for Next.js 16

Implement a resilient Driver.js product tour in Next.js 16 — dynamic import, App Router, and Playwright testing.

19th July 2026·Updated on:2nd August 2026··
Next.js
Production-Ready Driver.js Product Tour for Next.js 16

Comparing Headless CMS Options?

Answer 10 simple questions and get an independent recommendation matched to your project, budget, and team structure.

Try the CMS PickerGet a Second Opinion

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • Why the demo version breaks in production
  • The architectural decisions that make this work
  • Step 1: Install Driver.js with a pinned version
  • Step 2: Load the CSS from the layout, and the JavaScript from the client
  • Step 3: Give the tour a stable DOM contract
  • Step 4: Define the tour types
  • Step 5: Persist seen, completed, and resume state
  • Step 6: Keep analytics stable, consent-aware, and free of personal data
  • Step 7: Build a localized, route-aware definition
  • Step 8: Implement the client provider
  • What each ref is guarding against
  • Step 9: Add the manual restart trigger
  • Step 10: Mount the provider at the narrowest shared boundary
  • Step 11: Theme Driver.js with your own design tokens
  • Step 12: Validate the definition during development
  • Step 13: Add the translation keys
  • Step 14: Test the real browser behavior
  • Common mistakes to check for
  • Accessibility and privacy checklist
  • Adding another step later
  • FAQ
  • Wrapping up
  • Further reading
On this page:
  • Why the demo version breaks in production
  • The architectural decisions that make this work
  • Step 1: Install Driver.js with a pinned version
  • Step 2: Load the CSS from the layout, and the JavaScript from the client
  • Step 3: Give the tour a stable DOM contract
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

Building a real product tour with Driver.js in Next.js 16 comes down to one architectural choice: keep your pages as Server Components and let a single Client Component own the entire Driver.js lifecycle, including dynamic import, per-route segments, resume state, and cleanup. That one boundary is what lets a tour survive hydration timing, App Router navigation, responsive layouts, localization, reduced motion, and analytics consent without falling apart after the first demo. This guide walks through that exact implementation, built for a live vehicle marketplace, with the storage layer, the provider, the theming, and the Playwright tests that prove it works.

Why the demo version breaks in production

I needed a guided tour for Avtolibre, my vehicle marketplace built on Next.js and Payload CMS, covering search, an AI recommendation guide, vehicle filtering, and a leasing calculator across three locale-prefixed routes. The Driver.js quickstart handles a single static page fine: install the package, point a step at an element, call drive(). A tour that spans routes, locales, and device sizes has to also wait for hydration, target different desktop and mobile elements, persist across App Router navigation, respect reduced motion, and only fire analytics after consent. None of that comes from the library out of the box, and getting it right is what separates a demo from something you can ship.

The stack for this implementation:

  • Next.js 16.2.6 and the App Router
  • React 19.2
  • Driver.js 1.8.0
  • TypeScript
  • next-intl
  • Tailwind CSS and shadcn/ui
  • Google Tag Manager
  • Playwright

The finished tour has eight steps across three routes:

text
/{locale}
  Search
  AI guide

/{locale}/vehicles
  Filters
  Results
  Monthly cost (optional)

/{locale}/leasing-calculator
  Calculator inputs
  Estimated range
  Matching-vehicle search

It starts automatically once per tour version, can be dismissed and restarted manually, and resumes correctly after client-side navigation.

The architectural decisions that make this work

  1. Existing layouts and pages stay Server Components.
  2. A small Client Component owns Driver.js.
  3. The JavaScript package is dynamically imported after hydration.
  4. The vendor stylesheet loads once from the shared frontend layout.
  5. Tour targets use stable data-tour attributes.
  6. localStorage remembers whether a version was seen or completed.
  7. sessionStorage carries the active step across routes.
  8. Every Driver.js instance is destroyed before navigation or unmounting.
  9. Analytics use stable IDs rather than translated copy.
  10. Playwright verifies the complete browser behavior.

Here's the resulting file structure:

text
src/
  app/
    (frontend)/
      layout.tsx
  components/
    product-tour/
      ProductTourProvider.tsx
      ProductTourTrigger.tsx
      product-tour.css
  modules/
    marketplace/
      product-tour/
        analytics.ts
        definition.ts
        storage.ts
        types.ts
        validation.ts
        README.md
messages/
  de.json
  en.json
  sl.json
e2e/
  product-tour.spec.ts

Step 1: Install Driver.js with a pinned version

Use whatever package manager the project already uses. Avtolibre runs pnpm, and I pin Driver.js exactly rather than letting it float:

bash
pnpm add driver.js@1.8.0 --save-exact

In a pnpm workspace, target the application package when running from the repository root:

bash
pnpm --filter your-web-app add driver.js@1.8.0 --save-exact

Skip a CDN, next/script, custom Turbopack loaders, transpilePackages, or a Webpack fallback for this. Driver.js ships normal package exports, TypeScript declarations, JavaScript bundles, and a stylesheet, so none of that machinery is necessary.

Step 2: Load the CSS from the layout, and the JavaScript from the client

Import the Driver.js stylesheet once in the narrowest layout that covers the whole tour, and import any custom overrides right after it:

tsx
// File: src/app/(frontend)/layout.tsx
import "driver.js/dist/driver.css";
import "@/components/product-tour/product-tour.css";

export default function FrontendLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

The layout stays a Server Component throughout. Driver.js's JavaScript loads later, from the client runtime, using a dynamic import:

ts
const { driver } = await import("driver.js");

CSS belongs in the shared layout because it needs to be present the moment any tour target renders. Browser-only behavior belongs in a client island because it depends on hydration, window, and user interaction.

Step 3: Give the tour a stable DOM contract

Tour selectors are part of your UI contract, the same way a public API is. Target dedicated attributes rather than Tailwind classes, translated text, DOM position, or shadcn/ui internals, since any of those can change during a routine redesign and quietly break every step:

tsx
<form data-tour="marketplace-search">
  {/* Search controls */}
</form>

<section data-tour="vehicle-results">
  {/* Result cards */}
</section>

For content that only becomes usable after hydration, expose readiness explicitly:

tsx
"use client";

import { useEffect, useState } from "react";

export function SearchPanel() {
  const [tourReady, setTourReady] = useState(false);

  useEffect(() => {
    setTourReady(true);
  }, []);

  return (
    <form
      data-tour="marketplace-search"
      data-tour-ready={tourReady ? "true" : undefined}
    >
      {/* Search controls */}
    </form>
  );
}

The step can then require both identity and readiness:

ts
element:
  '[data-tour="marketplace-search"][data-tour-ready="true"]';

Driver.js observes the DOM until this target appears, which is far more reliable than a fixed timeout guessing when hydration finished.

Watch for duplicate data-tour values on repeated cards. In this implementation, only the first vehicle with a monthly-payment value gets the optional target:

tsx
const monthlyPaymentTourIndex = vehicles.findIndex(
  (vehicle) => vehicle.monthlyPaymentFrom != null,
);

{vehicles.map((vehicle, index) => (
  <VehicleCard
    key={vehicle.id}
    vehicle={vehicle}
    tourTarget={
      index === monthlyPaymentTourIndex
        ? "vehicle-monthly-cost"
        : undefined
    }
  />
))}

Step 4: Define the tour types

Reuse Driver.js's exported DriveStep type rather than recreating the library's API from scratch:

ts
// File: src/modules/marketplace/product-tour/types.ts
import type { DriveStep } from "driver.js";

export type ProductTourSource =
  | "automatic"
  | "manual"
  | "resume";

export type ProductTourSegment = {
  id: string;
  pathname: string;
  href?: string;
  steps: DriveStep[];
};

export type ProductTourDefinition = {
  id: string;
  version: number;
  autoStart?: boolean;
  segments: ProductTourSegment[];
};

export type ProductTourResumeState = {
  tourId: string;
  version: number;
  segmentIndex: number;
  stepIndex: number;
  source: ProductTourSource;
  savedAt: number;
};

export type StartProductTourOptions = {
  source?: ProductTourSource;
  restart?: boolean;
};

A segment maps to one route and one Driver.js instance. That boundary is what makes App Router navigation reliable: destroy the old instance, navigate, and create a fresh one on the destination route.

Step 5: Persist seen, completed, and resume state

Version every storage key. Bumping the version is what makes a materially changed tour eligible to run again for returning visitors:

text
product-tour:marketplace-overview:v2:seen
product-tour:marketplace-overview:v2:completed
product-tour:marketplace-overview:v2:resume

localStorage holds durable seen and completed state. sessionStorage carries the short-lived handoff between routes during a single visit:

ts
// File: src/modules/marketplace/product-tour/storage.ts
import type {
  ProductTourDefinition,
  ProductTourResumeState,
} from "./types";

function storagePrefix(tour: ProductTourDefinition): string {
  return `product-tour:${tour.id}:v${tour.version}`;
}

export function productTourSeenKey(
  tour: ProductTourDefinition,
): string {
  return `${storagePrefix(tour)}:seen`;
}

export function productTourCompletedKey(
  tour: ProductTourDefinition,
): string {
  return `${storagePrefix(tour)}:completed`;
}

export function productTourResumeKey(
  tour: ProductTourDefinition,
): string {
  return `${storagePrefix(tour)}:resume`;
}

export function hasSeenTour(
  tour: ProductTourDefinition,
): boolean {
  try {
    return (
      window.localStorage.getItem(productTourSeenKey(tour)) === "1"
    );
  } catch {
    return false;
  }
}

export function markTourSeen(
  tour: ProductTourDefinition,
): void {
  try {
    window.localStorage.setItem(productTourSeenKey(tour), "1");
  } catch {
    // Storage can be unavailable in restricted browser contexts.
  }
}

export function markTourCompleted(
  tour: ProductTourDefinition,
): void {
  try {
    window.localStorage.setItem(
      productTourCompletedKey(tour),
      "1",
    );
  } catch {
    // The tour still works when persistence is unavailable.
  }
}

export function saveTourResumeState(
  tour: ProductTourDefinition,
  state: ProductTourResumeState,
): void {
  try {
    window.sessionStorage.setItem(
      productTourResumeKey(tour),
      JSON.stringify(state),
    );
  } catch {
    // Cross-route resume is unavailable when storage is blocked.
  }
}

function isProductTourSource(
  value: unknown,
): value is ProductTourResumeState["source"] {
  return (
    value === "automatic" ||
    value === "manual" ||
    value === "resume"
  );
}

export function parseTourResumeState(
  tour: ProductTourDefinition,
  value: string,
): ProductTourResumeState | null {
  try {
    const parsed = JSON.parse(
      value,
    ) as Partial<ProductTourResumeState>;

    if (
      parsed.tourId !== tour.id ||
      parsed.version !== tour.version ||
      !Number.isInteger(parsed.segmentIndex) ||
      (parsed.segmentIndex ?? -1) < 0 ||
      !Number.isInteger(parsed.stepIndex) ||
      (parsed.stepIndex ?? -1) < 0 ||
      !isProductTourSource(parsed.source) ||
      typeof parsed.savedAt !== "number"
    ) {
      return null;
    }

    return parsed as ProductTourResumeState;
  } catch {
    return null;
  }
}

export function readTourResumeState(
  tour: ProductTourDefinition,
): ProductTourResumeState | null {
  try {
    const value = window.sessionStorage.getItem(
      productTourResumeKey(tour),
    );
    if (!value) return null;

    const state = parseTourResumeState(tour, value);
    if (!state) clearTourResumeState(tour);
    return state;
  } catch {
    clearTourResumeState(tour);
    return null;
  }
}

export function clearTourResumeState(
  tour: ProductTourDefinition,
): void {
  try {
    window.sessionStorage.removeItem(productTourResumeKey(tour));
  } catch {
    // No action is required.
  }
}

export function resetTourStorage(
  tour: ProductTourDefinition,
): void {
  try {
    window.localStorage.removeItem(productTourSeenKey(tour));
    window.localStorage.removeItem(productTourCompletedKey(tour));
    window.sessionStorage.removeItem(productTourResumeKey(tour));
  } catch {
    // No action is required.
  }
}

These functions only touch browser storage when called directly. Keep storage reads out of module scope, and out of anything that produces server-rendered HTML.

Step 6: Keep analytics stable, consent-aware, and free of personal data

A translated step title makes a poor analytics identifier, since it changes with every copy edit. Give every step a stable data.id and send that value to GTM instead. Avtolibre already tracks a consent flag, so the analytics helper checks it before pushing anything:

ts
// File: src/modules/marketplace/product-tour/analytics.ts
import type { ProductTourSource } from "./types";

export type ProductTourEvent =
  | "product_tour_started"
  | "product_tour_segment_started"
  | "product_tour_step_viewed"
  | "product_tour_route_transition"
  | "product_tour_completed"
  | "product_tour_dismissed"
  | "product_tour_error";

type ProductTourEventProperties = {
  tourId: string;
  tourVersion: number;
  source?: ProductTourSource;
  segmentId?: string;
  segmentIndex?: number;
  stepId?: string;
  stepIndex?: number;
  nextPathname?: string;
  errorMessage?: string;
};

export function trackProductTourEvent(
  event: ProductTourEvent,
  properties: ProductTourEventProperties,
): void {
  if (typeof window === "undefined") return;

  try {
    if (window.localStorage.getItem("analytics-consent") !== "true") {
      return;
    }
  } catch {
    return;
  }

  const analyticsWindow = window as Window & {
    dataLayer?: unknown[];
  };

  analyticsWindow.dataLayer ??= [];
  analyticsWindow.dataLayer.push({
    event,
    tour_id: properties.tourId,
    tour_version: properties.tourVersion,
    tour_source: properties.source,
    tour_segment_id: properties.segmentId,
    tour_segment_index: properties.segmentIndex,
    tour_step_id: properties.stepId,
    tour_step_index: properties.stepIndex,
    tour_next_pathname: properties.nextPathname,
    tour_error_message: properties.errorMessage,
  });
}

Swap analytics-consent for your own consent abstraction. Keep search text, vehicle IDs, financing inputs, form values, and any personal information out of tour events entirely.

Step 7: Build a localized, route-aware definition

The definition is a function because both the locale-prefixed paths and the translated copy are runtime inputs, not constants:

ts
// File: src/modules/marketplace/product-tour/definition.ts
import type { DriveStep } from "driver.js";
import type { ProductTourDefinition } from "./types";

export type ProductTourCopy = {
  next: string;
  previous: string;
  finish: string;
  continue: string;
  progress: string;
  searchTitle: string;
  searchDescription: string;
  aiGuideTitle: string;
  aiGuideDescription: string;
  filtersTitle: string;
  filtersDescription: string;
  resultsTitle: string;
  resultsDescription: string;
  monthlyCostTitle: string;
  monthlyCostDescription: string;
  calculatorInputsTitle: string;
  calculatorInputsDescription: string;
  calculatorEstimateTitle: string;
  calculatorEstimateDescription: string;
  calculatorSearchTitle: string;
  calculatorSearchDescription: string;
};

function visibleVehicleFilters(): Element {
  const targets = [
    document.querySelector(
      '[data-tour="vehicle-filters-desktop"]',
    ),
    document.querySelector(
      '[data-tour="vehicle-filters-mobile"]' +
        '[data-tour-ready="true"]',
    ),
  ];

  const visibleTarget = targets.find(
    (target): target is HTMLElement =>
      target instanceof HTMLElement && target.offsetParent !== null,
  );

  // An empty result remains pending until waitForElement expires.
  return visibleTarget ?? (undefined as unknown as Element);
}

function step(
  id: string,
  element: DriveStep["element"],
  title: string,
  description: string,
  placement: NonNullable<DriveStep["popover"]> = {},
): DriveStep {
  return {
    element,
    waitForElement: 5000,
    data: { id },
    popover: { title, description, ...placement },
  };
}

export function createMarketplaceProductTour(
  locale: string,
  copy: ProductTourCopy,
): ProductTourDefinition {
  return {
    id: "marketplace-overview",
    version: 2,
    autoStart: true,
    segments: [
      {
        id: "discovery",
        pathname: `/${locale}`,
        steps: [
          step(
            "marketplace-search",
            '[data-tour="marketplace-search"]' +
              '[data-tour-ready="true"]',
            copy.searchTitle,
            copy.searchDescription,
            { side: "bottom", align: "center" },
          ),
          step(
            "ai-guide",
            '[data-tour="ai-guide"]' +
              '[data-tour-ready="true"]',
            copy.aiGuideTitle,
            copy.aiGuideDescription,
            { side: "top", align: "center" },
          ),
        ],
      },
      {
        id: "vehicle-results",
        pathname: `/${locale}/vehicles`,
        steps: [
          step(
            "vehicle-filters",
            visibleVehicleFilters,
            copy.filtersTitle,
            copy.filtersDescription,
            { side: "right", align: "start" },
          ),
          step(
            "vehicle-results",
            '[data-tour="vehicle-results"]',
            copy.resultsTitle,
            copy.resultsDescription,
            { side: "top", align: "center" },
          ),
          {
            ...step(
              "vehicle-monthly-cost",
              '[data-tour="vehicle-monthly-cost"]',
              copy.monthlyCostTitle,
              copy.monthlyCostDescription,
              { side: "left", align: "center" },
            ),
            waitForElement: 1000,
            skipMissingElement: true,
            data: {
              id: "vehicle-monthly-cost",
              optional: true,
            },
          },
        ],
      },
      {
        id: "leasing-calculator",
        pathname: `/${locale}/leasing-calculator`,
        steps: [
          step(
            "leasing-inputs",
            '[data-tour="leasing-inputs"]' +
              '[data-tour-ready="true"]',
            copy.calculatorInputsTitle,
            copy.calculatorInputsDescription,
            { side: "right", align: "start" },
          ),
          step(
            "leasing-estimate",
            '[data-tour="leasing-estimate"]' +
              '[data-tour-ready="true"]',
            copy.calculatorEstimateTitle,
            copy.calculatorEstimateDescription,
            { side: "left", align: "center" },
          ),
          step(
            "leasing-vehicle-search",
            '[data-tour="leasing-vehicle-search"]' +
              '[data-tour-ready="true"]',
            copy.calculatorSearchTitle,
            copy.calculatorSearchDescription,
            { side: "top", align: "center" },
          ),
        ],
      },
    ],
  };
}

Avtolibre ships localized Slovenian path segments (/vozila and /leasing-kalkulator). I used the English paths above to keep the example portable, but your route strings need to match exactly what usePathname() returns in your application.

Two details in this definition are worth calling out. The responsive filter step resolves whichever target is actually visible, because a desktop sidebar can exist in the DOM while hidden with CSS, and checking only for existence would target the wrong one. offsetParent !== null is a practical visibility test for that layout. And only the monthly-cost step is marked optional, on purpose: turning on skipMissingElement globally would silently hide any broken selector across the entire tour, turning a bug into an invisible product defect.

Step 8: Implement the client provider

This provider is the runtime. It owns the current Driver.js instance, automatic and manual starts, route-segment transitions, resume state, completion and dismissal, localization, reduced motion, analytics, and cleanup after navigation or unmounting. Here's the pattern in production, with a few imports shortened to generic module paths:

tsx
// File: src/components/product-tour/ProductTourProvider.tsx
"use client";

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type { Driver } from "driver.js";

import { trackProductTourEvent } from "@/modules/product-tour/analytics";
import {
  clearTourResumeState,
  hasSeenTour,
  markTourCompleted,
  markTourSeen,
  readTourResumeState,
  resetTourStorage,
  saveTourResumeState,
} from "@/modules/product-tour/storage";
import {
  createMarketplaceProductTour,
  type ProductTourCopy,
} from "@/modules/product-tour/definition";
import type {
  ProductTourSource,
  StartProductTourOptions,
} from "@/modules/product-tour/types";
import { validateProductTourDefinition } from "@/modules/product-tour/validation";

type ProductTourContextValue = {
  isActive: boolean;
  startTour: (
    options?: StartProductTourOptions,
  ) => Promise<void>;
  stopTour: () => void;
  resetTour: () => void;
};

const ProductTourContext =
  createContext<ProductTourContextValue | null>(null);

export function ProductTourProvider({
  locale,
  children,
}: Readonly<{
  locale: string;
  children: React.ReactNode;
}>) {
  const pathname = usePathname();
  const router = useRouter();
  const t = useTranslations("common.productTour");

  const copy = useMemo<ProductTourCopy>(
    () => ({
      next: t("next"),
      previous: t("previous"),
      finish: t("finish"),
      continue: t("continue"),
      progress: t.raw("progress") as string,
      searchTitle: t("steps.search.title"),
      searchDescription: t("steps.search.description"),
      aiGuideTitle: t("steps.aiGuide.title"),
      aiGuideDescription: t("steps.aiGuide.description"),
      filtersTitle: t("steps.filters.title"),
      filtersDescription: t("steps.filters.description"),
      resultsTitle: t("steps.results.title"),
      resultsDescription: t("steps.results.description"),
      monthlyCostTitle: t("steps.monthlyCost.title"),
      monthlyCostDescription: t("steps.monthlyCost.description"),
      calculatorInputsTitle: t("steps.calculatorInputs.title"),
      calculatorInputsDescription: t(
        "steps.calculatorInputs.description",
      ),
      calculatorEstimateTitle: t("steps.calculatorEstimate.title"),
      calculatorEstimateDescription: t(
        "steps.calculatorEstimate.description",
      ),
      calculatorSearchTitle: t("steps.calculatorSearch.title"),
      calculatorSearchDescription: t(
        "steps.calculatorSearch.description",
      ),
    }),
    [t],
  );

  const tour = useMemo(
    () => createMarketplaceProductTour(locale, copy),
    [copy, locale],
  );

  const pathnameRef = useRef(pathname);
  const driverRef = useRef<Driver | null>(null);
  const mountedRef = useRef(false);
  const startingRef = useRef(false);
  const routeTransitionRef = useRef(false);
  const completedRef = useRef(false);
  const dismissedRef = useRef(false);
  const teardownRef = useRef(false);
  const generationRef = useRef(0);
  const [isActive, setIsActive] = useState(false);

  useEffect(() => {
    pathnameRef.current = pathname;
  }, [pathname]);

  useEffect(() => {
    if (process.env.NODE_ENV === "production") return;
    const errors = validateProductTourDefinition(tour);
    if (errors.length > 0) {
      throw new Error(`[ProductTour] ${errors.join("; ")}`);
    }
  }, [tour]);

  useEffect(() => {
    mountedRef.current = true;

    return () => {
      mountedRef.current = false;
      teardownRef.current = true;
      generationRef.current += 1;

      if (driverRef.current?.isActive()) {
        driverRef.current.destroy();
      }

      driverRef.current = null;
    };
  }, []);

  const runSegment = useCallback(
    async (
      segmentIndex: number,
      startIndex: number,
      source: ProductTourSource,
    ): Promise<void> => {
      const segment = tour.segments[segmentIndex];

      if (!segment || segment.pathname !== pathnameRef.current) return;
      if (startingRef.current || driverRef.current?.isActive()) return;

      startingRef.current = true;
      routeTransitionRef.current = false;
      completedRef.current = false;
      dismissedRef.current = false;
      teardownRef.current = false;
      const generation = ++generationRef.current;

      markTourSeen(tour);

      try {
        const { driver } = await import("driver.js");

        if (
          !mountedRef.current ||
          generation !== generationRef.current ||
          segment.pathname !== pathnameRef.current
        ) {
          return;
        }

        const hasNextSegment =
          segmentIndex < tour.segments.length - 1;
        const reducedMotion = window.matchMedia(
          "(prefers-reduced-motion: reduce)",
        ).matches;

        const instance = driver({
          animate: !reducedMotion,
          duration: reducedMotion ? 0 : 300,
          smoothScroll: !reducedMotion,
          showProgress: true,
          progressText: copy.progress,
          nextBtnText: copy.next,
          prevBtnText: copy.previous,
          doneBtnText: hasNextSegment
            ? copy.continue
            : copy.finish,
          allowClose: true,
          allowScroll: true,
          allowKeyboardControl: true,
          overlayColor: "#000000",
          overlayOpacity: 0.55,
          stagePadding: 8,
          stageRadius: 12,
          popoverOffset: 12,
          popoverClass: "app-product-tour-popover",
          waitForElement: 5000,
          skipMissingElement: false,
          steps: segment.steps,

          onHighlightStarted: (
            _element,
            activeStep,
            { index },
          ) => {
            const stepIndex = index ?? 0;
            const stepId =
              typeof activeStep.data?.id === "string"
                ? activeStep.data.id
                : undefined;

            saveTourResumeState(tour, {
              tourId: tour.id,
              version: tour.version,
              segmentIndex,
              stepIndex,
              source,
              savedAt: Date.now(),
            });

            trackProductTourEvent("product_tour_step_viewed", {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
              stepId,
              stepIndex,
            });
          },

          onDoneClick: (
            _element,
            _step,
            { driver: activeDriver },
          ) => {
            if (hasNextSegment) {
              const nextSegment = tour.segments[segmentIndex + 1];

              if (!nextSegment) {
                activeDriver.destroy();
                return;
              }

              saveTourResumeState(tour, {
                tourId: tour.id,
                version: tour.version,
                segmentIndex: segmentIndex + 1,
                stepIndex: 0,
                source: "resume",
                savedAt: Date.now(),
              });
              routeTransitionRef.current = true;

              trackProductTourEvent(
                "product_tour_route_transition",
                {
                  tourId: tour.id,
                  tourVersion: tour.version,
                  source,
                  segmentId: segment.id,
                  segmentIndex,
                  nextPathname: nextSegment.pathname,
                },
              );

              activeDriver.destroy();
              router.push(nextSegment.href ?? nextSegment.pathname);
              return;
            }

            completedRef.current = true;
            markTourCompleted(tour);
            clearTourResumeState(tour);

            trackProductTourEvent("product_tour_completed", {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
            });

            activeDriver.destroy();
          },

          onDestroyStarted: (
            _element,
            _step,
            { driver: activeDriver, index },
          ) => {
            dismissedRef.current = true;
            driverRef.current = null;
            if (mountedRef.current) setIsActive(false);
            clearTourResumeState(tour);

            trackProductTourEvent("product_tour_dismissed", {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
              stepIndex: index,
            });

            // A custom onDestroyStarted owns final teardown.
            activeDriver.destroy();
          },

          onDestroyed: (_element, _step, { index }) => {
            driverRef.current = null;
            if (mountedRef.current) setIsActive(false);

            if (
              routeTransitionRef.current ||
              completedRef.current ||
              dismissedRef.current ||
              teardownRef.current
            ) {
              return;
            }

            clearTourResumeState(tour);
            trackProductTourEvent("product_tour_dismissed", {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
              stepIndex: index,
            });
          },
        });

        driverRef.current = instance;
        setIsActive(true);

        trackProductTourEvent("product_tour_segment_started", {
          tourId: tour.id,
          tourVersion: tour.version,
          source,
          segmentId: segment.id,
          segmentIndex,
        });

        instance.drive(
          Math.min(startIndex, segment.steps.length - 1),
        );
      } catch (error) {
        const errorMessage =
          error instanceof Error
            ? error.message
            : "Unknown Driver.js error";

        driverRef.current = null;
        clearTourResumeState(tour);
        if (mountedRef.current) setIsActive(false);

        trackProductTourEvent("product_tour_error", {
          tourId: tour.id,
          tourVersion: tour.version,
          source,
          segmentId: segment.id,
          segmentIndex,
          errorMessage,
        });

        console.error("[ProductTour] Unable to start tour", error);
      } finally {
        startingRef.current = false;
      }
    },
    [copy, router, tour],
  );

  const startTour = useCallback(
    async (
      options: StartProductTourOptions = {},
    ): Promise<void> => {
      if (startingRef.current || driverRef.current?.isActive()) return;

      const source = options.source ?? "manual";
      const firstSegment = tour.segments[0];
      if (!firstSegment) return;

      clearTourResumeState(tour);
      trackProductTourEvent("product_tour_started", {
        tourId: tour.id,
        tourVersion: tour.version,
        source,
      });

      if (firstSegment.pathname !== pathnameRef.current) {
        markTourSeen(tour);
        saveTourResumeState(tour, {
          tourId: tour.id,
          version: tour.version,
          segmentIndex: 0,
          stepIndex: 0,
          source: "resume",
          savedAt: Date.now(),
        });
        router.push(firstSegment.href ?? firstSegment.pathname);
        return;
      }

      await runSegment(0, 0, source);
    },
    [router, runSegment, tour],
  );

  const stopTour = useCallback(() => {
    generationRef.current += 1;
    routeTransitionRef.current = false;
    clearTourResumeState(tour);
    if (driverRef.current?.isActive()) driverRef.current.destroy();
    driverRef.current = null;
    setIsActive(false);
  }, [tour]);

  const resetTour = useCallback(() => {
    generationRef.current += 1;
    if (driverRef.current?.isActive()) driverRef.current.destroy();
    driverRef.current = null;
    setIsActive(false);
    resetTourStorage(tour);
  }, [tour]);

  useEffect(() => {
    const resumeState = readTourResumeState(tour);
    if (!resumeState) return;

    const segment = tour.segments[resumeState.segmentIndex];
    if (!segment || segment.pathname !== pathname) return;

    if (resumeState.stepIndex >= segment.steps.length) {
      clearTourResumeState(tour);
      return;
    }

    clearTourResumeState(tour);
    void runSegment(
      resumeState.segmentIndex,
      resumeState.stepIndex,
      "resume",
    );
  }, [pathname, runSegment, tour]);

  useEffect(() => {
    if (!tour.autoStart || hasSeenTour(tour)) return;
    const firstSegment = tour.segments[0];
    if (!firstSegment || firstSegment.pathname !== pathname) return;
    void runSegment(0, 0, "automatic");
  }, [pathname, runSegment, tour]);

  const value = useMemo(
    () => ({ isActive, startTour, stopTour, resetTour }),
    [isActive, resetTour, startTour, stopTour],
  );

  return (
    <ProductTourContext.Provider value={value}>
      {children}
    </ProductTourContext.Provider>
  );
}

export function useProductTour(): ProductTourContextValue {
  const context = useContext(ProductTourContext);
  if (!context) {
    throw new Error(
      "useProductTour must be used within ProductTourProvider",
    );
  }
  return context;
}

What each ref is guarding against

Every ref in this provider closes a specific race condition or analytics ambiguity:

RefWhat it prevents
pathnameRefA delayed dynamic import starting on a route the user already left
startingRefTwo effects or two clicks starting two instances at once
routeTransitionRefNavigation teardown being logged as a dismissal
completedRefCompletion being logged as a dismissal
dismissedRefThe same dismissal firing twice
teardownRefReact unmount cleanup being logged as a dismissal
generationRefStale async work resuming after stop, reset, or unmount
mountedRefState updates running after the component has unmounted

The generation check earns its place especially in React development mode, where a dynamic import can resolve after the component has already unmounted, or after the user has already stopped the tour. Incrementing the generation counter invalidates that stale continuation before it can touch state.

Explicit destroy() calls matter for the same reason. Once you override Driver.js's lifecycle hooks, your callback becomes responsible for that behavior. This provider calls destroy() when a route segment ends, when the final step completes, from the custom destroy-start callback, when the user stops or resets the tour, and when the provider unmounts. Skipping any of those leaves a stale overlay or event listener behind after navigation.

Step 9: Add the manual restart trigger

Automatic start should never remove the manual path, since a visitor might dismiss the tour, finish it, or want to show it to a colleague later:

tsx
// File: src/components/product-tour/ProductTourTrigger.tsx
"use client";

import { CircleHelp } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useProductTour } from "./ProductTourProvider";

export function ProductTourTrigger({
  compact = false,
}: {
  compact?: boolean;
}) {
  const t = useTranslations("common.productTour");
  const { isActive, startTour } = useProductTour();
  const [ready, setReady] = useState(false);

  useEffect(() => {
    setReady(true);
  }, []);

  return (
    <Button
      type="button"
      variant="outline"
      size={compact ? "icon" : "sm"}
      disabled={!ready || isActive}
      data-tour-ready={ready ? "true" : undefined}
      aria-label={isActive ? t("active") : t("trigger")}
      title={
        compact
          ? isActive
            ? t("active")
            : t("trigger")
          : undefined
      }
      onClick={() =>
        void startTour({ source: "manual", restart: true })
      }
    >
      <CircleHelp aria-hidden="true" />
      {compact ? (
        <span className="sr-only">{t("trigger")}</span>
      ) : isActive ? (
        t("active")
      ) : (
        t("trigger")
      )}
    </Button>
  );
}

Render the labeled button on larger screens and the compact icon on smaller ones, keeping the accessible name intact either way.

Step 10: Mount the provider at the narrowest shared boundary

The provider needs to wrap every route the tour touches, and nothing beyond that. On Avtolibre, the public marketplace gets the provider while the authenticated back office does not:

tsx
// Server Component
export async function LocaleChromeGate({
  locale,
  children,
}: {
  locale: string;
  children: React.ReactNode;
}) {
  const pathname = await readRequestPathname();

  if (isOfficePath(pathname)) {
    return <div>{children}</div>;
  }

  return (
    <ProductTourProvider locale={locale}>
      <div className="flex min-h-screen flex-col">
        <MarketplaceNavbar locale={locale} />
        <main className="flex-1">{children}</main>
        <Footer />
      </div>
      <CookieConsent />
    </ProductTourProvider>
  );
}

Passing Server Component content as children through a Client Component boundary works fine here. The pages underneath never need "use client" themselves just because Driver.js highlights elements inside them.

Step 11: Theme Driver.js with your own design tokens

Driver.js adds its own classes to the DOM, which plain CSS can theme against existing shadcn variables:

css
/* File: src/components/product-tour/product-tour.css */
.driver-popover.app-product-tour-popover {
  --driver-popover-font-family:
    var(--font-sans, ui-sans-serif, system-ui, sans-serif);

  /* Driver.js puts the SVG overlay at z-index 10000 inline. */
  z-index: 10001;
  width: min(360px, calc(100vw - 32px));
  max-width: 360px;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) + 4px);
  background: var(--popover);
  color: var(--popover-foreground);
  box-shadow: var(--shadow-xl);
}

.driver-popover.app-product-tour-popover
  .driver-popover-title {
  color: var(--popover-foreground);
  font-size: 1rem;
  font-weight: 700;
  line-height: 1.4;
}

.driver-popover.app-product-tour-popover
  .driver-popover-description,
.driver-popover.app-product-tour-popover
  .driver-popover-progress-text {
  color: var(--muted-foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-description {
  font-size: 0.875rem;
  line-height: 1.55;
}

.driver-popover.app-product-tour-popover
  .driver-popover-progress-text {
  font-size: 0.75rem;
}

.driver-popover.app-product-tour-popover
  .driver-popover-footer-btn {
  min-height: 36px;
  padding-inline: 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--secondary);
  color: var(--secondary-foreground);
  font-family: inherit;
  font-size: 0.875rem;
  font-weight: 600;
  text-shadow: none;
}

.driver-popover.app-product-tour-popover
  .driver-popover-next-btn {
  border-color: var(--primary);
  background: var(--primary);
  color: var(--primary-foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-close-btn {
  color: var(--muted-foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-close-btn:hover {
  color: var(--foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-footer-btn:focus-visible,
.driver-popover.app-product-tour-popover
  .driver-popover-close-btn:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

@media (max-width: 640px) {
  .driver-popover.app-product-tour-popover {
    width: calc(100vw - 24px);
    max-width: none;
    padding: 14px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .driver-popover,
  .driver-overlay,
  .driver-active-element {
    transition-duration: 0s !important;
    animation-duration: 0s !important;
  }
}

Cross-check this against your full z-index system, and test the tour with sticky headers, dialogs, sheets, cookie banners, chat widgets, toasts, and mobile navigation all present at once.

Step 12: Validate the definition during development

A typo in a selector deserves to fail loudly before it reaches a demo. A small structural validator catches mistakes TypeScript can't:

ts
// File: src/modules/marketplace/product-tour/validation.ts
import type { ProductTourDefinition } from "./types";

export function validateProductTourDefinition(
  tour: ProductTourDefinition,
): string[] {
  const errors: string[] = [];
  const segmentIds = new Set<string>();
  const pathnames = new Set<string>();
  const stepIds = new Set<string>();

  if (!tour.id.trim()) errors.push("Tour ID must not be empty");
  if (!Number.isInteger(tour.version) || tour.version < 1) {
    errors.push("Tour version must be a positive integer");
  }
  if (tour.segments.length === 0) {
    errors.push("Tour must include at least one segment");
  }

  tour.segments.forEach((segment, segmentIndex) => {
    if (!segment.id.trim()) {
      errors.push(`Segment ${segmentIndex} must have an ID`);
    }
    if (segmentIds.has(segment.id)) {
      errors.push(`Duplicate segment ID: ${segment.id}`);
    }
    segmentIds.add(segment.id);

    if (!segment.pathname.startsWith("/")) {
      errors.push(
        `Segment ${segment.id} pathname must start with /`,
      );
    }
    if (pathnames.has(segment.pathname)) {
      errors.push(
        `Duplicate segment pathname: ${segment.pathname}`,
      );
    }
    pathnames.add(segment.pathname);

    if (segment.steps.length === 0) {
      errors.push(`Segment ${segment.id} must include a step`);
    }

    const selectors = new Set<string>();

    segment.steps.forEach((tourStep, stepIndex) => {
      const stepId =
        typeof tourStep.data?.id === "string"
          ? tourStep.data.id.trim()
          : "";

      if (!stepId) {
        errors.push(
          `Segment ${segment.id} step ${stepIndex} must have a data.id`,
        );
      }
      if (stepId && stepIds.has(stepId)) {
        errors.push(`Duplicate step ID: ${stepId}`);
      }
      if (stepId) stepIds.add(stepId);

      if (typeof tourStep.element === "string") {
        if (selectors.has(tourStep.element)) {
          errors.push(
            `Duplicate selector in segment ${segment.id}: ` +
              tourStep.element,
          );
        }
        selectors.add(tourStep.element);
      }

      if (
        stepIndex === segment.steps.length - 1 &&
        tourStep.popover?.onDoneClick
      ) {
        errors.push(
          `Segment ${segment.id} final step must not override onDoneClick`,
        );
      }
    });
  });

  return errors;
}

Run this from the provider outside production only. It checks structure, not actual DOM presence, since Suspense and portals can render a target later than the validator would expect. Browser tests are what confirm real DOM behavior, which is what Step 14 covers.

Step 13: Add the translation keys

The provider reads every label and every step's copy from one namespace. Here's a shortened English example:

json
{
  "common": {
    "productTour": {
      "trigger": "Guided tour",
      "active": "Tour in progress",
      "next": "Next",
      "previous": "Back",
      "continue": "Continue",
      "finish": "Finish",
      "progress": "{{current}} of {{total}}",
      "steps": {
        "search": {
          "title": "Find the right vehicle",
          "description": "Choose a vehicle type or describe the model you are looking for."
        },
        "aiGuide": {
          "title": "Recommendations for your needs",
          "description": "The guide narrows the available vehicles with a few short questions."
        },
        "filters": {
          "title": "Refine your selection",
          "description": "Filter by brand, price, monthly payment, and other important details."
        }
      }
    }
  }
}

Keep analytics IDs out of the translation files entirely. Copy changes often, and the event contract you rely on for reporting should stay fixed regardless. My guide on Next.js internationalization architecture covers the three-layer next-intl setup this tour's translations sit on top of, if you're wiring up locales from scratch.

Step 14: Test the real browser behavior

Unit tests cover versioned keys, corrupted resume state, and definition validation well. They can't confirm that the overlay actually lands on the correct responsive element, which is exactly what Playwright is for:

ts
// File: e2e/product-tour.spec.ts
import { expect, test } from "@playwright/test";

test.describe("marketplace product tour", () => {
  test.beforeEach(async ({ page }) => {
    await page.addInitScript(() => {
      window.localStorage.clear();
      window.sessionStorage.clear();
    });
    await page.goto("/en", { waitUntil: "domcontentloaded" });
  });

  test(
    "starts automatically, dismisses, and restarts manually",
    async ({ page }) => {
      await expect(page.locator(".driver-popover")).toBeVisible();
      await expect(
        page.locator('[data-tour="marketplace-search"]'),
      ).toHaveClass(/driver-active-element/);

      await page.locator(".driver-popover-close-btn").click();
      await expect(page.locator(".driver-popover")).toHaveCount(0);

      await page
        .getByRole("button", { name: "Guided tour" })
        .click();
      await expect(page.locator(".driver-popover")).toBeVisible();
    },
  );

  test("resumes after App Router navigation", async ({ page }) => {
    await page.locator(".driver-popover-next-btn").click();
    await page.locator(".driver-popover-next-btn").click();

    await expect(page).toHaveURL(/\/en\/vehicles$/);
    await expect(page.locator(".driver-popover")).toBeVisible();
  });

  test("uses the mobile filter target", async ({ page }) => {
    await page.setViewportSize({ width: 390, height: 844 });
    await page.evaluate(() => {
      window.localStorage.clear();
      window.sessionStorage.clear();
    });
    await page.reload({ waitUntil: "domcontentloaded" });

    await expect(page.locator(".driver-popover")).toBeVisible();
    await page.locator(".driver-popover-next-btn").click();
    await page.locator(".driver-popover-next-btn").click();

    await expect(page).toHaveURL(/\/en\/vehicles$/);
    await expect(
      page.locator('[data-tour="vehicle-filters-mobile"]'),
    ).toHaveClass(/driver-active-element/);
  });

  test("respects reduced motion", async ({ page }) => {
    await page.emulateMedia({ reducedMotion: "reduce" });
    await page.evaluate(() => {
      window.localStorage.clear();
      window.sessionStorage.clear();
    });
    await page.reload({ waitUntil: "domcontentloaded" });

    await expect(page.locator(".driver-popover")).toBeVisible();
    await expect(page.locator("body")).toHaveClass(/driver-simple/);

    const duration = await page
      .locator(".driver-popover")
      .evaluate(
        (popover) =>
          window.getComputedStyle(popover).animationDuration,
      );

    expect(duration).toBe("0s");
  });
});

My full Avtolibre suite also covers completion storage and filters console errors for hydration, window is not defined, and document is not defined failures. Run the narrow tests first:

bash
pnpm exec vitest run src/tests/unit/product-tour.test.ts
pnpm exec playwright test product-tour.spec.ts --project=default --workers=1

Follow up with the application's normal lint, type-check, and production build commands before merging.

Common mistakes to check for

MistakeFix
Importing Driver.js at module scopeImport it dynamically inside an effect or a user action, after hydration.
Turning the entire page into a Client ComponentKeep the client boundary around the provider and trigger; Server Components can render data-tour attributes fine.
Targeting Tailwind classes for tour stepsUse dedicated data-tour attributes, since styling classes describe appearance and change during redesigns.
Keeping one Driver.js instance alive across routesSave resume state, destroy the instance, navigate, and start a fresh segment once usePathname() changes.
Using a fixed timeout for async UIUse waitForElement, advanceOnClick, or an explicit data-tour-ready signal instead of guessing hydration timing.
Enabling skipMissingElement globallyKeep it false by default and opt in per step, only for targets that are genuinely optional.
Overriding Done or Destroy hooks without owning teardownExplicitly move or destroy the Driver.js instance inside any custom hook that replaces default behavior.
Treating completion and dismissal as the same eventTrack intent with refs, since destroy() fires for completion, route transitions, unmounting, and dismissal alike.
Replaying auto-start on every visitPersist a versioned seen key, auto-start once per version, and leave a manual restart button available permanently.

Accessibility and privacy checklist

Before shipping a tour like this, confirm:

  • Escape and the close button both dismiss the tour.
  • Previous, Next, and Finish are all keyboard reachable.
  • Focus indicators stay visible throughout.
  • Popovers fit at mobile widths and at 200% zoom.
  • Reduced-motion users get no animated transition.
  • The tour never traps a user permanently.
  • Cookie, privacy, or emergency controls stay accessible while the tour runs.
  • Translated copy holds sufficient contrast in every supported theme.
  • Analytics events carry no personal, search, or financial data.
  • Any CMS or user-provided HTML gets sanitized before it reaches popover copy.

Adding another step later

Whenever the tour changes, work through this sequence:

  1. Add one unique data-tour="..." attribute to a stable rendered element.
  2. Add the matching step with a globally unique data.id.
  3. Add translated title and description keys for every locale.
  4. Decide whether the target needs data-tour-ready or a longer waitForElement.
  5. Mark it optional only when its absence is genuinely valid product behavior.
  6. Update the validator and the Playwright coverage.
  7. Bump the tour version whenever order or meaning changes materially.
  8. Test desktop, mobile, reduced motion, dismissal, restart, completion, and cross-route resume.

FAQ

Does Driver.js support React out of the box? No. Driver.js is a vanilla JavaScript library with no React bindings, which is exactly why this implementation wraps it in a Client Component that manages its own lifecycle, refs, and cleanup by hand.

Why dynamically import Driver.js instead of importing it normally? A normal top-level import pulls the library into your initial client bundle even on routes where the tour never runs. A dynamic import inside an effect or a click handler defers that cost until the tour actually starts.

How do I stop the tour from re-running for every returning visitor? Store a seen flag in localStorage, keyed by tour ID and version, and check it before auto-starting. Bump the version number only when the tour changes enough to warrant showing it again.

What happens if a target element never appears? Driver.js waits up to the waitForElement duration and then either fails the step or skips it, depending on skipMissingElement. Set that flag per step rather than globally, so a genuinely broken selector still surfaces as an error instead of silently vanishing.

Can the tour run across multiple routes in Next.js App Router? Yes, by treating each route as its own segment with its own Driver.js instance. Save the target segment and step index to sessionStorage before navigating, destroy the current instance, and start a new one once usePathname() matches the destination.

Wrapping up

This structure keeps the strengths of the App Router intact. Pages stay server-rendered, Driver.js stays a small browser-only enhancement, Next.js keeps owning navigation, and state stays explicit and versioned. Responsive differences get resolved on purpose, analytics respect consent, and the Playwright suite proves the overlay actually appears and survives the full route sequence rather than just working in a local demo.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Further reading

  • Driver.js installation
  • Driver.js configuration
  • Driver.js theming
  • Next.js Server and Client Components
  • Next.js useRouter
  • Next.js usePathname

Thanks, Matija