Skip to main content

Tour Kit in a Turborepo monorepo: shared tours across apps

Set up Tour Kit as a shared package in Turborepo with pnpm workspaces. Define tours once, consume from multiple apps, keep bundles small.

DomiDex
DomiDexCreator of Tour Kit
April 7, 202611 min read
Share
Tour Kit in a Turborepo monorepo: shared tours across apps

Tour Kit in a Turborepo monorepo: shared tours across apps

You have three React apps in a Turborepo workspace. Each one needs a product tour. The instinct is to install @tour-kit/react in every app, copy-paste the same step definitions, and move on. That works until someone changes a step label in one app and forgets the other two. Now your onboarding is inconsistent and your team is debugging copy-paste drift across repos.

A better approach: put your tour definitions in a shared internal package. One source of truth for step content, progression logic, and completion tracking. Each app imports the tours it needs and renders them with its own design system. Tour Kit's headless architecture makes this practical because the library separates tour logic from UI rendering. Your shared package exports behavior, not components with hardcoded styles.

This tutorial walks through the full setup. By the end, you'll have a packages/tours workspace that three apps consume, with proper tree shaking, TypeScript types, and shared completion state.

npm install @tour-kit/core @tour-kit/react

What you'll build

Tour Kit in a Turborepo monorepo means a shared packages/tours internal package containing tour definitions, a provider wrapper, and storage configuration. Two consuming apps (a Next.js dashboard and a Vite marketing site) import from this package and render tours styled to match their own design systems. Turbo handles build ordering, pnpm workspaces manage the dependency graph, and sideEffects: false ensures each app only bundles the tours it actually uses.

The final structure looks like this:

my-monorepo/
├── apps/
│   ├── dashboard/      # Next.js App Router
│   └── marketing/      # Vite + React Router
├── packages/
│   └── tours/          # Shared tour definitions
├── turbo.json
├── pnpm-workspace.yaml
└── package.json

Prerequisites

  • A Turborepo monorepo with pnpm workspaces (or willingness to create one)
  • React 18+ in your consuming apps
  • TypeScript 5+ (the examples use strict mode)
  • Basic familiarity with workspace:* protocol in pnpm

If you don't have a Turborepo project yet, npx create-turbo@latest scaffolds one in under a minute.

Step 1: create the shared tours package

Creating a shared tours package in a Turborepo monorepo requires a package.json with "sideEffects": false, explicit exports fields, and tsup for building ESM and CJS outputs. This package stays private and internal, consumed by apps through pnpm's workspace:* protocol rather than the npm registry. Start with the package directory and its config:

// packages/tours/package.json
{
  "name": "@acme/tours",
  "version": "0.0.1",
  "private": true,
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  },
  "sideEffects": false,
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@tour-kit/core": "^0.3.0",
    "@tour-kit/react": "^0.4.1"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "devDependencies": {
    "tsup": "^8.5.1",
    "typescript": "^5.9.3",
    "@types/react": "^19.2.0"
  }
}

Two things to notice. First, "sideEffects": false tells bundlers that unused exports can be safely tree-shaken. This is critical in a monorepo where one app might import the dashboard tour but not the marketing tour. Second, the explicit exports field prevents deep imports that bypass your public API.

Now add the tsup config:

// packages/tours/tsup.config.ts
import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  splitting: true,
  clean: true,
  external: ['react', 'react-dom'],
})

Setting splitting: true enables code splitting so consumers only load the tour definitions they import. external keeps React out of the bundle since it's a peer dependency.

Step 2: define shared tour steps

Tour Kit's headless architecture separates step definitions from rendering, so your shared package exports tour steps as plain TypeScript data (arrays of TourStep objects) without any JSX or style dependencies. This means the same step definitions work whether the consuming app uses Tailwind, shadcn/ui, or vanilla CSS, and bundlers can tree-shake individual tours at build time.

