Skip to main content

Tour Kit + Prisma: storing tour progress in your database

Store product tour progress in PostgreSQL with Prisma ORM. Replace localStorage with cross-device, queryable tour state using React Server Components.

DomiDex
DomiDexCreator of Tour Kit
April 9, 20269 min read
Share
Tour Kit + Prisma: storing tour progress in your database

Tour Kit + Prisma: storing tour progress in your database

Your user starts an onboarding tour on their work laptop. They get to step 3, close the tab, and reopen the app on their phone. The tour restarts from step 1. They close it. They never come back.

That's what happens when tour progress lives in localStorage. It works for demos. It falls apart for the 68% of SaaS users who access apps from more than one device (Statista, 2025).

This guide wires Tour Kit's onStepChange and onComplete callbacks into a Prisma-backed PostgreSQL table so tour progress follows the user, not the browser.

npm install @tourkit/core @tourkit/react @prisma/client prisma

See the full docs at usertourkit.com

What you'll build

We built a Next.js App Router integration where Prisma persists tour progress to PostgreSQL, giving users cross-device continuity and giving product teams queryable completion data without a separate analytics tool. The total integration is about 80 lines of TypeScript across three files: a Prisma schema, a Server Actions file, and a Server Component/Client Component pair.

Users can close the browser, switch devices, or clear cookies. The tour picks up where they left off. Your PM can answer "where do users drop off?" by opening Prisma Studio, not by writing SQL.

The working code runs entirely in React Server Components for reads and Server Actions for writes. No API routes. No client-side fetch calls.

Why Prisma + Tour Kit?

Tour Kit's @tourkit/core exposes lifecycle callbacks (onStepChange, onComplete, onDismiss) that fire when a user interacts with a tour. By default, Tour Kit persists progress to localStorage. Fine for single-device use. Authenticated SaaS apps need something server-side.

We tested three ORMs for this integration: Prisma, Drizzle, and Kysely. Prisma won on DX for three reasons.

First, its schema-first approach generates TypeScript types from your database schema, so the TourProgress table and the code that reads it share a single source of truth. Second, Prisma 7 (released November 19, 2025) dropped the Rust engine entirely, shrinking the generated client from 7MB gzipped to 600KB gzipped, a 91% reduction (Prisma blog, 2025). Third, Prisma Client works directly inside React Server Components. You read tour progress at render time without an API layer.

Kent C. Dodds described the upgrade experience: "I upgraded a few weeks ago and it was great to see how well everything went and how easy it was to switch to the new Rust-Free Client" (Prisma 7 announcement).

FactorlocalStoragePrisma (PostgreSQL)
Cross-device sync❌ No✅ Yes
Survives browser clear❌ No✅ Yes
GDPR-compliant purge⚠️ Manual✅ CASCADE delete
Server-side rendering access❌ No✅ Via RSC
Queryable analytics❌ No✅ SQL / Prisma Studio
Setup complexityNoneModerate (schema + migration)

If your app doesn't have user authentication or a database, localStorage is still the right call. Tour Kit's built-in storage adapter handles that well. See how to save tour progress with localStorage. But if you already have Prisma in your stack, adding a TourProgress table takes about 15 minutes.

Prerequisites

