In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
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
Existing layouts and pages stay Server Components.
A small Client Component owns Driver.js.
The JavaScript package is dynamically imported after hydration.
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:
The layout stays a Server Component throughout. Driver.js's JavaScript loads later, from the client runtime, using a dynamic import:
ts
const { driver } = awaitimport("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:
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:
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:
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:
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:
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:
Every ref in this provider closes a specific race condition or analytics ambiguity:
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:
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
() {
pathname = ();
((pathname)) {
;
}
(
);
}
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:
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:
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
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:
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
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.
Adding another step later
Whenever the tour changes, work through this sequence:
Add one unique data-tour="..." attribute to a stable rendered element.
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.