the right way to design onboarding
derive onboarding UI from the real data your backend already stores
onboarding sounds like one of those very simple startup problems, and it is.
you show new users three screens, they fill in some stuff, and now they are onboarded. done.
but startups change fast. maybe you add a step next week. maybe inviting a teammate stops being mandatory. maybe connecting an integration only matters for teams on a certain plan.
and suddenly you have a backend that looks like this:
user.completedStepOne = true;
user.completedStepTwo = true;
user.completedStepThree = false;
now you want to insert a new step between one and two, or move a field from step two to step three. good luck lol.
the wrong approach
the easy mistake is treating onboarding like a sequence of backend steps.
1. create workspace
2. invite teammate
3. connect slack
and then saving that a user completed steps one and two, but not step three.
this is wrong in my opinion because step two is not a real thing in your product… it’s just where you happened to put something in a wizard one day.
also that flag is redundant because usually it’s just a shadow of data you already store.
“create a workspace” means a workspace should exist in your database.
“verify your email” means their email should be verified.
“connect slack” means there should be a valid slack connection in your db.
so if you also save completedStepThree = true or teammateInvited = true, you now have duplicate state.
hasConnectedIntegration(workspace);
!user.completedStepThree;
which one wins?
derive from the data
instead of saving completedStepTwo, ask the backend what is actually true.
hasWorkspace(user);
user.emailVerifiedAt !== null;
hasConnectedIntegration(workspace);
workspace.sentInvitesTo.length > 0;
you can then derive the UI from the presence or absence of that data.
if the workspace exists, do not show “create a workspace” step. if slack is connected, do not show “connect slack”. simple.
onboarding is now just a function of the things you have for the user in your database, instead of a second system trying to describe the same thing.
why this matters for startups
you are probably not changing onboarding every day, but you will change your mind about it.
you might go from:
create workspace -> invite teammate -> connect integration
to:
create workspace -> connect integration -> invite teammate
or make the invite optional. or only show an integration step only to a particular kind of customer.
when you work this way, changing onboarding is mostly changing the rules for what to show.
maybe inviting a teammate used to be required and now it is optional. fine. change the rule without having to migrate every user’s completedStepTwo value because step 2 now means something else.
obviously, some things still need to happen before others. you cannot ask someone to invite teammates before they have a workspace for those teammates to join. that is true no matter how you build onboarding.
hasVerifiedEmail(user); // show the email verification step if false
hasWorkspace(user); // show the workspace setup step if false
hasConnectedIntegration(workspace); // show the integration step if false
what should you save separately?
you can still save onboarding events, but save them for what they are: analytics and behavior, not truth about product setup.
also, not every onboarding action maps cleanly to data you can derive later.
maybe the goal is educational, like watching an intro video or reading a short guide. maybe the user needs to pick a timezone or theme, but your app already filled in a default, so you cannot tell whether they chose it.
in those cases, save the action explicitly. just name it after the thing that happened, not the step where it happened.
user dismissed the checklist
user skipped inviting teammates
user watched the tutorial
user confirmed their timezone
user belongs to onboarding flow v2
user was assigned to experiment B
this helps you understand how users behave during onboarding: what they skip, where they drop off, and which flow works better.
and again, it should not decide whether slack is actually connected, because that should come from the slack connection in your db.
finale
onboarding is UI that helps someone get your product into a useful state. usually that means creating or confirming some data. sometimes it means teaching them something.
your backend should know the more boring, permanent thing: what exists, what is missing, and which onboarding-only actions happened.
derive the UI from the data you already trust. save the bits that are genuinely about the journey separately.
then when your startup inevitably decides that step two should actually be step five, you change the rule for what to show instead of explaining to your team why completedStepTwo somehow means “connected slack” now.
honestly, you could probably give this post to your coding agent and ask it to design onboarding this way.
fin.