Friday, June 20, 2025

Build Robust User Onboarding with Supabase & OnboardJS (No More Lost Progress!)

Soma Somorjai
OnboardJS with Supabase

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:

tsx
9 lines
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:

  1. Identifies the User: If useSupabaseAuth is true, it fetches the currently authenticated Supabase user. Otherwise, it extracts the user ID from your initial OnboardingContext.
  2. Fetches from Supabase: It queries your onboarding_state table (or whatever tableName you configured) using the user ID.
  3. Hydrates Context: If data is found, it parses the state_data JSON and returns it to OnboardJS. Crucially, if useSupabaseAuth is enabled, the Supabase user object is automatically injected into your context.currentUser, ready for use throughout your flow.
tsx
32 lines
// 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.

  1. Extracts Current State: It takes the entire current OnboardingContext and the currentStepId from OnboardJS.
  2. Upserts to Supabase: It then performs an upsert operation on your Supabase table. Upsert is incredibly powerful here: if a record for the userId already exists, it updates it; otherwise, it inserts a new one. This ensures atomic and consistent state saving.
tsx
17 lines
// 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.

tsx
12 lines
// 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.

typescript
8 lines
// 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:

sh
3 lines
npm install @onboardjs/supabase-plugin @supabase/supabase-js
# or
yarn add @onboardjs/supabase-plugin @supabase/supabase-js

2. Initialize your OnboardingEngine:

tsx
34 lines
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).

mysql
5 lines
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!

What problem have YOU solved with OnboardJS? Share your success stories with me on X/Twitter!