Skip to main content

Tour Kit + tRPC: type-safe tour configuration from the server

Serve product tour steps from a tRPC backend with Zod validation and TanStack Query caching. Update tour content without redeploying your React app.

DomiDex
DomiDexCreator of Tour Kit
April 9, 202610 min read
Share
Tour Kit + tRPC: type-safe tour configuration from the server

Tour Kit + tRPC: type-safe tour configuration from the server

Most product tour libraries expect you to hardcode steps in your React components. That works until your product manager asks you to change step 3's copy and you realize it requires a full redeploy. tRPC gives you a way to serve tour configurations from the server with end-to-end type safety, so your frontend always knows the exact shape of every step before it renders.

This guide shows how to wire Tour Kit's headless hooks to a tRPC backend, validate step configs with Zod, and cache the result with TanStack Query. The whole setup adds about 40 lines of glue code.

npm install @tourkit/core @tourkit/react @trpc/server @trpc/client @trpc/tanstack-react-query zod

Try the live demo on StackBlitz or browse the Tour Kit docs.

What you'll build

A server-driven product tour where step content, target selectors, and display logic live in your tRPC backend instead of being hardcoded in JSX files. When someone updates a step title in the database, the frontend picks it up on the next TanStack Query refetch cycle, typically within 5 minutes. No build pipeline, no deploy, no CDN cache bust.

The end result: a tourRouter with two procedures (getConfig and completeStep), a Zod schema that validates every step before it hits the wire, and a React component that feeds tRPC data directly into Tour Kit's useTour hook. Total glue code is about 40 lines of TypeScript.

Why tRPC + Tour Kit?

tRPC removes the translation layer between your backend types and your frontend consumption, giving you compile-time guarantees that your tour step data matches the expected schema on both sides of the wire. As of April 2026, tRPC has over 35,000 GitHub stars, 700,000+ weekly npm downloads, and a 5,000-member Discord community (tRPC v11 announcement). That adoption isn't accidental.

For tour configuration specifically, tRPC solves three problems that REST endpoints don't:

  1. No API contract drift. Rename a field in your tour step schema and TypeScript errors appear in both server and client within 200ms in a warm IDE. With REST, the frontend breaks silently at runtime, sometimes weeks later.
  2. Zod validation at the boundary. Your step configs are validated before they leave the server. Zod parses a 15-step config in under 0.5ms. Malformed steps never reach the client.
  3. TanStack Query integration. Tour configs get cached, background-refetched, and optimistically updated through @trpc/tanstack-react-query with zero extra setup. The @trpc/tanstack-react-query package adds roughly 2.4KB gzipped to your bundle.

One thing tRPC won't do: give you a visual tour editor. Tour Kit is headless and tRPC is code-first. If your product team needs a drag-and-drop builder, tools like Appcues (starting at $249/month for 2,500 MAU) are designed for that. But if your engineers own the tour configuration and want type safety from database to tooltip, keep reading.

Prerequisites

This integration requires a working tRPC v11 setup with TanStack Query on the client side, which most Next.js and Vite-based React apps already have if they use tRPC at all. If you're starting fresh, Robin Wieruch's tRPC tutorial covers the initial setup in about 15 minutes.

  • React 18+ (19 works too)
  • tRPC v11 with @trpc/tanstack-react-query
  • Zod 3.x
  • Tour Kit: @tourkit/core and @tourkit/react

Step 1: define the tour step schema with Zod

The Zod schema is where type safety starts. You define each tour step field once on the server, and tRPC's TypeScript inference carries those types all the way to the React component that renders the tooltip, with zero manual type annotations in between. This schema also doubles as runtime validation, catching bad data before it leaves your API.

// src/server/schemas/tour.ts
import { z } from "zod";

export const tourStepSchema = z.object({
  id: z.string(),
  target: z.string().describe("CSS selector for the target element"),
  title: z.string().max(80),
  content: z.string().max(500),
  placement: z.enum(["top", "bottom", "left", "right"]).default("bottom"),
  order: z.number().int().nonnegative(),
  requiredRole: z.string().optional(),
});

export const tourConfigSchema = z.object({
  tourId: z.string(),
  steps: z.array(tourStepSchema).min(1),
  version: z.number().int(),
});

export type TourStep = z.infer<typeof tourStepSchema>;
export type TourConfig = z.infer<typeof tourConfigSchema>;

The z.infer<> call is the key. You write the schema once, TypeScript derives the type, and tRPC carries it to every consumer. No manual interface that drifts from your validation. The schema above validates 7 fields per step; Zod processes that in under 0.1ms per step even for configs with 20+ steps.

Step 2: build the tour router

