LaunchKit

Onboarding in Rails

A multistep flow where each step is a form object, the order lives in a committed YAML file, and the gate that forces users through it needs one opt-out or the flow redirects to itself forever.

The signup form asks for an email and a password because anything more loses people. Everything else the product needs, a name, a workspace, a preference or two, has to be collected after the account exists. That is onboarding, and it is a multistep form with a gate in front of it.

Three parts that should not know about each other

A Rails onboarding flow goes wrong in one specific way: the steps, the validation and the navigation end up in the same place. The User model grows validates :company_name, presence: true, if: :step_two?, and six months later a completely unrelated admin screen cannot save a user because it does not know about step two.

The split here keeps them apart. Onboarding::Registry answers which steps exist and in what order. Onboarding::Step pairs a key with the form class that handles it. OnboardingController reads the current step, hands the params to that step's form, and moves on if it saved. None of the three knows what any step actually collects. The multistep flow works through the navigation half: resuming, advancing, and what happens to somebody parked on a step you delete.

Every step is a form object

class Onboarding::ProfileForm < Onboarding::BaseForm
  attribute :name, :string
  validates :name, presence: true

  private

  def apply_defaults = self.name = user.name
  def user_attributes = { name: name }
end

Eight lines, and they contain everything the step is: what it collects, what makes it valid, what it writes. The User model is not involved and gains no validation. Form objects in Rails covers what ActiveModel::Model and ActiveModel::Attributes give you for free, and the one line in the controller that makes the strong parameters follow the form instead of drifting from it.

The gate, and the loop it creates

Forcing users through the flow is one before_action on ApplicationController:

def require_onboarding
  return unless authenticated?
  return if Current.user.onboarded?

  redirect_to onboarding_path
end

Four lines, and applied naively they make the onboarding page redirect to the onboarding page. Every controller that must stay reachable to a half-finished user declares allow_unonboarded_access, and fourteen of them do, including the one that signs people out. The onboarding gate is where that list gets argued, with the two failures measured rather than described.

Seven steps on disk, one in the flow

Onboarding::Registry scans app/forms/onboarding/*_form.rb for step classes and reads the active order from config/onboarding_steps.yml. Today that finds seven forms and one active key: a step class that exists but is not listed is available and switched off, waiting to be turned on.

Putting the order in a committed file rather than a database table is the decision worth noticing. The admin at /admin/onboarding reorders steps by drag and drop, and what it writes is that YAML file. So changing the flow produces a diff, goes through review, and deploys with the code. Nothing about the shape of the product lives only in production state that nobody can read back.

What this is not

Not a wizard gem, and not a state machine. There is no aasm, no wicked, no persisted workflow object: the user's position is one string column, onboarding_step, and completion is one timestamp, onboarded_at.

Not conditional either. Every user sees the same steps in the same order. Branching a flow on a plan, a role or an answer given two steps earlier is a real requirement for some products and it is not what this does. The registry returns a list, and a list has no idea who is walking it.

Articles on this topic

  • The onboarding gate, and the two ways it traps people

    Four lines force every signed-in user into the flow. Applied to every controller they also make the flow redirect to itself, and they stop a half-finished user from signing out. Both failures, measured.

  • A Rails multistep form without a wizard gem

    One string column holds the position, a list holds the order, and a controller with two actions moves between them. What resuming costs, and why deleting a step cannot strand the people standing on it.

  • Form objects in Rails, with ActiveModel

    Eight lines per step: the attributes it collects, what makes it valid, and where it writes. What ActiveModel::Model and ActiveModel::Attributes hand you, and the controller line that makes strong parameters follow the form.