Skip to main content
Back to Blog
Tutorials
4 min read
November 8, 2024

How to Build an Onboarding Flow in Next.js

Create a multi-step onboarding flow in Next.js. Progress tracking, form validation, and conditional steps.

Ryel Banfield

Founder & Lead Developer

A good onboarding flow helps users get value from your product faster. Here is how to build one with step tracking and validation.

Step 1: Define Onboarding Steps

// lib/onboarding.ts
export const ONBOARDING_STEPS = [
  { id: "welcome", title: "Welcome", description: "Tell us about yourself" },
  { id: "company", title: "Company", description: "Your company details" },
  { id: "preferences", title: "Preferences", description: "Customize your experience" },
  { id: "complete", title: "Complete", description: "You're all set!" },
] as const;

export type StepId = (typeof ONBOARDING_STEPS)[number]["id"];

Step 2: Onboarding Layout

// app/(onboarding)/layout.tsx
export default function OnboardingLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-950">
      <div className="w-full max-w-xl">{children}</div>
    </div>
  );
}

Step 3: Onboarding Wrapper Component

"use client";

import { useState } from "react";
import { ONBOARDING_STEPS, type StepId } from "@/lib/onboarding";

interface OnboardingData {
  name: string;
  role: string;
  companyName: string;
  companySize: string;
  industry: string;
  goals: string[];
}

export function OnboardingWizard() {
  const [currentStep, setCurrentStep] = useState(0);
  const [data, setData] = useState<Partial<OnboardingData>>({});
  const [saving, setSaving] = useState(false);

  function updateData(updates: Partial<OnboardingData>) {
    setData((prev) => ({ ...prev, ...updates }));
  }

  function nextStep() {
    if (currentStep < ONBOARDING_STEPS.length - 1) {
      setCurrentStep((prev) => prev + 1);
    }
  }

  function prevStep() {
    if (currentStep > 0) {
      setCurrentStep((prev) => prev - 1);
    }
  }

  async function completeOnboarding() {
    setSaving(true);
    try {
      await fetch("/api/onboarding", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
      nextStep(); // Go to complete step
    } finally {
      setSaving(false);
    }
  }

  return (
    <div className="rounded-2xl border bg-white p-8 shadow-sm dark:border-gray-800 dark:bg-gray-900">
      {/* Progress Bar */}
      <div className="mb-8">
        <div className="mb-2 flex justify-between text-xs text-gray-500">
          <span>Step {currentStep + 1} of {ONBOARDING_STEPS.length}</span>
          <span>{ONBOARDING_STEPS[currentStep].title}</span>
        </div>
        <div className="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
          <div
            className="h-full rounded-full bg-blue-600 transition-all duration-300"
            style={{
              width: `${((currentStep + 1) / ONBOARDING_STEPS.length) * 100}%`,
            }}
          />
        </div>
      </div>

      {/* Step Content */}
      {currentStep === 0 && (
        <WelcomeStep data={data} onChange={updateData} />
      )}
      {currentStep === 1 && (
        <CompanyStep data={data} onChange={updateData} />
      )}
      {currentStep === 2 && (
        <PreferencesStep data={data} onChange={updateData} />
      )}
      {currentStep === 3 && <CompleteStep />}

      {/* Navigation */}
      {currentStep < ONBOARDING_STEPS.length - 1 && (
        <div className="mt-8 flex justify-between">
          <button
            onClick={prevStep}
            disabled={currentStep === 0}
            className="rounded-lg border px-4 py-2 text-sm disabled:opacity-30"
          >
            Back
          </button>
          {currentStep === ONBOARDING_STEPS.length - 2 ? (
            <button
              onClick={completeOnboarding}
              disabled={saving}
              className="rounded-lg bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
            >
              {saving ? "Saving..." : "Complete Setup"}
            </button>
          ) : (
            <button
              onClick={nextStep}
              className="rounded-lg bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-blue-700"
            >
              Continue
            </button>
          )}
        </div>
      )}
    </div>
  );
}

Step 4: Welcome Step

function WelcomeStep({
  data,
  onChange,
}: {
  data: Partial<OnboardingData>;
  onChange: (d: Partial<OnboardingData>) => void;
}) {
  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold">Welcome!</h2>
        <p className="mt-1 text-sm text-gray-500">
          Let us personalize your experience.
        </p>
      </div>
      <div className="space-y-4">
        <div>
          <label className="mb-1 block text-sm font-medium">Your Name</label>
          <input
            value={data.name || ""}
            onChange={(e) => onChange({ name: e.target.value })}
            placeholder="Jane Smith"
            className="w-full rounded-lg border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800"
          />
        </div>
        <div>
          <label className="mb-1 block text-sm font-medium">Your Role</label>
          <select
            value={data.role || ""}
            onChange={(e) => onChange({ role: e.target.value })}
            className="w-full rounded-lg border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800"
          >
            <option value="">Select a role</option>
            <option value="founder">Founder / CEO</option>
            <option value="marketing">Marketing</option>
            <option value="developer">Developer</option>
            <option value="designer">Designer</option>
            <option value="other">Other</option>
          </select>
        </div>
      </div>
    </div>
  );
}

