Web Engineering ·

Building a SaaS Onboarding Flow: Product Tours, Activation Metrics, and Progressive Disclosure in Next.js

A practical guide to building production onboarding in Next.js. Covers step-based wizard architecture with server-side state, database-backed progress tracking, role/plan conditional rendering, Floating UI product tours, activation metric design, progressive disclosure patterns, and a state machine approach for multi-step flows. TypeScript examples throughout.

Building a SaaS Onboarding Flow: Product Tours, Activation Metrics, and Progressive Disclosure in Next.js

Most SaaS products treat onboarding as a UX problem. Ship a modal, a checklist, a product tour, call it done. In practice, onboarding is a systems problem: state that lives across sessions, conditional logic driven by user role and plan tier, progress that needs to survive refreshes, and metrics that need to be reliable enough to make product decisions from.

This article walks through building a production-grade onboarding system in Next.js. The focus is on the architecture: where state lives, how steps are modeled, how to implement a tooltip-based product tour without a third-party dependency, and how to define and track activation metrics that are actually useful.

Why Onboarding Architecture Matters

Activation is the event where a user first experiences the value they signed up for. For a project management tool, that might be creating the first task with a teammate. For a payment processor, it might be completing the first transaction. The definition varies by product, but the metric is always the same: what percentage of signups reach the activation event, and how long does it take?

The activation rate at most SaaS products sits below 30%. Improving it by even 10 percentage points tends to have more impact than any other growth lever at the early stage, because you are converting users who already showed enough intent to sign up. Onboarding is the mechanism for that improvement.

The architecture decisions that affect outcomes are less obvious than the UI:

  • Progress must persist server-side. Browser storage loses state on device switch and incognito sessions. A user who signed up on desktop and opens your app on mobile should not start over.
  • Steps must be conditional. A free-tier user should not see a step for a feature they cannot access. An admin role has a different activation path than a viewer.
  • The tour must not be a popup wall. Tooltip-based walkthroughs that interrupt task completion convert worse than progressive disclosure that surfaces guidance when a user is about to perform the relevant action.
  • Metrics must be precise. “Onboarding completion rate” tells you nothing. You need step-level abandonment and time-to-activation broken down by plan and role.

Data Model and Server-Side State

The onboarding state lives in the database, linked to the user record. A minimal schema covers the current step, a set of completed steps, and a timestamp for the activation event.

// types/onboarding.ts
export type OnboardingStatus = "not_started" | "in_progress" | "activated";

export interface OnboardingState {
  userId: string;
  status: OnboardingStatus;
  currentStep: string;
  completedSteps: string[];
  activatedAt: Date | null;
  createdAt: Date;
  updatedAt: Date;
}

export interface OnboardingStep {
  id: string;
  title: string;
  description: string;
  requiredRoles?: string[];
  requiredPlans?: string[];
  isActivationStep?: boolean;
}

The step definitions are static configuration, not database rows. Storing steps in the database creates a migration problem every time the onboarding flow changes. Instead, store only the step IDs that have been completed. The current step is derived from the configuration.

// lib/onboarding/steps.ts
export const ONBOARDING_STEPS: OnboardingStep[] = [
  {
    id: "profile_complete",
    title: "Complete your profile",
    description: "Add your name and avatar so teammates can recognize you.",
  },
  {
    id: "invite_teammate",
    title: "Invite a teammate",
    description: "Onboarding with a colleague increases retention significantly.",
    requiredPlans: ["team", "enterprise"],
  },
  {
    id: "create_first_project",
    title: "Create your first project",
    description: "Set up a project and assign it to yourself.",
    isActivationStep: true,
  },
  {
    id: "connect_integration",
    title: "Connect an integration",
    description: "Bring your existing tools in.",
    requiredRoles: ["admin", "owner"],
  },
];

export function getStepsForUser(
  role: string,
  plan: string
): OnboardingStep[] {
  return ONBOARDING_STEPS.filter((step) => {
    if (step.requiredRoles && !step.requiredRoles.includes(role)) return false;
    if (step.requiredPlans && !step.requiredPlans.includes(plan)) return false;
    return true;
  });
}

export function deriveCurrentStep(
  completedSteps: string[],
  role: string,
  plan: string
): string | null {
  const steps = getStepsForUser(role, plan);
  const next = steps.find((s) => !completedSteps.includes(s.id));
  return next?.id ?? null;
}

This keeps the logic pure and testable. The database only stores completedSteps: string[] and status. Everything else is derived.

Server Actions for Step Progression

Next.js Server Actions are the right mechanism here. Step completion involves a database write, a metrics event, and potentially a cache invalidation. Running this server-side avoids a round-trip to a dedicated API route and keeps the mutation co-located with the component that triggers it.