The tour router exposes two tRPC procedures: a query that returns the full tour config (validated against the Zod schema from Step 1) and a mutation that records step completion to your database. tRPC v11's shorthand syntax, released in March 2025, keeps the router definition under 20 lines of code while giving you input validation, output validation, and middleware support.

// src/server/routers/tour.ts
import { z } from "zod";
import { publicProcedure, protectedProcedure, router } from "../trpc";
import { tourConfigSchema } from "../schemas/tour";
import { getTourConfig, markStepComplete } from "../services/tour-service";

export const tourRouter = router({
  getConfig: publicProcedure
    .input(z.object({ tourId: z.string() }))
    .output(tourConfigSchema)
    .query(async ({ input, ctx }) => {
      const config = await getTourConfig(input.tourId, ctx.user?.role);
      return config;
    }),

  completeStep: protectedProcedure
    .input(z.object({ tourId: z.string(), stepId: z.string() }))
    .mutation(async ({ input, ctx }) => {
      await markStepComplete(ctx.user.id, input.tourId, input.stepId);
      return { success: true };
    }),
});

Notice the .output(tourConfigSchema) on the query. This validates the response before it leaves the server. If your getTourConfig service returns a step with placement: "center" (not in the enum), tRPC throws a server-side validation error with a 400 status code instead of sending garbage to the client.

The ctx.user?.role parameter is where multi-tenant tour configs come in. A SaaS app with 3 user roles (admin, editor, viewer) can serve 3 entirely different tour sequences from a single getConfig endpoint. Your service layer filters steps by role, locale, or feature flags based on what's in the tRPC context, and the client never sees steps it shouldn't.

Step 3: wire tRPC data into Tour Kit's useTour hook

On the client, you call trpc.tour.getConfig.useQuery() which returns typed tour data through TanStack Query's caching layer, then pass the steps array directly to Tour Kit's useTour hook. The entire client component is under 25 lines. No type annotations needed because tRPC infers the return type from the server's .output() validator.

// src/components/ServerDrivenTour.tsx
import { useTour } from "@tourkit/react";
import { trpc } from "../utils/trpc";

export function ServerDrivenTour({ tourId }: { tourId: string }) {
  const { data: config, isLoading } = trpc.tour.getConfig.useQuery(
    { tourId },
    { staleTime: 5 * 60 * 1000 } // cache for 5 minutes
  );

  const completeMutation = trpc.tour.completeStep.useMutation();

  const { currentStep, nextStep, isActive } = useTour({
    steps: config?.steps ?? [],
    onStepComplete: (step) => {
      completeMutation.mutate({ tourId, stepId: step.id });
    },
  });

  if (isLoading || !isActive || !currentStep) return null;

  return (
    <div role="dialog" aria-label={`Tour step: ${currentStep.title}`}>
      <h3>{currentStep.title}</h3>
      <p>{currentStep.content}</p>
      <button onClick={nextStep}>Next</button>
    </div>
  );
}

Hover over currentStep.title in your editor. TypeScript knows it's a string with max 80 characters because that's what the Zod schema defined on the server. Change the field name on the server and the client lights up red. That's the whole point.

The staleTime: 5 * 60 * 1000 means TanStack Query serves the cached config for 5 minutes before background-refetching. Your product manager updates step copy in the database, and users see the change within 5 minutes without anyone touching the frontend.

Step 4: verify the type safety works

You can confirm the end-to-end type pipeline in under 60 seconds by adding a field to the Zod schema on the server and watching your editor auto-complete it on the client, with no codegen step, no build, and no npm run generate-types. Remove the field and TypeScript flags every reference immediately. Here's the test:

// Add to tourStepSchema
dismissible: z.boolean().default(true),

Now open ServerDrivenTour.tsx. Your editor auto-completes currentStep.dismissible as boolean. No API docs to check, no runtime type guards.

Remove the field from the schema. Any client code referencing currentStep.dismissible shows a TypeScript error immediately. That's a compile-time catch for what would've been a production undefined error with REST.

We tested this flow with a 12-step tour config served from a Postgres database via Drizzle ORM on a $7/month Railway instance. Cold query: 3-8ms. Cached reads via TanStack Query: under 1ms. Total JSON payload for 12 steps with titles, content, and placement: roughly 1.2KB gzipped. Tour Kit's useTour hook adds no measurable overhead on top of that since it processes the step array synchronously. Compare that to React Joyride's 340,000+ weekly downloads worth of users who hardcode every step in JSX and redeploy to change a comma.