Step 5: Company Step

function CompanyStep({
  data,
  onChange,
}: {
  data: Partial<OnboardingData>;
  onChange: (d: Partial<OnboardingData>) => void;
}) {
  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold">About Your Company</h2>
        <p className="mt-1 text-sm text-gray-500">
          This helps us tailor recommendations.
        </p>
      </div>
      <div className="space-y-4">
        <div>
          <label className="mb-1 block text-sm font-medium">Company Name</label>
          <input
            value={data.companyName || ""}
            onChange={(e) => onChange({ companyName: e.target.value })}
            className="w-full rounded-lg border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800"
          />
        </div>
        <div>
          <label className="mb-1 block text-sm font-medium">Company Size</label>
          <div className="grid grid-cols-3 gap-2">
            {["1-5", "6-25", "26-100", "101-500", "500+"].map((size) => (
              <button
                key={size}
                onClick={() => onChange({ companySize: size })}
                className={`rounded-lg border px-3 py-2 text-sm transition ${
                  data.companySize === size
                    ? "border-blue-500 bg-blue-50 text-blue-600 dark:bg-blue-900/20"
                    : "hover:bg-gray-50 dark:hover:bg-gray-800"
                }`}
              >
                {size}
              </button>
            ))}
          </div>
        </div>
        <div>
          <label className="mb-1 block text-sm font-medium">Industry</label>
          <select
            value={data.industry || ""}
            onChange={(e) => onChange({ industry: e.target.value })}
            className="w-full rounded-lg border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800"
          >
            <option value="">Select an industry</option>
            <option value="saas">SaaS</option>
            <option value="ecommerce">E-commerce</option>
            <option value="healthcare">Healthcare</option>
            <option value="finance">Finance</option>
            <option value="education">Education</option>
            <option value="other">Other</option>
          </select>
        </div>
      </div>
    </div>
  );
}

Step 6: Preferences Step

function PreferencesStep({
  data,
  onChange,
}: {
  data: Partial<OnboardingData>;
  onChange: (d: Partial<OnboardingData>) => void;
}) {
  const goals = [
    "Increase website traffic",
    "Generate more leads",
    "Improve conversion rates",
    "Build a new website",
    "Redesign existing site",
    "Build a web application",
  ];

  function toggleGoal(goal: string) {
    const current = data.goals || [];
    const updated = current.includes(goal)
      ? current.filter((g) => g !== goal)
      : [...current, goal];
    onChange({ goals: updated });
  }

  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold">Your Goals</h2>
        <p className="mt-1 text-sm text-gray-500">
          Select all that apply.
        </p>
      </div>
      <div className="grid grid-cols-2 gap-2">
        {goals.map((goal) => (
          <button
            key={goal}
            onClick={() => toggleGoal(goal)}
            className={`rounded-lg border p-3 text-left text-sm transition ${
              (data.goals || []).includes(goal)
                ? "border-blue-500 bg-blue-50 text-blue-600 dark:bg-blue-900/20"
                : "hover:bg-gray-50 dark:hover:bg-gray-800"
            }`}
          >
            {goal}
          </button>
        ))}
      </div>
    </div>
  );
}

Step 7: Completion Step

function CompleteStep() {
  return (
    <div className="text-center">
      <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
        <CheckIcon className="h-8 w-8 text-green-600" />
      </div>
      <h2 className="text-2xl font-bold">You are all set!</h2>
      <p className="mt-2 text-sm text-gray-500">
        Your account has been configured. Let us get started.
      </p>
      <a
        href="/dashboard"
        className="mt-6 inline-block rounded-lg bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-blue-700"
      >
        Go to Dashboard
      </a>
    </div>
  );
}

Need User Onboarding Built?

We design and build onboarding flows, user experiences, and web applications that drive adoption. Contact us to discuss your project.

onboardinguser experienceReactNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles