Wakeline
wakeline for next.js

Next.js product tour libraries compared (2026).

Next.js adds three problems a plain React tour never meets: server rendering (hello, "window is not defined"), tours that cross App Router navigations, and remembering who has already seen what. Here are the libraries that handle them, with working App Router code, what each leaves you to build, and when buying honestly wins.

Libraries and licenses checked October 10, 2026.

Every React tour library technically works in Next.js — the friction is in the seams. Components in the App Router are Server Components by default, so a library that reaches for window or document at the wrong moment throws during prerendering. Tours that span pages have to survive a client-side route change, where the next step's element doesn't exist yet. And "don't show this twice" needs storage that the server can't read and localStorage can't sync across devices.

Two libraries were built specifically for Next.js — NextStep and Onborda — and two general-purpose ones (React Joyride and Driver.js) work well once you put them in the right kind of component. Everything below is written for the App Router; for the general React picture, see the React roundup.

Two well-known names are deliberately missing from the main list: Shepherd.js (and react-shepherd) and Intro.js are both AGPL-3.0, with paid commercial licenses for closed-source products. Both are good software, but for a commercial Next.js app they come with a license fee, so we've stuck to MIT libraries here.

For library-versus-library detail, see React Joyride alternatives (it covers NextStep and Onborda) and Driver.js vs React Joyride.

the open-source options

The libraries, honestly — with the code.

NextStep — the actively maintained Next.js-native one

License:
MIT
Best for:
App Router teams that want multi-page tours with next/navigation built in

NextStep (the nextstepjs package) started as an Onborda-inspired library and has since become the more active of the two: v2 added navigation adapters for React Router and Remix, but Next.js is the default — a step's nextRoute is pushed through next/navigation with no setup. Tours are named, so one provider can hold several and you start them from anywhere with the useNextStep hook.

The details that matter in production are here too: onComplete and onSkip callbacks (your seen-state hook), selectorRetryAttempts for targets that render after a route change, keyboard navigation, and a default card styled inline, so Tailwind isn't required. It depends on motion for animation.

The App Router catch: those callbacks are functions, and functions can't be passed from a Server Component. Put the provider in its own "use client" file and wrap your layout's children with it, as below.

// app/tour-provider.tsx — render <TourProvider> in app/layout.tsx
"use client";
import { NextStep, NextStepProvider, type Tour } from "nextstepjs";

const tours: Tour[] = [
  {
    tour: "onboarding",
    steps: [
      {
        title: "Start here",
        content: "Create your first project.",
        selector: "#new-project-btn",
        side: "bottom",
        showControls: true,
        showSkip: true,
        nextRoute: "/settings", // pushed via next/navigation
      },
      {
        title: "Bring the team",
        content: "Invite a teammate from settings.",
        selector: "#invite-field",
        selectorRetryAttempts: 10, // wait for the new route to render
        showControls: true,
      },
    ],
  },
];

const markDone = (tour: string | null) => localStorage.setItem(`tour:${tour}`, "done");

export function TourProvider({ children }: { children: React.ReactNode }) {
  return (
    <NextStepProvider>
      <NextStep
        steps={tours}
        onComplete={markDone}
        onSkip={(_step: number, tour: string | null) => markDone(tour)}
      >
        {children}
      </NextStep>
    </NextStepProvider>
  );
}

// Anywhere in a client component:
//   const { startNextStep } = useNextStep();
//   startNextStep("onboarding");
NextStep 2.x: a client-side provider, named tours, routes via next/navigation.

Onborda — the original Next.js + Tailwind option

License:
MIT
Best for:
Tailwind/shadcn apps that want a tour that looks native with little styling work

Onborda was the first tour library built for the App Router: a provider in your root layout, named tours, steps that target elements by id, and nextRoute/prevRoute on steps to move between pages with next/navigation. Its default card is built with Tailwind and pairs naturally with shadcn/ui, and its components ship with "use client", so the provider can sit straight in a server layout.

Two honest caveats. Tailwind must scan the package's dist folder or the default card renders unstyled (or you supply your own cardComponent). And the momentum has moved: the latest npm release is v1.2.5 from December 2024, and there's no onComplete-style callback, so you track completion yourself. It still works, but check the repo's pulse against your Next.js version before you rely on it — or use NextStep, which grew out of it.

// app/layout.tsx
import { Onborda, OnbordaProvider } from "onborda";
import type { OnbordaProps } from "onborda";

const tours: OnbordaProps["steps"] = [
  {
    tour: "onboarding",
    steps: [
      {
        icon: null,
        title: "Your projects",
        content: "Everything you build lives here.",
        selector: "#projects-nav",
        side: "right",
        showControls: true,
        nextRoute: "/projects", // navigated with next/navigation
      },
      {
        icon: null,
        title: "Start here",
        content: "Create your first project.",
        selector: "#new-project-btn",
        side: "bottom",
        showControls: true,
      },
    ],
  },
];

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <OnbordaProvider>
          <Onborda steps={tours} shadowOpacity="0.6">
            {children}
          </Onborda>
        </OnbordaProvider>
      </body>
    </html>
  );
}

// In a client component:
//   const { startOnborda } = useOnborda();
//   startOnborda("onboarding");
Onborda: provider in the root layout, tours started from the useOnborda hook.

React Joyride — the React default, App Router-ready

License:
MIT
Best for:
Teams that want the most battle-tested React tour component

Joyride v3 is the most widely used React tour library and now describes itself as SSR-safe; its build ships with a "use client" directive. In practice you still write a small client component around it, because a tour that does anything (stops when skipped, records completion) needs state and an onEvent handler, and handlers can't cross the server–client boundary.

v3 changed the API in ways that break most tutorials ("react joyride next" searches turn up plenty of v2 code): it's a named export now, run defaults to false, showProgress and the button set moved into an options prop, and callback became onEvent. It doesn't know about routes, so multi-page tours mean controlled mode (stepIndex) plus your own router.push calls.

"use client";
import { useState } from "react";
import { Joyride, STATUS } from "react-joyride"; // v3: named export

const steps = [
  { target: "#new-project-btn", content: "Everything starts here — create your first project." },
  { target: "#invite-field", content: "Onboarding sticks better with a teammate aboard." },
];

export function OnboardingTour() {
  const [run, setRun] = useState(true); // v3: run defaults to false

  return (
    <Joyride
      run={run}
      steps={steps}
      continuous
      options={{ showProgress: true, buttons: ["back", "skip", "primary"] }}
      onEvent={({ status }) => {
        if (status === STATUS.FINISHED || status === STATUS.SKIPPED) setRun(false);
      }}
    />
  );
}
Joyride v3 in a client component — render <OnboardingTour /> from any page or layout.

Driver.js — the lightweight one

License:
MIT
Best for:
Feature highlights and short tours with no React-specific dependency

Driver.js is framework-agnostic and dependency-free, which in Next.js means one rule: create the driver inside useEffect, in a "use client" component. Importing it is harmless on the server; calling driver() or drive() touches document and throws during prerender. useEffect only runs in the browser, so the SSR error never happens.

Clean-up matters more than in a plain SPA: call tour.destroy() in the effect's cleanup, or the overlay outlives a client-side navigation — and React Strict Mode's dev-only remount will fire your onDestroyed hook once. The guard below keeps that from marking the tour as seen. For tours that span pages, Driver.js's official multi-page tour guide saves the next step index, destroys the tour as the link navigates, and resumes with drive(index) on the next page, with waitForElement absorbing the render delay.

"use client";
import { useEffect } from "react";
import { driver } from "driver.js";
import "driver.js/dist/driver.css";