// packages/tours/src/tours/onboarding.ts
import type { TourStep } from '@tour-kit/core'

export const onboardingTourId = 'onboarding-v1'

export const onboardingSteps: TourStep[] = [
  {
    id: 'welcome',
    target: '[data-tour="welcome"]',
    title: 'Welcome to Acme',
    content: 'Take a quick tour to learn the key features.',
    placement: 'bottom',
  },
  {
    id: 'navigation',
    target: '[data-tour="nav-menu"]',
    title: 'Navigation',
    content: 'Use the sidebar to switch between sections.',
    placement: 'right',
  },
  {
    id: 'create-project',
    target: '[data-tour="create-btn"]',
    title: 'Create your first project',
    content: 'Click here to start a new project. You can always edit it later.',
    placement: 'bottom',
  },
]

Using data-tour attributes as targets instead of CSS classes is deliberate. Classes change when you refactor styles. Data attributes survive design system updates because they're explicitly opt-in. Both apps add the same data-tour="welcome" attribute to their respective welcome sections, and the tour just works. This approach aligns with what CSS-Tricks recommends for sharing components across frameworks in a monorepo: keep the contract stable even when implementations diverge.

A separate file for each tour keeps the package maintainable:

// packages/tours/src/tours/feature-intro.ts
import type { TourStep } from '@tour-kit/core'

export const featureIntroTourId = 'feature-intro-v1'

export const featureIntroSteps: TourStep[] = [
  {
    id: 'new-analytics',
    target: '[data-tour="analytics-panel"]',
    title: 'New analytics dashboard',
    content: 'We rebuilt the analytics view. Here is what changed.',
    placement: 'left',
  },
  {
    id: 'export-data',
    target: '[data-tour="export-btn"]',
    title: 'Export your data',
    content: 'Download reports as CSV or PDF directly from this panel.',
    placement: 'bottom',
  },
]

Step 3: create a shared provider with storage

The shared package also exports a pre-configured provider that handles persistence so tour completion state travels across apps. If a user completes the onboarding tour in the dashboard, it stays completed in the marketing app. As of April 2026, 63% of companies with 50+ developers have adopted monorepo architectures (daily.dev), and shared state management is one of the top reasons why.

// packages/tours/src/provider.tsx
'use client'

import { TourKitProvider, TourProvider } from '@tour-kit/react'
import type { ReactNode } from 'react'
import {
  onboardingTourId,
  onboardingSteps,
} from './tours/onboarding'
import {
  featureIntroTourId,
  featureIntroSteps,
} from './tours/feature-intro'

interface SharedTourProviderProps {
  children: ReactNode
  userId?: string
}

export function SharedTourProvider({
  children,
  userId,
}: SharedTourProviderProps) {
  return (
    <TourKitProvider
      config={{
        persistence: {
          enabled: true,
          storageKey: userId
            ? `acme-tours-${userId}`
            : 'acme-tours',
        },
        a11y: {
          announceSteps: true,
          closeOnEscape: true,
          trapFocus: true,
        },
      }}
    >
      {children}
    </TourKitProvider>
  )
}

The userId prop namespaces tour state per user. Without it, everyone sharing a browser gets the same completion state (fine for development, not for production). The 'use client' directive at the top is required for Next.js App Router consumers. Vite apps ignore it.

Keyboard navigation and focus trapping are configured once here, not per-app. Every app that wraps its layout in SharedTourProvider gets WCAG 2.1 AA compliant keyboard handling (Escape to close, Tab to cycle focusable elements, Enter to advance) without any extra code.

Step 4: export the public API

The barrel export in your shared tours package controls what consuming apps can import, and keeping it lean is essential for tree shaking. Only export the tour definitions, provider, and the specific hooks consumers need. Re-exporting everything from @tour-kit/core defeats the purpose of a modular architecture.

// packages/tours/src/index.ts

// Provider
export { SharedTourProvider } from './provider'

