Next.js developers love the power and flexibility of the framework, but even with its advantages, building an engaging, multi-step user onboarding experience can quickly become a significant undertaking. Managing step logic, dynamic navigation, persistence across sessions, and analytics can turn your elegant codebase into a complex web of conditional rendering and state management.
Imagine an onboarding library that handles all this complexity for you, letting you focus on crafting beautiful UI components and compelling content, perfectly integrated within your Next.js application.
This is where OnboardJS comes in. It's an open-source, headless onboarding engine designed to abstract away the intricate logic of multi-step flows. This post will guide you through setting up OnboardJS to build a robust Next.js onboarding flow, making your first-user experiences intuitive and efficient.
What is OnboardJS? The Headless Advantage for Next.js
OnboardJS offers a powerful, framework-agnostic onboarding engine (from @onboardjs/core
). This "headless" nature means it focuses solely on the intricate backend logic of your flow:
- Dynamically determining the next/previous step
- Managing the flow's internal state and context
- Handling conditional navigation and step skipping
- Seamlessly integrating with data persistence and analytics layers
- Providing a robust plugin system for extended functionality
For Next.js developers, this headless approach is a perfect fit. The core engine runs independently, and our @onboardjs/react
package provides the necessary hooks and a context provider to integrate it effortlessly into your Next.js Client Components, allowing you to render any step dynamically and respond to user actions.
Before we dive in, we have a full Next.js example on our GitHub so you don't have to start from scratch! All we ask is a ⭐ in return 😉.
Why OnboardJS for Your Next.js App?
- Simplifies Complex Logic: No more tangled if/else trees or manual state machines for your Next.js onboarding flow. OnboardJS handles the entire orchestration.
- SSR/CSR Compatibility: OnboardJS's headless nature means its core logic isn't tied to the DOM, making it ideal for Next.js environments. The React components that consume it live in Client Components, where they belong.
- Extensible by Design: Need to persist user progress to PostgreSQL via Supabase or Neon? Or track onboarding events with PostHog? Our plugin system makes it incredibly easy – and we already offer official plugins for these!
Getting Started: Installation
Let's install the necessary packages for your Next.js project. This tutorial assumes that you already have a Next.js project set up. If you don't have that yet, follow the official Next.js Docs.
npm install @onboardjs/core @onboardjs/react
Step 1: Define Your Onboarding Steps (Configuration)
The core definition of your Next.js onboarding flow lies in its configuration. This is where you outline each step: its unique ID, its type (e.g., "INFORMATION", "CUSTOM_COMPONENT"), and any specific data (payload) it needs for rendering. This configuration file can be shared between server and client components as needed.
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 (Next.js Specifics)
This is a crucial step for Next.js, especially with the App Router. The OnboardingProvider
from @onboardjs/react
must be defined as a Client Component (using "use client";
) because it relies on React Hooks and browser APIs (like localStorage
if used for device persistence). It then makes the OnboardJS engine's state and actions available throughout your Client Component tree.
Create a dedicated Client Component wrapper component (e.g.: OnboardingWrapper.tsx
):
// app/OnboardingWrapper.tsx
"use client"; // REQUIRED: This makes the component a Client Component
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 Next.js 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 Next.js 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 Next.js onboarding?
- Explore the live demo: https://onboardjs.com/
- Get the source code and ⭐ us on GitHub!
- Join our Discord community
What challenges have you faced building Next.js onboarding flows, and how could a tool like OnboardJS help you overcome them? Tell me on Discord!
Top comments (0)