Thursday, July 10, 2025
Building Seamless User Journeys: Your Guide to React Onboarding with OnboardJS


Every product developer knows the pain: building an engaging onboarding experience. It often starts simple, but quickly spirals into a tangled mess of state management, conditional logic, persistence layers, and endless UI components. You end up building an "onboarding engine" from scratch, reinventing the wheel with every new product tour or multi-step form.
What if there was a better way? What if you could focus solely on the user interface and content, leaving the complex flow management to a robust core?
Enter OnboardJS. It's an open-source, headless onboarding engine designed to liberate you from this complexity. In this tutortial I will show you exactly how to get started with React onboarding using OnboardJS, enabling you to build powerful and flexible React onboarding flows that delight your users.
Before we dive in, we have a full React example on our GitHub so you don't have to start from scratch! All we ask is a ⭐ in return 😉.
What is OnboardJS? The Headless Advantage
At its core, OnboardJS provides a powerful, framework-agnostic onboarding engine. This "headless" approach means the engine handles all the intricate logic:
- Determining the next/previous step
- Managing the flow's state and context
- Handling conditional navigation and skips
- Integrating with data persistence and analytics
- Providing a robust plugin system for extensibility
This core engine is entirely separate from your UI. For React developers, this is where the @onboardjs/react
package comes in. It provides hooks and a context provider to seamlessly connect the OnboardJS engine to your React components, allowing you to render any step dynamically.
Why OnboardJS for Your React App?
- Simplify Complex Logic: Stop writing endless if/else statements for navigation. OnboardJS handles it.
- Clean Separation of Concerns: Your React components focus purely on rendering and user interaction, while the engine manages the flow. This makes your code cleaner and easier to maintain.
- Extensible by Design: Need to persist user progress to Supabase or track events with PostHog? Our plugin system makes it a breeze (and we already have plugins for these!).
- Developer Experience (DX) Focused: Built with TypeScript, OnboardJS offers type safety and a predictable API, making your React onboarding flow development much smoother.
- Future-Proof: The headless nature means your core onboarding logic isn't tied to React, giving you flexibility down the line.
Getting Started: Installation
Let's dive into setting up OnboardJS in your React project. In this example, we assume you already have a React project set up.
If you don't, I follow this React + Vite documentation to get started with a new project.
First, install the necessary packages:
npm install @onboardjs/core @onboardjs/react
Step 1: Define Your Onboarding Steps (Configuration)
The heart of your React onboarding flow is its configuration. This is where you define your steps: their IDs, types, and the data (payload) they need to render.
Create a config/onboardingConfig.ts
file (or similar):
// config/onboardingConfig.ts
// Optionally, define your custom onboarding context type
export interface MyAppContext extends OnboardingContext {
currentUser?: {
id: string;
email: string;
firstName?: string;
};
// Add any other global data you need throughout the flow
}
export const onboardingSteps: OnboardingStep<MyAppContext>[] = [
{
id: "welcome",
payload: {
mainText: "Welcome to our product! Let's get you set up.",
subText: "This quick tour will guide you through the basics.",
},
nextStep: "collect-info", // Go to next step by ID
},
{
id: "collect-info",
type: "CUSTOM_COMPONENT", // We'll render this with a custom React component
payload: {
componentKey: "UserProfileForm", // Key to map to your React component
formFields: [
// Example form field data
{ id: "name", label: "Your Name", type: "text", dataKey: "userName" },
{ id: "email", label: "Email", type: "email", dataKey: "userEmail" },
],
},
// Conditionally skip if user data already exists
condition: (context) => !context.currentUser?.firstName,
isSkippable: true,
skipToStep: "select-plan",
},
{
id: "select-plan",
type: "SINGLE_CHOICE",
payload: {
question: "Which plan are you interested in?",
options: [
{ id: "basic", label: "Basic", value: "basic" },
{ id: "pro", label: "Pro", value: "pro" },
],
dataKey: "chosenPlan", // Data will be stored as flowData.chosenPlan
},
// nextStep defaults to the next step in array if not specified
},
{
id: "all-done",
payload: {
mainText: "You're all set!",
subText: "Thanks for completing the onboarding.",
},
},
];
export const onboardingConfig: OnboardingEngineConfig<MyAppContext> = {
steps: onboardingSteps,
initialStepId: "welcome", // Start here or from persisted data
initialContext: {
// Initial global context, can be overwritten by loaded data
currentUser: { id: "user_123", email: "test@example.com" },
},
// You can define load/persist/clearData functions here,
// or use the localStoragePersistence option in OnboardingProvider
};
For more details on defining steps and configurations, refer to the OnboardJS Core Documentation: Configuration.
Step 2: Wrap Your App with OnboardingProvider
The OnboardingProvider
from @onboardjs/react
sets up the OnboardingEngine and makes its state and actions available throughout your React component tree via Context.
If you're using Next.js App Router, you'll place this in your layout.tsx
or a dedicated client component wrapper. For our case, it goes into index.tsx
or App.tsx
.
// app/OnboardingWrapper.tsx
"use client"; // Important only for Next.js App Router
import { OnboardingProvider } from "@onboardjs/react";
import { onboardingConfig, type MyAppContext } from "@/config/onboardingConfig"; // Adjust path as needed
export function OnboardingWrapper({ children }: { children: React.ReactNode }) {
// Example: Using localStorage for persistence - great for quick demos!
const localStoragePersistenceOptions = {
key: "onboardjs-demo-state",
ttl: 7 * 24 * 60 * 60 * 1000, // 7 days TTL
};
// Optional: Listen for flow completion to clear local storage
const handleFlowComplete = async (context: MyAppContext) => {
console.log("Onboarding Flow Completed!", context.flowData);
// Any final actions like redirecting, showing success message etc.
// The provider automatically clears local storage if localStoragePersistence is active.
};
// Optional: Listen for step changes for debugging or custom logic
const handleStepChange = (newStep, oldStep, context) => {
console.log(
`Step changed from ${oldStep?.id || "N/A"} to ${newStep?.id || "N/A"}`,
context.flowData,
);
};
return (
<OnboardingProvider
{...onboardingConfig} // Pass your defined steps, initialStepId, initialContext etc.
localStoragePersistence={localStoragePersistenceOptions}
onFlowComplete={handleFlowComplete}
onStepChange={handleStepChange}
// You can also pass custom loadData/persistData/clearPersistedData here
>
{children}
</OnboardingProvider>
);
}
// In your root layout.tsx:
// import { OnboardingWrapper } from "./OnboardingWrapper";
// export default function RootLayout({ children }) {
// return (
// <html>
// <body>
// <OnboardingWrapper>{children}</OnboardingWrapper>
// </body>
// </html>
// );
// }
Step 3: Render Your Current Step with useOnboarding
The useOnboarding
hook gives you access to the current state of the engine (like currentStep
) and actions (next
, previous
, skip
, goToStep
, updateContext
).You'll also need a StepComponentRegistry
to map your step types or step IDs (e.g., "INFORMATION", "CUSTOM_COMPONENT") to actual React components.
First, define your StepComponentRegistry
:
// config/stepRegistry.tsx
import React from "react";
import {
useOnboarding,
type StepComponentRegistry,
type StepComponentProps,
} from "@onboardjs/react";
import type { InformationStepPayload } from "@onboardjs/core";
import type { MyAppContext } from "@/config/onboardingConfig";
// --- Step Components (examples) - Feel free to put these into separate component files ---
const InformationStep: React.FC<StepComponentProps<InformationStepPayload>> = ({
payload,
}) => {
return (
<div>
<h2 className="text-2xl font-bold mb-4">{payload.mainText}</h2>
{payload.subText && <p className="text-gray-600">{payload.subText}</p>}
</div>
);
};
const UserProfileFormStep: React.FC<StepComponentProps> = ({
payload,
coreContext,
}) => {
const { updateContext } = useOnboarding<MyAppContext>();
const [userName, setUserName] = React.useState(
coreContext.flowData.userName || "",
);
const [userEmail, setUserEmail] = React.useState(
coreContext.flowData.userEmail || "",
);
React.useEffect(() => {
// Update the context whenever userName or userEmail changes
// Note: There are more sophisticated ways to handle this,
// such as debouncing or only updating on form submission.
updateContext({ flowData: { userName, userEmail } });
}, [userName, userEmail, updateContext]);
return (
<div>
<h2 className="text-2xl font-bold mb-4">Tell us about yourself!</h2>
<input
type="text"
placeholder="Your Name"
value={userName}
onChange={(e) => setUserName(e.target.value)}
className="border p-2 rounded mb-2 w-full"
/>
<input
type="email"
placeholder="Your Email"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
className="border p-2 rounded mb-4 w-full"
/>
{payload.formFields?.map((field: any) => (
<div key={field.id}>
{/* Render other form fields based on payload.formFields */}
</div>
))}
</div>
);
};
// Map your step types to React components
export const stepComponentRegistry: StepComponentRegistry = {
INFORMATION: InformationStep,
CUSTOM_COMPONENT: UserProfileFormStep, // Map 'CUSTOM_COMPONENT' to your form
// You'd add components for 'SINGLE_CHOICE', 'MULTIPLE_CHOICE', etc. here
// For example, you could defined a one-off component for the 'select-plan' id step
// 'select-plan': SelectPlanStep, // Example for a custom step
};
Then, add it to your OnboardingProvider
:
import { stepComponentRegistry } from "@/config/stepRegistry"
// In your OnboardingWrapper
return (
<OnboardingProvider
componentRegistry={stepComponentRegistry}
>
{children}
</OnboardingProvider>
);
And finally, we provide our Onboarding UI:
// components/OnboardingUI.tsx
import React from "react";
import {
useOnboarding,
} from "@onboardjs/react";
import type { OnboardingContext } from "@onboardjs/core";
export default function OnboardingUI() {
const { engine, state, next, previous, isCompleted, currentStep, renderStep, error } =
useOnboarding<MyAppContext>();
if (!engine || !state) {
return <div className="p-4">Loading onboarding...</div>;
}
if (error) {
return (
<div className="p-4 text-red-500">
Error: {error.message} (Please check console for details)
</div>
);
}
if (currentStep === null || isCompleted) {
return (
<div className="p-8 text-center bg-green-50 rounded-lg">
<h2 className="text-3xl font-bold text-green-700">
Onboarding Complete!
</h2>
<p className="text-gray-700 mt-4">
Thanks for walking through the flow. Check your console for the final
context!
</p>
<button
onClick={() => engine.reset()}
className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Reset Onboarding
</button>
</div>
);
}
const { isLastStep, canGoPrevious } = state;
return (
<div className="p-8 bg-white rounded-lg shadow-xl max-w-md mx-auto my-10">
<h3 className="text-xl font-semibold mb-6">
Step: {String(currentStep?.id)} ({currentStep?.type})
</h3>
<div className="mb-6">
{renderStep()}
</div>
<div className="flex justify-between mt-8">
<button
onClick={() => previous()}
disabled={!canGoPrevious}
className="px-6 py-3 bg-gray-300 text-gray-800 rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-400 transition-colors"
>
Previous
</button>
<button
onClick={() => next()}
className="px-6 py-3 bg-blue-600 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700 transition-colors"
>
{isLastStep ? "Finish" : "Next"}
</button>
</div>
<div className="mt-4 text-sm text-center text-gray-500">
Current flow data:{" "}
<pre className="bg-gray-100 p-2 rounded text-xs mt-2 overflow-x-auto">
{JSON.stringify(state.context.flowData, null, 2)}
</pre>
</div>
</div>
);
}
The custom step components and the Onboarding UI is where YOU shine. These are the components where you can realise your beautiful design!
Next Steps: Beyond the Basics
You've now got a functional React onboarding flow! But OnboardJS offers much more:
- Conditional Steps: Use the condition property on any step to dynamically include or skip it based on your context.
- Plugins for Persistence & Analytics: Integrate seamlessly with your backend or analytics tools. Check out our dedicated
@onboardjs/supabase-plugin
and@onboardjs/posthog-plugin
for automated data handling. - Advanced Step Types: Explore CHECKLIST, MULTIPLE_CHOICE, and SINGLE_CHOICE steps for common onboarding patterns, or define even more CUSTOM_COMPONENT types.
- Custom Context: Extend the OnboardingContext to store any global data your flow needs, making it accessible across all steps.
- Error Handling: Leverage the built-in error handling and error state from
useOnboarding
to provide graceful fallbacks.
Conclusion: Build Better Onboarding, Faster
Building a compelling React onboarding flow doesn't have to be a drag. OnboardJS empowers you to create dynamic, data-driven user journeys with a clear separation of concerns, robust features, and excellent developer experience. By handling the complex orchestration, OnboardJS lets you focus on what truly matters: designing an intuitive and effective first impression for your users.Ready to build your next React onboarding masterpiece?
- Explore the live demo: https://onboardjs.com/
- Get the source code and ⭐ us on GitHub: https://github.com/Somafet/onboardjs
- Join our Discord community
What challenges have you faced building React onboarding flows, and how could a tool like OnboardJS help you overcome them?