// app/actions/onboarding.ts
"use server";

import { revalidatePath } from "next/cache";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { deriveCurrentStep, getStepsForUser, ONBOARDING_STEPS } from "@/lib/onboarding/steps";
import { trackEvent } from "@/lib/analytics";

export async function completeOnboardingStep(stepId: string) {
  const session = await getSession();
  if (!session) throw new Error("Unauthenticated");

  const { userId, role, plan } = session;

  const existing = await db.onboardingState.findUnique({ where: { userId } });
  if (!existing) throw new Error("Onboarding state not found");

  if (existing.completedSteps.includes(stepId)) {
    return { ok: true, alreadyCompleted: true };
  }

  const validSteps = getStepsForUser(role, plan).map((s) => s.id);
  if (!validSteps.includes(stepId)) {
    throw new Error(`Step ${stepId} not valid for this user`);
  }

  const updatedCompleted = [...existing.completedSteps, stepId];
  const step = ONBOARDING_STEPS.find((s) => s.id === stepId);
  const isActivation = step?.isActivationStep ?? false;

  await db.onboardingState.update({
    where: { userId },
    data: {
      completedSteps: updatedCompleted,
      status: isActivation ? "activated" : "in_progress",
      activatedAt: isActivation ? new Date() : existing.activatedAt,
      updatedAt: new Date(),
    },
  });

  await trackEvent({
    event: "onboarding_step_completed",
    userId,
    properties: {
      stepId,
      isActivation,
      totalCompleted: updatedCompleted.length,
      plan,
      role,
    },
  });

  revalidatePath("/onboarding");
  return { ok: true, activated: isActivation };
}

Two things worth noting here. First, the server action validates that the step being completed is valid for the user’s current role and plan. A client can call this action directly, so the validation must live on the server. Second, the activation event is tracked at the moment of database write, not as a side effect somewhere else. This eliminates the class of bugs where analytics and database get out of sync.

Onboarding Wizard Component

The wizard component reads server-side state and renders the correct step. Using server components for the outer shell and a client component for interaction keeps the data fetch clean.

// app/onboarding/page.tsx (Server Component)
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { getStepsForUser, deriveCurrentStep } from "@/lib/onboarding/steps";
import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";

export default async function OnboardingPage() {
  const session = await getSession();
  const { userId, role, plan } = session;

  const state = await db.onboardingState.findUnique({ where: { userId } });
  const steps = getStepsForUser(role, plan);
  const currentStepId = state
    ? deriveCurrentStep(state.completedSteps, role, plan)
    : steps[0]?.id ?? null;

  return (
    <OnboardingWizard
      steps={steps}
      completedSteps={state?.completedSteps ?? []}
      currentStepId={currentStepId}
      status={state?.status ?? "not_started"}
    />
  );
}
// components/onboarding/OnboardingWizard.tsx
"use client";

import { useState, useTransition } from "react";
import { completeOnboardingStep } from "@/app/actions/onboarding";
import type { OnboardingStep, OnboardingStatus } from "@/types/onboarding";

interface Props {
  steps: OnboardingStep[];
  completedSteps: string[];
  currentStepId: string | null;
  status: OnboardingStatus;
}

export function OnboardingWizard({ steps, completedSteps, currentStepId, status }: Props) {
  const [activeStepId, setActiveStepId] = useState(currentStepId);
  const [isPending, startTransition] = useTransition();

  const activeStep = steps.find((s) => s.id === activeStepId);
  const progress = (completedSteps.length / steps.length) * 100;

  function handleComplete(stepId: string) {
    startTransition(async () => {
      await completeOnboardingStep(stepId);
      const currentIndex = steps.findIndex((s) => s.id === stepId);
      const nextStep = steps[currentIndex + 1];
      if (nextStep) setActiveStepId(nextStep.id);
    });
  }

  if (status === "activated") {
    return <OnboardingComplete />;
  }

  return (
    <div className="onboarding-wizard">
      <ProgressBar value={progress} />
      <StepList
        steps={steps}
        completedSteps={completedSteps}
        activeStepId={activeStepId}
        onSelect={setActiveStepId}
      />
      {activeStep && (
        <StepPanel
          step={activeStep}
          isCompleted={completedSteps.includes(activeStep.id)}
          isPending={isPending}
          onComplete={() => handleComplete(activeStep.id)}
        />
      )}
    </div>
  );
}

useTransition here is important. The server action triggers a revalidation, which causes the server component to re-render with fresh data. isPending lets the client show a loading state without blocking the UI during that round-trip.

Product Tour with Floating UI