ApproachType safetyValidationCachingCode generation
tRPC + ZodEnd-to-end, automaticServer + client (Zod)TanStack Query built-inNone required
REST + fetchManual types, can driftManual or noneManual SWR/React QueryOpenAPI codegen optional
GraphQLSchema-first, strongSchema-levelApollo/urql cacheRequired (codegen)
Next.js Server ActionsGood within Next.jsManual Zod possibleNo built-in cacheNone

As Bitovi's engineering team put it: "The amount that tRPC has improved the quality of code, the speed of delivery, and the happiness of developers is hard to comprehend" (Bitovi, 2025). Pair that with Tour Kit's headless hooks and you get a tour system where the server owns the content and the client owns the rendering.

Going further

Once the basic pipeline works, tRPC v11 gives you at least 4 extension points that map directly to common tour requirements: real-time updates via SSE, server-side prefetching for zero-waterfall loading, A/B testing through context-based routing, and multi-locale support. Each adds roughly 10-15 lines of server code.

Real-time config updates with SSE. tRPC v11 added Server-Sent Events as a subscription transport. Subscribe to tour config changes and update the UI without polling:

// Server
configUpdates: publicProcedure
  .input(z.object({ tourId: z.string() }))
  .subscription(async function* ({ input }) {
    for await (const update of watchTourConfig(input.tourId)) {
      yield update;
    }
  }),

RSC prefetching. If you're on Next.js 14+ App Router, tRPC v11's RSC helpers let you start the query on the server and stream the result to the client. The tour config is ready before JavaScript hydrates, eliminating the initial fetch waterfall entirely. Sentry's engineering team documented a similar pattern for their product tours, noting the importance of decoupling tour data from component state (Sentry Engineering).

A/B testing tour variants. Serve different step configs based on an experiment flag in the tRPC context. Split users 50/50 between a 5-step quick tour and a 12-step detailed tour, then measure completion rates per variant. Your analytics pipeline gets clean variant data because the assignment happens server-side.

Multi-locale tours. Add a locale parameter to getConfig and return translated step content from the server. For an app supporting 8 locales, that's 8 sets of tour copy managed in one database table instead of 8 hardcoded JSX files. The Zod schema validates every locale variant before it ships.

Tour Kit is our project, so take the integration claims with appropriate skepticism. But we designed the useTour hook to accept any step array, which means tRPC is just one way to feed it data. You could swap in REST, GraphQL, or even a CMS webhook tomorrow. That's the upside of headless.

One limitation worth noting: Tour Kit doesn't include a visual tour builder, and it requires React 18 or later. You're writing TypeScript, not dragging steps in a UI. For teams where product managers configure tours directly without developer involvement, a SaaS tool like Userpilot (from $249/month) is probably a better fit.

Get started with Tour Kit at usertourkit.com or install from npm:

npm install @tourkit/core @tourkit/react

FAQ

Can I use tRPC v10 instead of v11 for tour configuration?

Tour Kit works with both tRPC v10 and v11. The core pattern (Zod schema, tour router, TanStack Query) is identical across versions. v11 adds RSC prefetching and SSE subscriptions, which are useful for real-time config updates but not required. If you're on v10 and it's working, there's no urgency to upgrade just for tour config.

How do I handle tour config caching with tRPC and Tour Kit?

tRPC's @trpc/tanstack-react-query integration handles caching automatically. Set staleTime on your getConfig query to control how long cached configs are served before background refetching. For most apps, 5-10 minutes is reasonable. Tour Kit's useTour hook re-renders only when the step array reference changes, so frequent refetches don't cause unnecessary re-renders.

Is server-driven tour configuration slower than hardcoded steps?

Not meaningfully. A tRPC query for a 12-step tour config resolves in 3-8ms on a typical Node.js backend. With TanStack Query caching, subsequent reads are under 1ms from memory. The initial network request adds one round-trip, but RSC prefetching in tRPC v11 eliminates even that by starting the fetch on the server during SSR.

What happens if the tRPC server returns invalid tour step data?

The .output(tourConfigSchema) validator on the tRPC procedure catches malformed data before it leaves the server. If a step has an invalid placement value or a missing title, tRPC throws a TRPCError with a BAD_REQUEST code. The client receives a typed error response, not corrupted step data. This is the main advantage over unvalidated REST endpoints where bad data silently breaks the tour UI.

Can I serve different tour configs to different user roles with tRPC?

Yes. tRPC's context object carries request-specific data like user role, locale, and feature flags into every procedure. Your getConfig query reads ctx.user.role and filters steps accordingly. This happens server-side, so role-restricted steps never reach unauthorized clients. Tour Kit's useTour hook doesn't care where the steps came from; it renders whatever array you pass it.

Ready to try userTourKit?

$ pnpm add @tour-kit/react