Friday, June 20, 2025
Build Robust User Onboarding with Supabase & OnboardJS (No More Lost Progress!)


The Silent Killer: Why Your User Onboarding Might Be Leaking Users
As developers, we pour our hearts into crafting intuitive user interfaces and powerful backend logic. But there's a sneaky culprit that often undermines all that effort: fragile user onboarding persistence.Think about it: Your user starts the onboarding flow, maybe fills out a few steps, then gets interrupted. Their browser crashes, they switch devices, or they simply close the tab intending to come back later. What happens next?
- The Nightmare Scenario: They return, and their progress is GONE. They're forced to start from scratch. Frustration sets in. They leave, often never to return.
- Your Developer Headache: You're left trying to build a custom state machine to track every single user's progress, handle race conditions, and figure out how to reliably store and retrieve that state. Manual
localStorage
hacks? Inconsistent backend calls? It quickly turns into a tangled mess of conditional logic and tedious boilerplate.
I've been there. I hate when building something as critical as a user's first impression becomes an exercise in reinventing the wheel, patching together unreliable persistence mechanisms, and battling unexpected states. It steals time, adds technical debt, and ultimately, costs you users.
Enter OnboardJS: The Headless Engine for Seamless Flows
This is exactly why I built OnboardJS – a headless, type-safe onboarding engine designed to simplify complex user flows. OnboardJS handles the intricate state management, navigation logic, and conditional steps, allowing you to focus purely on your UI and user experience.
But an engine, no matter how powerful, needs a reliable fuel source for persistence. That's where databases like Supabase come in.
The Perfect Pairing: OnboardJS & Supabase for Bulletproof Persistence
If you're a Next.js developer, chances are you're already familiar with Supabase. Its robust Postgres database, real-time capabilities, and integrated authentication make it a go-to backend for modern web applications. It's perfectly suited for storing granular user data, including onboarding state.
We need a way to tell OnboardJS: "Hey, when you need to save or load user progress, talk to Supabase." This is where the power of OnboardJS's plugin architecture shines.
Building the Official Supabase Persistence Plugin for OnboardJS
OnboardJS's core is designed for extensibility. It provides clear, simple APIs to hook into its lifecycle for data loading, persistence, and even clearing user state. This allowed us to build the official @onboardjs/supabase-plugin
as a first-class solution.
Let's look at how it works under the hood.
1. The SupabasePersistencePluginConfig
First, we define how our plugin will be configured:
export interface SupabasePersistencePluginConfig extends PluginConfig {
client: SupabaseClient; // Your initialized Supabase client
tableName?: string; // e.g., 'onboarding_state'
userIdColumn?: string; // e.g., 'user_id'
contextKeyForId?: string; // Where to find the user's ID in your OnboardingContext
stateDataColumn?: string; // Where the JSON state is stored, e.g., 'state_data'
useSupabaseAuth?: boolean; // Automatically use Supabase's auth.user.id
onError?: (error: PostgrestError, operation: SupabaseOperation) => void;
}
Notice the flexibility here: you can specify table/column names, and critically, how the plugin should identify the user. You can either let it automatically use Supabase's authenticated user ID (useSupabaseAuth: true
) or provide a path to a user ID within your custom OnboardingContext
(contextKeyForId: 'currentUser.id'
for example). This means the plugin adapts to your data model, not the other way around.
2. How install(
) Wires Up Persistence
The heart of the plugin is its install
method, which gets called when you add the plugin to your OnboardingEngine instance. Inside install
, we set the loadData
, persistData
, and clearPersistedData
handlers that OnboardJS's core persistence manager will use.
Let's break down the key parts:
Loading User Onboarding State (loadData
):
When your application initializes and OnboardJS needs to hydrate its state, it calls the loadData
handler. Our Supabase plugin's implementation:
- Identifies the User: If
useSupabaseAuth
istrue
, it fetches the currently authenticated Supabase user. Otherwise, it extracts the user ID from your initialOnboardingContext
. - Fetches from Supabase: It queries your
onboarding_state
table (or whatevertableName
you configured) using the user ID. - Hydrates Context: If data is found, it parses the
state_data
JSON and returns it to OnboardJS. Crucially, ifuseSupabaseAuth
is enabled, the Supabase user object is automatically injected into yourcontext.currentUser
, ready for use throughout your flow.
// Inside the plugin's install method:
engine.setDataLoadHandler(async () => {
let userId: string | undefined;
let user: User | null = null;
if (this.config.useSupabaseAuth) {
const { data } = await this.config.client.auth.getUser();
user = data.user;
userId = user?.id;
} else {
// Logic to get userId from contextKeyForId (not shown here for brevity)
userId = getUserIdFromContext();
}
if (!userId) return null; // No user ID, no data to load
const { data: stateData, error } = await this.config.client
.from(this.tableName)
.select(this.stateDataColumn)
.eq(this.userIdColumn, userId)
.maybeSingle(); // Efficiently retrieve a single record
if (error) { /* handle error */ return null; }
const loadedState = stateData?.[this.stateDataColumn] as LoadedData<TContext> || {};
// Inject user into context if using Supabase Auth
if (this.config.useSupabaseAuth && user) {
loadedState.currentUser = user;
}
return loadedState;
});
Persisting User Onboarding State (persistData
):
Whenever OnboardJS's internal context changes (e.g., a step is completed, updateContext
is called, or a checklist item is updated), the persistData
handler springs into action.
- Extracts Current State: It takes the entire current
OnboardingContext
and thecurrentStepId
from OnboardJS. - Upserts to Supabase: It then performs an
upsert
operation on your Supabase table.Upsert
is incredibly powerful here: if a record for theuserId
already exists, it updates it; otherwise, it inserts a new one. This ensures atomic and consistent state saving.
// Inside the plugin's install method:
engine.setDataPersistHandler(async (context, currentStepId) => {
const userId = getUserIdFromContext(); // Get user ID from the *latest* context
if (!userId) return;
const stateToPersist = { ...context, currentStepId };
const { error } = await this.config.client.from(this.tableName).upsert(
{
[this.userIdColumn]: userId,
[this.stateDataColumn]: stateToPersist,
},
{ onConflict: this.userIdColumn }, // Crucial for "update or insert" behavior
);
if (error) { /* handle error */ }
});
Clearing Persisted Data (clearPersistedData
):
This handler is particularly useful for scenarios like user logout, flow reset, or when you want to force a fresh onboarding experience. It simply clears the state_data
for a given user.
// Inside the plugin's install method:
engine.setClearPersistedDataHandler(async () => {
const userId = getUserIdFromContext();
if (!userId) return;
const { error } = await this.config.client
.from(this.tableName)
.update({ [this.stateDataColumn]: null }) // Set state data to null
.eq(this.userIdColumn, userId);
if (error) { /* handle error */ }
});
3. Robust Error Handling
Our Supabase plugin doesn't just work; it fails gracefully. It includes an onError
callback in its configuration, allowing you to define custom logic for Supabase-specific errors. Any errors are also passed to OnboardJS's central ErrorHandler
, ensuring consistency across your application.
// Simplified _handleError method
private _handleError(error: PostgrestError, operation: SupabaseOperation) {
if (this.config.onError) {
this.config.onError(error, operation);
}
// Also reports to the main OnboardJS engine's error handler
this.engine?.reportError(new Error(`[Supabase Plugin] ${operation} failed`), `SupabasePlugin.${operation}`);
}
The Payoff: What This Means for You
By using the @onboardjs/supabase-plugin
, you can:
- Eliminate Boilerplate: Stop writing tedious data persistence code. Focus on the value of your onboarding.
- Guarantee User Progress: Users will always pick up exactly where they left off, across devices, leading to significantly higher completion rates.
- Build Faster: Get a robust, battle-tested persistence layer out-of-the-box.
- Keep Your Stack: Seamlessly integrate with your existing Supabase setup, leveraging the tools you already love.
- Enhance User Experience: A smooth, uninterrupted onboarding journey delights users and sets the stage for long-term retention. Imagine tailoring your onboarding flow so perfectly, users get FOMO for not completing it – this plugin makes that kind of experience possible by handling the foundational state.
Getting Started is Simple!
1. Install the plugin:
npm install @onboardjs/supabase-plugin @supabase/supabase-js
# or
yarn add @onboardjs/supabase-plugin @supabase/supabase-js
2. Initialize your OnboardingEngine
:
import { OnboardingEngine } from "@onboardjs/core";
import { createSupabasePlugin } from "@onboardjs/supabase-plugin";
// Your Supabase client setup
const supabase = createClient({
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL!,
supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
});
// Your custom context interface (if any)
interface MyOnboardingContext extends OnboardingContext<User> {
// Add any custom context properties here
userRole?: string;
}
// Initialize the plugin
const supabasePlugin = createSupabasePlugin<MyOnboardingContext>({
client: supabase,
useSupabaseAuth: true, // Automatically link to Supabase authenticated user
tableName: "onboarding_state", // Optional, defaults to 'onboarding_state'
// onError: (error, op) => console.error(`[Plugin Error] ${op}:`, error)
});
// Your Onboarding Steps
const mySteps = [
{ id: "welcome", type: "INFORMATION", payload: { mainText: "Welcome!" } },
// ... more steps
];
const engine = new OnboardingEngine<MyOnboardingContext>({
steps: mySteps,
plugins: [supabasePlugin],
// Other engine configs like initialStepId, onFlowComplete etc.
});
3. Ensure your Supabase table is set up!
(A simple onboarding_state
table with user_id
(TEXT/UUID) and state_data
(JSONB) columns will get you started).
CREATE TABLE onboarding_state (
user_id UUID PRIMARY KEY REFERENCES auth.users (id) ON DELETE CASCADE,
state_data JSONB
);
-- Ensure you have RLS policies if needed, e.g., to allow users to update their own state
Ready to say goodbye to complex onboarding persistence and hello to delightful user journeys?
Dive into the code, contribute, and join our community!
- OnboardJS on GitHub: [Link to Repo]
- OnboardJS Documentation: [Link to Docs Site]
- Join our Discord: [Link to Discord]
What problem have YOU solved with OnboardJS? Share your success stories with me on X/Twitter!