Third-party product tour libraries tend to be heavy, opinionated, and difficult to customize. For a tooltip-based walkthrough, Floating UI gives you precise anchor positioning without the overhead.

The pattern is: a tour is an ordered list of targets (CSS selectors or refs) with content for each. A context provider manages the current tour step. Each target element renders a tooltip when it is the active step.

// lib/onboarding/tour.ts
export interface TourStep {
  targetId: string;
  title: string;
  content: string;
  placement: "top" | "bottom" | "left" | "right";
}

export const DASHBOARD_TOUR: TourStep[] = [
  {
    targetId: "nav-projects",
    title: "Your projects",
    content: "All projects you have access to live here. Use the search to filter quickly.",
    placement: "right",
  },
  {
    targetId: "create-project-btn",
    title: "Create a project",
    content: "Start here. Give the project a name and assign an owner.",
    placement: "bottom",
  },
  {
    targetId: "invite-panel",
    title: "Invite teammates",
    content: "Projects get more useful when the right people are in them.",
    placement: "left",
  },
];
// components/onboarding/TourTooltip.tsx
"use client";

import { useFloating, offset, flip, shift, arrow } from "@floating-ui/react";
import { useRef, useEffect } from "react";

interface Props {
  targetId: string;
  title: string;
  content: string;
  placement: "top" | "bottom" | "left" | "right";
  onNext: () => void;
  onDismiss: () => void;
  stepIndex: number;
  totalSteps: number;
}

export function TourTooltip({
  targetId, title, content, placement, onNext, onDismiss, stepIndex, totalSteps
}: Props) {
  const arrowRef = useRef<HTMLDivElement>(null);
  const { refs, floatingStyles, update } = useFloating({
    placement,
    middleware: [offset(8), flip(), shift({ padding: 8 }), arrow({ element: arrowRef })],
  });

  useEffect(() => {
    const target = document.getElementById(targetId);
    if (!target) return;
    refs.setReference(target);
    target.scrollIntoView({ behavior: "smooth", block: "nearest" });
    update();
  }, [targetId, refs, update]);

  return (
    <div
      ref={refs.setFloating}
      style={floatingStyles}
      className="tour-tooltip"
      role="dialog"
      aria-label={title}
    >
      <div ref={arrowRef} className="tour-arrow" />
      <h3>{title}</h3>
      <p>{content}</p>
      <div className="tour-footer">
        <span className="tour-progress">{stepIndex + 1} / {totalSteps}</span>
        <button onClick={onDismiss} className="btn-ghost">Skip</button>
        <button onClick={onNext} className="btn-primary">
          {stepIndex + 1 === totalSteps ? "Done" : "Next"}
        </button>
      </div>
    </div>
  );
}

The tour state (active step index, whether the tour is running) lives in a context that wraps the dashboard. A user triggers the tour by clicking “Take a tour” in the onboarding checklist. The context is not persisted: if the user closes the tab mid-tour, they can restart from the checklist. Persisting tour position adds complexity for minimal benefit.

Progressive Disclosure

Progressive disclosure means surfacing features as the user is ready for them, rather than all at once. The practical pattern in a SaaS product is gating UI elements behind onboarding step completion.

// hooks/useFeatureGate.ts
"use client";

import { useOnboarding } from "@/context/OnboardingContext";

export function useFeatureGate(requiredStep: string): boolean {
  const { completedSteps } = useOnboarding();
  return completedSteps.includes(requiredStep);
}
// components/dashboard/IntegrationsPanel.tsx
"use client";

import { useFeatureGate } from "@/hooks/useFeatureGate";

export function IntegrationsPanel() {
  const canAccess = useFeatureGate("create_first_project");

  if (!canAccess) {
    return (
      <div className="feature-locked">
        <p>Create your first project to unlock integrations.</p>
        <a href="/onboarding">Go to onboarding</a>
      </div>
    );
  }

  return <IntegrationsList />;
}

The gate is based on step completion, not a separate feature flag system. This is the correct design: the onboarding steps and feature access are the same concept. If you decouple them, you introduce a synchronization problem where the step says “complete” but the gate says “locked.”

Activation Metric Design

“Activation rate” is a lagging indicator. You cannot optimize it without knowing where users are dropping off and why. The step-level data collected by completeOnboardingStep gives you the raw material.

The queries you actually want to run are:

Step funnel: For each step, how many users started it and how many completed it?

SELECT
  step_id,
  COUNT(*) FILTER (WHERE step_id = ANY(started)) AS started,
  COUNT(*) FILTER (WHERE step_id = ANY(completed_steps)) AS completed
FROM onboarding_states
CROSS JOIN UNNEST(ARRAY['profile_complete','invite_teammate','create_first_project','connect_integration']) AS step_id
GROUP BY step_id;