export function OnboardingTour() {
  useEffect(() => {
    // driver() touches document, so it must run in the browser, after mount.
    if (localStorage.getItem("tour:onboarding")) return;
    let unmounting = false;
    const tour = driver({
      showProgress: true,
      steps: [
        {
          element: "#new-project-btn",
          popover: { title: "Start here", description: "Create your first project." },
        },
        {
          element: "#invite-field",
          popover: { title: "Bring the team", description: "Invite a teammate early." },
        },
      ],
      // Finished or closed: remember it. Skipped when React unmounts us.
      onDestroyed: () => {
        if (!unmounting) localStorage.setItem("tour:onboarding", "done");
      },
    });
    tour.drive();
    return () => {
      unmounting = true; // Strict Mode remounts, route changes
      tour.destroy();
    };
  }, []);

  return null;
}
Driver.js 1.x in the App Router: client component, init in useEffect, destroy on unmount.
the honest accounting

What every library leaves you to build.

The tour is the visible fifth of an onboarding system. These four builds are the rest — and they are where hand-rolled setups quietly stall.

Who edits the copy?

With every library above, a step's wording lives in a .tsx file — changing it means a pull request, a review, and a full Next.js build and deploy. The person who owns activation files a ticket and waits.

Targeting is a second project

New users only, admins but not viewers, re-show to people who never finished — every library leaves audiences and rules to you, usually split awkwardly between server session data and client components.

Seen-state is a third one

Every sample above uses localStorage, which the server can't read during render and which doesn't follow a user to their laptop. Doing it properly means a user-state table, an API route, and hydration-safe reads — forever.

Analytics is a fourth

Which step loses people, and did the tour move activation? Callbacks like onComplete and onEvent are where measurement starts, not where it ends — funnels, dashboards, and experiments are separate builds.

build vs. buy

The whole trade, in one table.

DimensionBuild on a libraryBuy (Wakeline)
Upfront costFree (library) + engineering days to integrateFree tier, then $49/mo for 25,000 MAU
Who edits a step's copyAn engineer, via a pull request and a deployAnyone on the team, in a visual builder, live in seconds
Targeting & segmentationYou build it — user traits, rules, audiencesIncluded: audiences, trait rules, saved segments
AnalyticsYou build it — step events, funnels, dashboardsIncluded: per-step funnels, trends, click actions
Seen-state & persistenceYou build it — per user, across devicesIncluded, cross-device for identified users
Surfaces beyond toursChecklists, surveys, banners: all separate buildsIncluded in the same builder
A/B testingYou build it, including the statisticsIncluded, with significance testing
Ongoing maintenanceYours — selector breakage, library upgrades, edge casesVendor's — anchors degrade gracefully, tooling maintained
the buy side

Wakeline with Next.js: deliberately boring.

Wakeline is the buy side, and the Next.js story is deliberately boring: no Next.js SDK, no provider, no next.config change. It's one script tag, loaded with next/script after hydration, plus an identify() call once you know who the user is. The SDK only ever runs in the browser, so Server Components, streaming, and prerendering never touch it.

  • App Router navigations are handled. The SDK watches client-side route changes, so a tour targeted at /settings fires when the user gets there via a Link, not just on a hard reload — and steps can pin to URL paths with wildcards.
  • Anchors wait for the page. Steps attach by selector to the live DOM and wait for elements that render after a route change or a Suspense boundary resolves — or skip gracefully.
  • Tours are built visually on your live app, no-code — copy changes go live without a rebuild or a redeploy.
  • The system gaps come filled: targeting and segments, cross-device seen-state, per-step funnels, A/B testing, plus checklists, surveys, and banners from the same builder.
// app/layout.tsx
import Script from "next/script";
import { getCurrentUser } from "@/lib/auth"; // your auth, server-side
import { WakelineUser } from "./wakeline-user";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const user = await getCurrentUser();
  return (
    <html lang="en">
      <body>
        {children}
        <WakelineUser user={user} />
        <Script src="https://wakeline.io/wakeline.js" strategy="afterInteractive" />
      </body>
    </html>
  );
}

// app/wakeline-user.tsx
"use client";
import { useEffect } from "react";

type User = { id: string; name: string; plan: string } | null;

