Stop Wasting Time: Build Your First Onboarding Flow with OnboardJS in Under 10 Minutes
Monday, June 23, 2025


I Hate When Building Simple Onboarding Feels Like a Whole New App
As a developer, I've spent countless hours wrestling with custom onboarding logic. The state management, the routing, the persistence, the conditional steps... it's a headache most libraries just ignore, leaving you to piece together a complex state machine from scratch.
You're trying to ship a great product, but instead, you're deep in the weeds of boilerplate code, managing all the possible states your app could be in as users navigate back and forth, or worse, worrying about losing their progress on a simple page refresh.
The truth is: you shouldn't have to build a complex state machine for _every single onboarding flow_. OnboardJS does it for you.
In this post, you'll learn how to set up OnboardJS and create a working onboarding flow in a Next.js app — from install to a running demo.
Introducing OnboardJS: The Headless Advantage
OnboardJS is a headless, type-safe, and extensible engine designed specifically for managing multi-step user flows. "Headless" means OnboardJS gives you the powerful brain to manage your flow's state, logic, and persistence, while you provide the beauty — your own React components.
This separation is crucial:
- Your UI, Your Rules: Use any component library — Tailwind, Shadcn UI, Chakra UI, or your own custom components.
- Built-in Logic: Handle navigation, conditional steps, and data collection without writing a state machine.
- Automatic Persistence: Store user progress with a single prop, preventing frustrating restarts.
- Type-Safe: Full TypeScript support catches errors at build time, not in production.
If you're coming from a tooltip-tour library and wondering how this is different, check out why OnboardJS isn't just another tour library.
Hands-On: Building Your First Flow
We'll create a two-step onboarding flow: a "Welcome" step to collect a user's name, and a "Finish" step to display the collected data.
No boilerplate, no complex state machines. Define your steps, map your UI, and let OnboardJS handle the rest.
1. Install OnboardJS
In your Next.js project (or any React app), install the two packages:
1npm install @onboardjs/core @onboardjs/react
2. Define Your Steps
OnboardJS steps define what your flow looks like — the order, the data each step needs, and which component renders it.
Create src/lib/onboarding/steps.ts:
1// src/lib/onboarding/steps.ts
2import WelcomeStep from '@/components/onboarding/WelcomeStep';
3import FinishStep from '@/components/onboarding/FinishStep';
4import type { OnboardingStep } from '@onboardjs/react';
5
6export const steps: OnboardingStep[] = [
7 {
8 id: 'welcome',
9 component: WelcomeStep,
10 nextStep: 'finish',
11 payload: {
12 title: 'Welcome to Our App!',
13 },
14 },
15 {
16 id: 'finish',
17 component: FinishStep,
18 nextStep: null, // End of flow
19 payload: {
20 title: 'Setup Complete!',
21 },
22 },
23];
Each step has an id, a component to render, and a payload for any custom data your component needs (like a title). Setting nextStep: null marks the end of the flow.
3. Create Your Step Components
Step components receive payload (your custom data) and coreContext (the global onboarding data) as props. Use the useOnboarding hook for navigation and updating context.
`src/components/onboarding/WelcomeStep.tsx`:
1// src/components/onboarding/WelcomeStep.tsx
2'use client';
3
4import React from 'react';
5import { useOnboarding, StepComponentProps } from '@onboardjs/react';
6
7interface WelcomePayload {
8 title: string;
9}
10
11const WelcomeStep: React.FC<StepComponentProps<WelcomePayload>> = ({
12 payload,
13 coreContext,
14}) => {
15 const { updateContext } = useOnboarding();
16
17 const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
18 updateContext({ flowData: { profileName: e.target.value } });
19 };
20
21 return (
22 <div>
23 <h2>{payload.title}</h2>
24 <label htmlFor="name-input">Your Name:</label>
25 <input
26 id="name-input"
27 type="text"
28 defaultValue={coreContext?.flowData?.profileName || ''}
29 onChange={handleNameChange}
30 placeholder="Enter your name"
31 />
32 </div>
33 );
34};
35
36export default WelcomeStep;
`src/components/onboarding/FinishStep.tsx`:
1// src/components/onboarding/FinishStep.tsx
2'use client';
3
4import React from 'react';
5import { StepComponentProps } from '@onboardjs/react';
6
7interface FinishPayload {
8 title: string;
9}
10
11const FinishStep: React.FC<StepComponentProps<FinishPayload>> = ({
12 payload,
13 coreContext,
14}) => {
15 const userName = coreContext?.flowData?.profileName || 'Guest';
16
17 return (
18 <div>
19 <h2>{payload.title}</h2>
20 <p>Welcome, {userName}!</p>
21 <pre style={{ background: '#f5f5f5', padding: '12px', borderRadius: '6px' }}>
22 {JSON.stringify(coreContext?.flowData, null, 2)}
23 </pre>
24 </div>
25 );
26};
27
28export default FinishStep;
Notice these components only handle rendering. Navigation (Next, Back, Skip) is handled centrally in the next step — no need to scatter buttons across every step component.
4. Build the Onboarding UI Wrapper
This single component renders the current step and provides navigation controls. It uses renderStep() to display whatever component is active, and reads state to know which buttons to show.
`src/components/onboarding/OnboardingUI.tsx`:
1// src/components/onboarding/OnboardingUI.tsx
2'use client';
3
4import React from 'react';
5import { useOnboarding } from '@onboardjs/react';
6
7export default function OnboardingUI() {
8 const { state, isLoading, next, previous, skip, reset, renderStep } =
9 useOnboarding();
10
11 if (!state) {
12 return <p style={{ textAlign: 'center', padding: '20px' }}>Loading...</p>;
13 }
14
15 if (state.error) {
16 return (
17 <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>
18 Error: {state.error.message}
19 <button onClick={() => reset()} style={{ marginLeft: '10px' }}>
20 Reset
21 </button>
22 </div>
23 );
24 }
25
26 if (state.isCompleted) {
27 return (
28 <div style={{ textAlign: 'center', padding: '20px' }}>
29 <h2>Onboarding Complete!</h2>
30 <p>You're all set.</p>
31 </div>
32 );
33 }
34
35 return (
36 <div style={{ maxWidth: '500px', margin: '40px auto', padding: '24px', border: '1px solid #ddd', borderRadius: '8px' }}>
37 {renderStep()}
38
39 <div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
40 {state.canGoPrevious && (
41 <button onClick={previous} disabled={isLoading}>
42 Back
43 </button>
44 )}
45 {state.isSkippable && (
46 <button onClick={skip} disabled={isLoading} style={{ marginLeft: 'auto' }}>
47 Skip
48 </button>
49 )}
50 {(state.canGoNext || state.isLastStep) && (
51 <button
52 onClick={() => next()}
53 disabled={isLoading}
54 style={{ marginLeft: 'auto', background: state.isLastStep ? '#28a745' : '#0070f3', color: 'white', border: 'none', padding: '8px 16px', borderRadius: '4px', cursor: 'pointer' }}
55 >
56 {state.isLastStep ? 'Finish' : 'Next'}
57 </button>
58 )}
59 </div>
60 </div>
61 );
62}
This keeps all navigation logic in one place. state.canGoNext, state.canGoPrevious, and state.isSkippable let you show exactly the right buttons for each step automatically.
5. Wire It Up with the Provider
The OnboardingProvider makes the engine available to your entire app. Create a client-side wrapper component so your root layout stays a server component:
`src/components/onboarding/OnboardingProviderWrapper.tsx`:
1// src/components/onboarding/OnboardingProviderWrapper.tsx
2'use client';
3
4import React from 'react';
5import { OnboardingProvider } from '@onboardjs/react';
6import { steps } from '@/lib/onboarding/steps';
7import { useRouter } from 'next/navigation';
8import OnboardingUI from './OnboardingUI';
9
10export default function OnboardingProviderWrapper() {
11 const router = useRouter();
12
13 return (
14 <OnboardingProvider
15 steps={steps}
16 localStoragePersistence={{ key: 'myAppOnboarding' }}
17 onFlowComplete={(context) => {
18 console.log('Onboarding complete!', context);
19 router.push('/dashboard');
20 }}
21 >
22 <OnboardingUI />
23 </OnboardingProvider>
24 );
25}
That localStoragePersistence line is all it takes to save and restore user progress across page refreshes — no extra code needed.
Then simply render this wrapper in your page:
1// src/app/onboarding/page.tsx
2import OnboardingProviderWrapper from '@/components/onboarding/OnboardingProviderWrapper';
3
4export default function OnboardingPage() {
5 return <OnboardingProviderWrapper />;
6}
6. Run It
Start your dev server:
1npm run dev
Navigate to /onboarding — you should see the Welcome step. Fill in your name, click Next, and see it reflected on the Finish step. Refresh the page — your progress is automatically restored.
What's Next
You just built a stateful onboarding flow without writing a single line of state management boilerplate. Here's where to go from here:
- Conditional steps: Show or hide steps based on user input using the
conditionfunction on any step — great for branching flows like role selection. - Cross-device persistence: Swap localStorage for Supabase persistence so users pick up where they left off on any device.
- Analytics: Track completion rates, drop-off points, and step timing with the PostHog plugin — setup takes under 5 minutes.
- URL-based navigation: Sync steps to your URL with the built-in Next.js navigator adapter, so each step has its own shareable route.
- A/B testing: Run experiments on your onboarding flows to find what converts best. See our guide on A/B testing with PostHog.
For a deeper dive into the React integration, read the full React onboarding guide or the Next.js-specific guide.
Ready to build smarter onboarding experiences?
- Explore OnboardJS on GitHub (consider giving us a star!)
- Read the Full OnboardJS Docs
- Join the OnboardJS Discord Community