Time to activation by plan: The median time from signup to activation, segmented by plan tier.

SELECT
  u.plan,
  PERCENTILE_CONT(0.5) WITHIN GROUP (
    ORDER BY EXTRACT(EPOCH FROM (o.activated_at - u.created_at)) / 3600
  ) AS median_hours_to_activation
FROM onboarding_states o
JOIN users u ON o.user_id = u.id
WHERE o.activated_at IS NOT NULL
GROUP BY u.plan;

The activation step (the one with isActivationStep: true) should be defined as the earliest moment a user has experienced core value, not the completion of every setup step. Getting this definition wrong is the most common mistake. If you set the activation step to “filled out billing information,” you are measuring payment intent, not value delivery.

State Machine for Multi-Step Flows

As onboarding flows grow, the step logic becomes hard to follow. A simple array with filter logic works up to about 6-8 steps. Beyond that, a state machine makes the transitions explicit and testable.

// lib/onboarding/machine.ts
type StepId = "profile_complete" | "invite_teammate" | "create_first_project" | "connect_integration";

interface StepTransition {
  on: {
    COMPLETE: StepId | "done";
    SKIP?: StepId | "done";
  };
}

type OnboardingMachine = Record<StepId, StepTransition>;

export function buildMachine(role: string, plan: string): OnboardingMachine {
  const isAdmin = role === "admin" || role === "owner";
  const hasTeamPlan = plan === "team" || plan === "enterprise";

  return {
    profile_complete: {
      on: {
        COMPLETE: hasTeamPlan ? "invite_teammate" : "create_first_project",
      },
    },
    invite_teammate: {
      on: {
        COMPLETE: "create_first_project",
        SKIP: "create_first_project",
      },
    },
    create_first_project: {
      on: {
        COMPLETE: isAdmin ? "connect_integration" : "done",
      },
    },
    connect_integration: {
      on: {
        COMPLETE: "done",
        SKIP: "done",
      },
    },
  };
}

export function transition(
  machine: OnboardingMachine,
  currentStep: StepId,
  event: "COMPLETE" | "SKIP"
): StepId | "done" {
  const stepConfig = machine[currentStep];
  return stepConfig?.on[event] ?? "done";
}

The machine is a plain object, not a library. The transitions are explicit and can be tested without rendering any components. Role and plan are wired in at construction time, so the machine itself has no conditional logic.

This structure also makes it straightforward to support “skip” paths for optional steps without scattering early-return logic across the wizard component.

Tradeoffs

ApproachFlexibilityComplexityWhen to use
Linear array with filterLowLowFewer than 6 steps, no branching
State machineHighMediumBranching based on role/plan, skippable steps
Third-party tour library (Shepherd, Intro.js)MediumLow initiallyRapid prototyping, accept the dependency
Floating UI custom tourHighMediumDesign control, no vendor lock-in
Client-side progress storageLowLowSingle-device usage, not cross-device
Database-backed progressHighMediumProduction SaaS with real retention requirements

Production Considerations

Onboarding state initialization. The onboarding_states record needs to exist before the user reaches the onboarding page. Create it in the post-signup webhook or in the user creation transaction. Querying for a record that does not exist and treating a null result as “not started” creates a race condition if the user opens two tabs.

Idempotency on step completion. The server action checks for prior completion before writing. This is not just defensive coding: it protects against the case where the user double-clicks a button or the optimistic UI state diverges from the server.

Bypassing onboarding for returning users. Users who deactivate and reactivate, or who were created via an admin import, should not be forced through onboarding. Check status === "activated" at the middleware or layout level and redirect to the dashboard.

Tour accessibility. The tooltip renders as a role="dialog". Focus should trap to the tooltip when it opens and return to the trigger element when it closes. This requires a focus trap implementation (the focus-trap-react package is small and handles this well) and a dismissal handler on Escape.

Metrics hygiene. The onboarding_step_completed event should include the plan and role at time of completion, not at time of query. Plans change. If you store only userId and join at query time, your historical data will look different every time a user upgrades. Denormalize the relevant dimensions into the event at write time.

The Activation Definition Problem

The hardest part of this work is not the code. It is deciding what activation means for your product and being honest about whether your definition is correct.

A useful test: if you look at your activated cohort six months later, do they have materially higher retention than non-activated users? If not, your activation step is probably measuring something that correlates with good users rather than causing them to become good users. The definition should represent the moment of value delivery, not the completion of housekeeping.

Get that definition right, instrument it with the server action pattern above, and you have a reliable signal that product changes can move. Everything else, the wizard UI, the tour tooltips, the progressive disclosure, is in service of getting more users to that moment faster.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.