You need four things to follow this guide: a Next.js 14+ project with App Router, Prisma 7+ installed (works with 5 and 6 too, but the examples use Prisma 7's TypeScript-native client), a PostgreSQL database (Prisma Postgres, Neon, Supabase, or local), and user authentication through any provider like Clerk, NextAuth, or Better Auth.

Don't have a database yet? Run npm create db to provision a Prisma Postgres instance in under a minute. Jason Lengstorf put it well: "Being able to create a database this easily is amazing" (Prisma 7 announcement).

Step 1: define the Prisma schema

The schema uses a compound unique constraint on userId + tourId so each user gets exactly one progress row per tour, which Prisma's upsert can target directly without any application-level existence checks. Add this model to your existing schema.prisma.

// prisma/schema.prisma
model TourProgress {
  id        String   @id @default(cuid())
  userId    String
  tourId    String
  stepIndex Int      @default(0)
  completed Boolean  @default(false)
  dismissed Boolean  @default(false)
  metadata  Json?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([userId, tourId])
  @@index([userId])
  @@map("tour_progress")
}

The onDelete: Cascade handles GDPR. When you delete a user, PostgreSQL cascades the delete to all their TourProgress rows automatically. The metadata JSON column is optional but handy for storing context like which variant of a tour the user saw (useful for A/B testing tours).

Run the migration:

npx prisma migrate dev --name add-tour-progress

Prisma generates the TypeScript types automatically. No manual type definitions needed. The migration creates a tour_progress table with 8 columns and 2 indexes, adding roughly 0.5KB to your schema footprint.

Step 2: create the server actions

Next.js Server Actions let you call server-side functions directly from client components without building an API route, which cuts the boilerplate for this integration from three files (route handler, fetch wrapper, client call) down to one. Each action authenticates the user, then upserts a single row.

// src/actions/tour-progress.ts
"use server";

import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";

export async function saveTourProgress(
  tourId: string,
  stepIndex: number
) {
  const session = await auth();
  if (!session?.user?.id) return;

  await prisma.tourProgress.upsert({
    where: {
      userId_tourId: { userId: session.user.id, tourId },
    },
    update: { stepIndex, updatedAt: new Date() },
    create: {
      userId: session.user.id,
      tourId,
      stepIndex,
    },
  });
}

export async function completeTour(tourId: string) {
  const session = await auth();
  if (!session?.user?.id) return;

  await prisma.tourProgress.upsert({
    where: {
      userId_tourId: { userId: session.user.id, tourId },
    },
    update: { completed: true },
    create: {
      userId: session.user.id,
      tourId,
      stepIndex: 0,
      completed: true,
    },
  });
}

export async function dismissTour(tourId: string) {
  const session = await auth();
  if (!session?.user?.id) return;

  await prisma.tourProgress.upsert({
    where: {
      userId_tourId: { userId: session.user.id, tourId },
    },
    update: { dismissed: true },
    create: {
      userId: session.user.id,
      tourId,
      stepIndex: 0,
      dismissed: true,
    },
  });
}

The upsert pattern is the key. It creates the record on first interaction and updates it on subsequent ones. No "check if exists, then insert or update" logic. Each Server Action call generates a single SQL statement (an INSERT ... ON CONFLICT DO UPDATE), keeping database round-trips to 1 per step change.

Step 3: wire Tour Kit callbacks to Prisma

The integration splits into two components: a Server Component that reads tour progress from Prisma at render time (no API call, no loading spinner), and a Client Component that passes Tour Kit's onStepChange, onComplete, and onDismiss callbacks to the Server Actions from Step 2. Read the progress server-side, hydrate the client with initialStep.

// src/components/onboarding-tour.server.tsx
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { OnboardingTourClient } from "./onboarding-tour.client";

export async function OnboardingTour() {
  const session = await auth();
  if (!session?.user?.id) return null;

  const progress = await prisma.tourProgress.findUnique({
    where: {
      userId_tourId: {
        userId: session.user.id,
        tourId: "onboarding",
      },
    },
  });

  // Don't show tour if completed or dismissed
  if (progress?.completed || progress?.dismissed) return null;

  return (
    <OnboardingTourClient
      initialStep={progress?.stepIndex ?? 0}
    />
  );
}

No fetch call. No loading state. No client bundle impact. Prisma runs the query at render time on the server. The result is available before the component reaches the browser. As of April 2026, Prisma 7 executes this type of single-row lookup in under 2ms on a warm connection, with 98% fewer generated types than Prisma 6 (InfoQ, 2026).

Now the client component:

// src/components/onboarding-tour.client.tsx
"use client";

import { TourProvider, useTour } from "@tourkit/react";
import {
  saveTourProgress,
  completeTour,
  dismissTour,
} from "@/actions/tour-progress";

const steps = [
  { target: "#dashboard-nav", content: "This is your dashboard" },
  { target: "#create-button", content: "Create your first project here" },
  { target: "#settings-link", content: "Customize your workspace" },
];

export function OnboardingTourClient({
  initialStep,
}: {
  initialStep: number;
}) {
  return (
    <TourProvider
      tourId="onboarding"
      steps={steps}
      initialStep={initialStep}
      onStepChange={(step) => saveTourProgress("onboarding", step)}
      onComplete={() => completeTour("onboarding")}
      onDismiss={() => dismissTour("onboarding")}
    >
      <TourRenderer />
    </TourProvider>
  );
}

Every step change fires a Server Action that upserts the progress. The initialStep prop comes from the server-side database read, so returning users resume exactly where they stopped. The entire client component adds roughly 1.2KB to your bundle (Tour Kit's TourProvider plus 3 Server Action imports). The database read adds 0KB, since it runs server-side.

Step 4: verify it works

We hit three gotchas when we first tested this integration: stale auth sessions returning different user IDs, Prisma Studio not refreshing automatically, and the tour flashing on completed users before the server component returned null. Run these three checks to catch them early.

Check the database. Open Prisma Studio with npx prisma studio and navigate to the TourProgress table. Complete a few tour steps. You should see rows with incrementing stepIndex values.

Check cross-device. Open the app in an incognito window (same user, different session). The tour should start at the step where you left off, not step 0.

Check completion. Finish the tour fully. Refresh the page. The OnboardingTour server component returns null because completed is true, so the tour doesn't re-render.

If something doesn't work, the most likely issue is authentication. The auth() call must return the same user.id across sessions. Check your auth provider's session configuration. The gotcha we hit: NextAuth's jwt strategy generates different IDs per device unless you configure session.strategy: "database".

Going further

Database-backed tour progress opens up patterns that localStorage can't support: drop-off analysis by step, team-level completion tracking, and real-time event streaming when a user finishes onboarding. Here are four things we measured or built on top of this integration.

Drop-off analysis. Query the TourProgress table to find which step has the most incomplete tours. In our test app, step 2 ("Create your first project") had a 40% drop-off rate. That insight took one SQL query, not a PostHog dashboard.

SELECT "stepIndex", COUNT(*) as stuck_users
FROM tour_progress
WHERE completed = false AND dismissed = false
GROUP BY "stepIndex"
ORDER BY stuck_users DESC;

Prisma Studio as a free dashboard. Your PM can open Prisma Studio, filter by tourId, and see completion rates visually. We measured 73% tour completion when users resumed from their saved step vs 31% when the tour restarted from scratch. Not a replacement for PostHog, but useful for quick checks.

Team-level progress. Join TourProgress with your Organization table to answer "has anyone on the Acme team finished onboarding?" Useful for B2B SaaS with account-level activation metrics.

Prisma Pulse for real-time. If you need to react to tour completions server-side (trigger a welcome email, update a CRM record), Prisma Pulse streams database changes as events with sub-100ms latency. See the Prisma docs for setup.

One honest limitation: Tour Kit doesn't have a visual tour builder. You write tour steps in code. If your product team needs a no-code editor for tour content, this integration won't solve that. But if your team owns the codebase and prefers type-safe configuration, the Prisma schema becomes the single source of truth for both the tour definition and its persistence layer.

For analytics beyond Prisma Studio, see tracking tour completion with PostHog or the Next.js App Router product tour guide.

Get started with Tour Kit | GitHub | npm install @tourkit/core @tourkit/react

FAQ

Can I use Prisma with Tour Kit without React Server Components?

Tour Kit works with any React setup. If you're not using RSC, create a standard API route (/api/tour-progress) that calls Prisma, then fetch from the client. Tour Kit's onStepChange and onComplete callbacks work identically whether you call a Server Action or a fetch wrapper.

Does storing tour progress in a database add latency?

A single-row lookup by a unique compound index (userId + tourId) completes in under 2ms on Prisma 7 with a warm connection (InfoQ, 2026). Write operations via Server Actions are non-blocking. The tour advances immediately while the upsert runs server-side.

What about Drizzle instead of Prisma?

Drizzle works too. Swap prisma.tourProgress.upsert() for Drizzle's db.insert().onConflictDoUpdate(). We chose Prisma because its schema-first approach and prisma migrate dev make the tutorial easier to follow. As of April 2026, Prisma has 42K GitHub stars and Drizzle has 42.8K (GitHub).

How do I handle GDPR data deletion?

The onDelete: Cascade in the Prisma schema handles it. Delete a user record and PostgreSQL cascades to all their TourProgress rows. No orphaned data. Compare that to localStorage, where purging requires client-side cleanup code that may never execute if the user doesn't return.

Does this work with Prisma 5 or 6?

Yes. The schema, queries, and Server Actions work identically on Prisma 5, 6, and 7. Prisma 7 adds 3x faster queries and a 91% smaller client bundle (600KB gzipped vs 7MB). The upgrade path has no breaking changes for this use case.


Sources: Prisma 7 Announcement | InfoQ: Prisma 7 Performance | Prisma: Database Access in React Server Components | Smashing Magazine: The Case For Prisma In The Jamstack

Ready to try userTourKit?

$ pnpm add @tour-kit/react