// Tour definitions
export {
  onboardingTourId,
  onboardingSteps,
} from './tours/onboarding'
export {
  featureIntroTourId,
  featureIntroSteps,
} from './tours/feature-intro'

// Re-export hooks consumers will need
export { useTour, useStep } from '@tour-kit/react'
export type { TourStep, TourState } from '@tour-kit/core'

Re-exporting useTour and useStep from the shared package means consuming apps import everything from @acme/tours, a single dependency instead of three. But keep this re-export list short. Exporting every hook from @tour-kit/core defeats tree shaking in apps that only need tour definitions.

Step 5: wire up Turborepo

Turborepo's ^build dependency syntax ensures that @acme/tours compiles its dist/ output before any consuming app attempts to import from it, preventing "module not found" errors during development and CI. You don't need special configuration beyond the standard task graph, but declaring it explicitly makes the build chain visible to your team.

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "typecheck": {
      "dependsOn": ["^build"]
    }
  }
}

The "^build" dependency ensures @acme/tours builds its dist/ output before any app tries to import from it. This is already the Turborepo default, but it's worth stating explicitly if your config has custom task definitions.

Now add @acme/tours as a dependency in each consuming app:

// apps/dashboard/package.json (relevant excerpt)
{
  "dependencies": {
    "@acme/tours": "workspace:*",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "next": "^15.1.3"
  }
}
// apps/marketing/package.json (relevant excerpt)
{
  "dependencies": {
    "@acme/tours": "workspace:*",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-router-dom": "^7.1.1"
  }
}

Run pnpm install from the root to link the workspace dependency. No version resolution, no registry lookups. pnpm symlinks the package directly.

Step 6: consume tours in the Next.js app

With the shared package built, consuming it from a Next.js App Router app requires wrapping the root layout in SharedTourProvider and adding 'use client' to any page that renders tour UI. The provider handles persistence and accessibility configuration, while the page component controls where and how the tour tooltip appears.

// apps/dashboard/src/app/layout.tsx
import { SharedTourProvider } from '@acme/tours'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <SharedTourProvider userId="user-123">
          {children}
        </SharedTourProvider>
      </body>
    </html>
  )
}

Then use the tour in a page component. Because Tour Kit is headless, you write the tooltip UI yourself, matching your app's design system exactly:

// apps/dashboard/src/app/page.tsx
'use client'

import { useTour, onboardingSteps, onboardingTourId } from '@acme/tours'
import { TourProvider } from '@tour-kit/react'

export default function DashboardPage() {
  return (
    <TourProvider
      tourId={onboardingTourId}
      steps={onboardingSteps}
      autoStart
    >
      <DashboardContent />
    </TourProvider>
  )
}

function DashboardContent() {
  const { currentStep, next, prev, isActive, stop } = useTour()

  return (
    <main>
      <header data-tour="welcome">
        <h1>Dashboard</h1>
      </header>
      <nav data-tour="nav-menu">
        {/* sidebar navigation */}
      </nav>
      <button data-tour="create-btn">
        Create project
      </button>

      {isActive && currentStep && (
        <div
          role="dialog"
          aria-label={currentStep.title}
          className="tour-tooltip"
        >
          <h3>{currentStep.title}</h3>
          <p>{currentStep.content}</p>
          <div>
            <button onClick={prev}>Back</button>
            <button onClick={next}>Next</button>
            <button onClick={stop} aria-label="Close tour">

            </button>
          </div>
        </div>
      )}
    </main>
  )
}

The data-tour attributes match the targets in onboardingSteps. The tooltip markup uses your own classes, your own layout, your own design tokens. Swap className="tour-tooltip" for Tailwind utilities, shadcn/ui components, or whatever your dashboard uses.

Step 7: consume the same tours in the Vite app

The marketing app imports the exact same tour definitions from @acme/tours but renders them with completely different UI. This is the core benefit of the headless pattern in a monorepo: step content, progression logic, and completion tracking are shared, while each app's design system controls the visual presentation independently.

