---
title: "Production-Ready Driver.js Product Tour for Next.js 16"
slug: "build-driverjs-product-tour-nextjs-16"
published: "2026-07-19"
updated: "2026-08-02"
categories:
  - "Next.js"
tags:
  - "Driver.js product tour"
  - "Next.js product tour"
  - "Next.js 16 App Router"
  - "dynamic import"
  - "resume state"
  - "data-tour attributes"
  - "Playwright tests"
  - "localization"
  - "reduced motion"
  - "analytics consent"
  - "Tailwind CSS"
  - "TypeScript"
llm-intent: "reference"
audience-level: "advanced"
framework-versions:
  - "next.js 16.2.6"
  - "react 19.2"
  - "driver.js 1.8.0"
  - "typescript"
  - "next-intl"
status: "stable"
llm-purpose: "Driver.js product tour — Next.js 16 guide to build a production-ready, localized tour with dynamic import, resume state, Playwright tests, and analytics."
llm-prereqs:
  - "Access to Next.js 16.2.6"
  - "Access to React 19.2"
  - "Access to Driver.js 1.8.0"
  - "Access to TypeScript"
  - "Access to next-intl"
llm-outputs:
  - "Completed outcome: Driver.js product tour — Next.js 16 guide to build a production-ready, localized tour with dynamic import, resume state, Playwright tests, and analytics."
---

**Summary Triples**
- (Single Client Component, owns, Driver.js lifecycle including dynamic import, per-route segments, resume state, and cleanup)
- (Dynamic import, prevents, hydration and SSR/runtime errors by loading Driver.js only on the client)
- (App Router, requires, a single client-side driver boundary to persist tours across route navigation)
- (data-tour attributes, identify, target elements consistently across routes, locales, and responsive layouts)
- (Resume state, persisted in, a storage layer (e.g., localStorage) so tours survive refresh/navigation)
- (Reduced motion, handled by, checking prefers-reduced-motion and disabling/adjusting Driver.js animations)
- (Analytics events, deferred until, explicit user consent (e.g., trigger GTM only after consent))
- (Playwright tests, verify, tour lifecycle across routes: start, step navigation, cross-route targeting, resume state, and cleanup)
- (Theming, applied via, a provider that maps site theme to Driver.js theme for visual consistency)
- (Cleanup, required on, route change or component unmount to remove overlays and event handlers)
- (Production tour, example, 8 steps across 3 locale-prefixed routes (search, AI guide; filters, results, monthly cost; leasing calculator steps))

### {GOAL}
Driver.js product tour — Next.js 16 guide to build a production-ready, localized tour with dynamic import, resume state, Playwright tests, and analytics.

### {PREREQS}
- Access to Next.js 16.2.6
- Access to React 19.2
- Access to Driver.js 1.8.0
- Access to TypeScript
- Access to next-intl

### {STEPS}
1. Install Driver.js with pinned version
2. Load Driver.js CSS in shared layout
3. Dynamically import Driver.js in client
4. Use stable data-tour DOM attributes
5. Define typed tour structure and segments
6. Persist seen, completed, and resume
7. Respect reduced motion and analytics consent
8. Implement the ProductTour client provider
9. Add a manual restart trigger
10. Mount provider at narrow shared boundary
11. Theme Driver.js with design tokens
12. Validate and test with Playwright

<!-- llm:goal="Driver.js product tour — Next.js 16 guide to build a production-ready, localized tour with dynamic import, resume state, Playwright tests, and analytics." -->
<!-- llm:prereq="Access to Next.js 16.2.6" -->
<!-- llm:prereq="Access to React 19.2" -->
<!-- llm:prereq="Access to Driver.js 1.8.0" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to next-intl" -->
<!-- llm:output="Completed outcome: Driver.js product tour — Next.js 16 guide to build a production-ready, localized tour with dynamic import, resume state, Playwright tests, and analytics." -->

# Production-Ready Driver.js Product Tour for Next.js 16
> Driver.js product tour — Next.js 16 guide to build a production-ready, localized tour with dynamic import, resume state, Playwright tests, and analytics.
Matija Žiberna · 2026-07-19

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:

| Ref | What it prevents |
|---|---|
| `pathnameRef` | A delayed dynamic import starting on a route the user already left |
| `startingRef` | Two effects or two clicks starting two instances at once |
| `routeTransitionRef` | Navigation teardown being logged as a dismissal |
| `completedRef` | Completion being logged as a dismissal |
| `dismissedRef` | The same dismissal firing twice |
| `teardownRef` | React unmount cleanup being logged as a dismissal |
| `generationRef` | Stale async work resuming after stop, reset, or unmount |
| `mountedRef` | State 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](https://www.buildwithmatija.com/blog/nextjs-internationalization-architecture-guide) 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

<table>
<thead>
<tr><th>Mistake</th><th>Fix</th></tr>
</thead>
<tbody>
<tr><td>Importing Driver.js at module scope</td><td>Import it dynamically inside an effect or a user action, after hydration.</td></tr>
<tr><td>Turning the entire page into a Client Component</td><td>Keep the client boundary around the provider and trigger; Server Components can render <code>data-tour</code> attributes fine.</td></tr>
<tr><td>Targeting Tailwind classes for tour steps</td><td>Use dedicated <code>data-tour</code> attributes, since styling classes describe appearance and change during redesigns.</td></tr>
<tr><td>Keeping one Driver.js instance alive across routes</td><td>Save resume state, destroy the instance, navigate, and start a fresh segment once <code>usePathname()</code> changes.</td></tr>
<tr><td>Using a fixed timeout for async UI</td><td>Use <code>waitForElement</code>, <code>advanceOnClick</code>, or an explicit <code>data-tour-ready</code> signal instead of guessing hydration timing.</td></tr>
<tr><td>Enabling <code>skipMissingElement</code> globally</td><td>Keep it false by default and opt in per step, only for targets that are genuinely optional.</td></tr>
<tr><td>Overriding Done or Destroy hooks without owning teardown</td><td>Explicitly move or destroy the Driver.js instance inside any custom hook that replaces default behavior.</td></tr>
<tr><td>Treating completion and dismissal as the same event</td><td>Track intent with refs, since <code>destroy()</code> fires for completion, route transitions, unmounting, and dismissal alike.</td></tr>
<tr><td>Replaying auto-start on every visit</td><td>Persist a versioned seen key, auto-start once per version, and leave a manual restart button available permanently.</td></tr>
</tbody>
</table>

## 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](https://driverjs.com/docs/installation)
- [Driver.js configuration](https://driverjs.com/docs/configuration)
- [Driver.js theming](https://driverjs.com/docs/theming)
- [Next.js Server and Client Components](https://nextjs.org/docs/app/getting-started/server-and-client-components)
- [Next.js `useRouter`](https://nextjs.org/docs/app/api-reference/functions/use-router)
- [Next.js `usePathname`](https://nextjs.org/docs/app/api-reference/functions/use-pathname)

Thanks, Matija

## LLM Response Snippet
```json
{
  "goal": "Driver.js product tour — Next.js 16 guide to build a production-ready, localized tour with dynamic import, resume state, Playwright tests, and analytics.",
  "responses": [
    {
      "question": "What does the article \"Production-Ready Driver.js Product Tour for Next.js 16\" cover?",
      "answer": "Driver.js product tour — Next.js 16 guide to build a production-ready, localized tour with dynamic import, resume state, Playwright tests, and analytics."
    }
  ]
}
```