export function WakelineUser({ user }: { user: User }) {
  useEffect(() => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const w = window as any;
    // The snippet's queue stub: calls made before wakeline.js loads are replayed.
    w.Wakeline = w.Wakeline || function () { (w.Wakeline.q = w.Wakeline.q || []).push(arguments); };
    w.Wakeline("init", { key: process.env.NEXT_PUBLIC_WAKELINE_KEY, apiHost: "https://wakeline.io" });
    // After login — unlocks targeting, segments, and cross-device state:
    if (user) w.Wakeline("identify", user.id, { name: user.name, plan: user.plan });
  }, [user]);

  return null;
}
The entire Next.js integration: next/script plus one client component. With the Pages Router, the same two pieces go in pages/_app.
honest limits

When building on a library is still right.

One static tour, engineer-owned

A single walkthrough that rarely changes fits NextStep or Driver.js fine — with no onboarding program behind it, the system gaps never bite.

Your users are developers

A dev-tool audience respects a hand-rolled tour, and a Next.js team can genuinely maintain one alongside the rest of the app.

Zero budget, genuinely

NextStep, Onborda, React Joyride and Driver.js are all MIT and free forever. (A free tier covering 1,000 MAU exists for exactly this stage — count the engineering hours honestly before deciding.)

The tour is part of the product

If a tour drives real app state — creating sample data, opening panels, gating a route — it belongs in your codebase. NextStep's route-aware steps or Joyride's controlled mode are good foundations.

questions

Next.js onboarding tours: common questions.

Still stuck? Read the docs or talk to us.

What is the best product tour library for Next.js?

For the App Router, NextStep (nextstepjs) is the most actively maintained Next.js-native option: route-aware steps via next/navigation, named tours, and completion callbacks. Onborda pioneered that design and suits Tailwind/shadcn apps, but its last release was December 2024. React Joyride v3 is the most proven general React option, and Driver.js is the lightest. All four are MIT; Shepherd.js and Intro.js are AGPL-3.0 with paid commercial licenses.

How do I fix "window is not defined" with a tour library in Next.js?

The library is touching the DOM while Next.js renders on the server. Move the tour into a component that starts with "use client" and create or start it inside useEffect, which only runs in the browser. If the library touches window or Element just by being imported, load that component with next/dynamic and { ssr: false } — note that ssr: false is only allowed inside a Client Component, not in a Server Component like your root layout.

How do I make a product tour span multiple pages in the App Router?

Either use a library that navigates for you — NextStep and Onborda both support nextRoute/prevRoute on a step, pushed through next/navigation — or follow Driver.js's documented multi-page pattern: save the next step index, destroy the tour as the user navigates, and resume with drive(index) on the next page. Either way, give the first step on the new page time to render (NextStep's selectorRetryAttempts, Driver.js's waitForElement).

Onborda or NextStep — which should I pick?

They share a design: provider in the layout, named tours, id selectors, nextRoute steps. NextStep credits Onborda as its inspiration and has kept moving — v2.3.0 shipped in July 2026 with onComplete and onSkip callbacks, selector retries, and Tailwind-free default styling. Onborda's last npm release was v1.2.5 in December 2024 and its default card needs Tailwind. For a new project, NextStep is the safer bet.

Does React Joyride work with Next.js?

Yes. v3 is SSR-safe and ships a "use client" directive, but put it in your own client component anyway, since handling onEvent needs client-side code. Watch for v2-era tutorials: v3 uses a named import ({ Joyride }), run defaults to false, showProgress and buttons moved into the options prop, and callback was renamed onEvent.

Does Wakeline need a Next.js SDK?

No. Load the script with next/script (strategy="afterInteractive") and call identify() from a small client component once the user is known — the code above is the whole integration, for the App Router or the Pages Router. If you'd rather install from npm, the framework-agnostic @wakeline/sdk package does the same job and no-ops on the server instead of throwing.

Ship a Next.js tour today — without the build.

The snippet takes five minutes; the free plan covers 1,000 monthly active users. Compare it against the library route on your own app.

Free plan available · No credit card · No demo call