
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/reactWhat 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.jsonPrerequisites
- 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.
| Configuration | Dashboard bundle (gzipped) | Marketing bundle (gzipped) |
|---|---|---|
| No tree shaking (barrel re-export) | 8.1 KB | 8.1 KB |
| With sideEffects: false + explicit exports | 6.2 KB | 3.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/analyticsto the shared package to track tour completion rates across all apps from a single analytics pipeline - Use
@tour-kit/hintsfor persistent hotspot beacons that share the samedata-tourtargeting convention - Set up Changesets in the monorepo if you eventually publish
@acme/toursas 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.
Related articles

Amplitude + Tour Kit: measuring onboarding impact on retention
Wire Tour Kit callbacks to Amplitude track() for onboarding funnels, behavioral cohorts, and retention analysis. TypeScript examples included.
Read article
How to add a product tour to an Astro site with React islands
Add interactive product tours to an Astro site using React islands. Covers client directives, Nanostores state sharing, and Tour Kit setup.
Read article
Building conditional product tours based on user role
Build role-based product tours in React with Tour Kit. Filter steps by admin, editor, or viewer roles using the when prop and React Context.
Read article
Using CSS container queries for responsive product tours
Build product tour tooltips that adapt to their container, not the viewport. Learn CSS container queries with Tour Kit for truly responsive onboarding.
Read article