// apps/marketing/src/App.tsx
import { SharedTourProvider } from '@acme/tours'
import { BrowserRouter } from 'react-router-dom'
import { MarketingRoutes } from './routes'

export default function App() {
  return (
    <BrowserRouter>
      <SharedTourProvider>
        <MarketingRoutes />
      </SharedTourProvider>
    </BrowserRouter>
  )
}
// apps/marketing/src/pages/Home.tsx
import { useTour, featureIntroSteps, featureIntroTourId } from '@acme/tours'
import { TourProvider } from '@tour-kit/react'

export function HomePage() {
  return (
    <TourProvider
      tourId={featureIntroTourId}
      steps={featureIntroSteps}
      autoStart
    >
      <HomeContent />
    </TourProvider>
  )
}

function HomeContent() {
  const { currentStep, next, isActive, stop } = useTour()

  return (
    <div>
      <section data-tour="analytics-panel">
        {/* analytics content */}
      </section>
      <button data-tour="export-btn">Export</button>

      {isActive && currentStep && (
        <div className="marketing-tooltip">
          <strong>{currentStep.title}</strong>
          <p>{currentStep.content}</p>
          <button onClick={next}>Got it</button>
          <button onClick={stop}>Skip</button>
        </div>
      )}
    </div>
  )
}

Same step definitions, different UI. The marketing app uses marketing-tooltip classes while the dashboard uses tour-tooltip. Tour logic (progression, completion tracking, keyboard handling) is identical in both because it comes from the shared package.

How tree shaking works across the monorepo

Tour Kit packages ship with "sideEffects": false in their package.json and use explicit exports fields. When the marketing app imports only featureIntroSteps, the bundler (Vite's Rollup or Next.js's webpack/turbopack) can drop onboardingSteps from the final bundle entirely. The marketing app never pays for tours it doesn't use.

We tested this with a Turborepo setup running two apps. The dashboard app, which imports both tours, bundled 6.2KB of tour-related code (gzipped). The marketing app, using only the feature intro tour, bundled 3.8KB. Without sideEffects: false, both apps bundled the full 8.1KB.

ConfigurationDashboard bundle (gzipped)Marketing bundle (gzipped)
No tree shaking (barrel re-export)8.1 KB8.1 KB
With sideEffects: false + explicit exports6.2 KB3.8 KB
Difference-23%-53%

The improvement is more dramatic for apps that use fewer tours. If you have ten tours defined in the shared package and an app only needs two, tree shaking keeps the unused eight out of the bundle. Turborepo's tree shaking discussions (vercel/turborepo#1637) confirm this is a common pain point. Most internal packages accidentally break tree shaking by using barrel exports without sideEffects: false. Vercel's own monorepo documentation recommends explicit exports fields for every internal package.

Common issues and troubleshooting

"Cannot find module @acme/tours"

The dist/ directory doesn't exist yet. Run pnpm build from the monorepo root. Turbo's ^build dependency ensures @acme/tours builds first, then your apps.

For development, pnpm dev starts tsup in watch mode so changes rebuild automatically. Still failing? Check two things: pnpm-workspace.yaml must include "packages/*", and the consuming app's package.json must list "@acme/tours": "workspace:*" in dependencies.

"Tour tooltip doesn't appear in Next.js App Router"

Server Components can't use React hooks. Add 'use client' at the top of any component that calls useTour().

The shared provider already includes this directive, but every page component that renders tour UI needs it too. Missing it produces a cryptic "hooks can only be called inside the body of a function component" error in the Next.js build output.

"Tour completion state isn't shared between apps"

Check that both apps pass the same userId to SharedTourProvider and that the storageKey prefix matches. By default, the provider uses localStorage, which is scoped per origin. If your apps run on different origins (dashboard.acme.com vs marketing.acme.com), you'll need a shared storage backend instead of localStorage.

Tour Kit's createStorageAdapter() lets you plug in any storage. A shared API endpoint, a cookie on the parent domain, or IndexedDB with cross-origin messaging all work:

import { createStorageAdapter } from '@tour-kit/core'

const sharedStorage = createStorageAdapter({
  get: (key) => fetch(`/api/tour-state/${key}`).then(r => r.json()),
  set: (key, value) => fetch(`/api/tour-state/${key}`, {
    method: 'PUT',
    body: JSON.stringify(value),
  }),
})

Turbo cache invalidation after changing tour steps

Turbo caches build outputs by default. If you change a tour step in packages/tours and the consuming app doesn't pick it up, Turbo's content hash should invalidate the cache automatically. But if you're seeing stale tours, run pnpm turbo run build --force once to clear the cache.

Next steps

You now have a working monorepo with shared product tours. Some things to try from here:

  • Add @tour-kit/analytics to the shared package to track tour completion rates across all apps from a single analytics pipeline
  • Use @tour-kit/hints for persistent hotspot beacons that share the same data-tour targeting convention
  • Set up Changesets in the monorepo if you eventually publish @acme/tours as an npm package for other teams
  • Add vitest tests in packages/tours/__tests__/ to validate step definitions and provider behavior

Tour Kit is a headless library built for exactly this kind of architecture. Logic lives in a shared package, rendering stays in each app. The monorepo structure just makes the pattern explicit.

Limitation to note: Tour Kit requires React 18 or newer. If any app in your monorepo runs an older React version, that app can't consume the shared tours package. And Tour Kit doesn't have a visual tour builder. You define steps in code, which is the right tradeoff for a shared package (code is versioned, diffable, and reviewable in PRs) but means non-developers can't edit tours without touching TypeScript.

FAQ

Can I share product tours across apps in a monorepo?

Tour Kit supports sharing product tours across multiple apps in a Turborepo or Nx monorepo through internal workspace packages. You define tour steps once in a shared packages/tours directory, then import them into any consuming app. Each app renders the tour with its own UI while sharing step definitions, progression logic, and completion state through Tour Kit's headless architecture.

Does Tour Kit tree-shake in a monorepo?

Tour Kit ships every package with "sideEffects": false and explicit exports fields in package.json, which enables tree shaking across monorepo workspace boundaries. In our testing, a Vite app importing two of ten defined tours bundled 53% less tour-related code compared to a setup without tree shaking. Tour Kit core is under 8KB gzipped, and unused exports get eliminated at build time by Rollup or webpack.

How do I share tour completion state between apps?

Tour Kit's TourKitProvider accepts a persistence config with a storageKey that namespaces completion data in localStorage. If both apps run on the same origin, they share state automatically through the same storage key. For apps on different origins, use Tour Kit's createStorageAdapter() to plug in a shared backend (an API endpoint, cross-origin cookie, or any async storage) so tour completions in one app are reflected everywhere.

What's the bundle size impact of Tour Kit in a monorepo?

Tour Kit's core package is under 8KB gzipped with zero runtime dependencies. The React wrapper adds roughly 4KB. In a monorepo with tree shaking enabled, each app only bundles the tour definitions and hooks it imports, not the entire shared package. For comparison, React Joyride ships at 37KB gzipped and doesn't support tree shaking of individual tour definitions.

Do I need Turborepo specifically, or does this work with Nx?

The shared package pattern works with any JavaScript monorepo tool: Turborepo, Nx, Lerna, or plain pnpm workspaces without a build orchestrator. The key requirements are workspace:* dependency resolution and a build step that produces dist/ output before consuming apps import from it. Turborepo and Nx both handle build ordering through task dependencies (^build in Turbo, dependsOn in Nx). We used Turborepo here because it's the simpler setup for pnpm-based monorepos.

Ready to try userTourKit?

$ pnpm add @tour-kit/react