Multistep forms with Rails and the Wicked gem

By Exequiel Rozas

- May 08, 2025

Improving our users' experience when they sign up for our application is usually a good idea.

That's why dividing long forms into multiple steps increases our chances for conversion.

In this article, we will build a multistep onboarding form using Rails and the Wicked gem to avoid writing any custom JavaScript while getting a comparable experience.

Let's start by seeing what we will build:

What we will build

For this article, we will build a multistep user registration form for a fantasy learning platform focused on programming, we will call AvoCasts.

Our form will have the following steps:

  • A section to retrieve information about the user.
  • A learning goals and time commitment section.
  • Topics of interest and the level of current knowledge.
  • A “how you heard about us” section.

To achieve this, we will use the wicked gem, which allows us to generate a multistep flows without having to introduce complex logic into our controllers.

The final result should look like this:

Alternatives

If you're reading this, you've probably considered some of the alternatives we have to build a multistep form in Rails.

Besides using something like the Wicked gem, we could create a multistep form with:

  • Individual controller actions and views for each step: this implies that we would define each step as a controller action and manually redirect the user through each step. It seems simple enough, but it has some downsides: keeping track of the redirections, no partial object creation unless we perform some complicated tricks, no logical step order, among other things.
  • A big tabbed form using vanilla JS or Stimulus: this implies creating a big form inside a single view and showing the parts we want the user to see using a tabbed navigation. The main issue is that we would have to handle validations in the frontend and to store progress in cookies or localStorage in case the user doesn't complete the flow at once. Moreover, changes to the steps logic would probably require some changes in our JavaScript.
  • Using Hotwire: this implies rendering the multistep form progressively, with something like Turbo frames to conditionally render the content. With this approach, we could re-gain some Rails features, but keeping state and the lack of URLs would be the main issue.
  • Using a frontend framework: this approach is more drastic, and I've been guilty of using it inside a Rails application. It's similar to the tabbed form approach, but we lose almost every frontend related Rails features. We would need to add frontend validations, handle state and progress using JavaScript and, in case of needing something like nested models, we would have to shape those objects too. Unless you have a highly interactive form, or you're already using something like Inertia with Rails you're probably better off with other approaches.
  • Poorly rebuilding the Wicked gem: at its core, the Wicked gem is a state machine hooked to a controller, so if we tried to recreate its features, we would likely end up with a lot of code and a partial implementation of its features.

Nowadays, with features like link prefetching, using a solution like Wicked is a no-brainer, and it can actually save us plenty of time if our forms are more or less complicated.

Now that we've learned about the alternatives, let's start by setting our application up:

Application set up

The first step is to create a new Rails application with Tailwind and ESBuild:

rails new wizard --css=tailwind --javascript=esbuild

Then, we will add and install the wicked gem:

bundle add wicked && bundle install

Now, we will add a User model to authenticate with it, just like we did in the authentication with the Rails generator article:

bin/rails generate authentication

This will add everything we need to authenticate users into our application, with views for the sign and password reset flows.

However, we need to add a registration flow to be able to sign up users and redirect them to our onboarding flow after they've created their account. For this, we will create to add a RegistrationsController with a corresponding route and views:

# config/routes.rb
resources :registrations, only: [:new, :create]

For the controller:

class RegistrationsController < ApplicationController
  allow_unauthenticated_access only: %i[new create]

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      start_new_session_for @user
      redirect_to root_path, notice: "Your account was successfully created. Welcome to AvoCasts!"
    else
      flash[:alert] = "Something went wrong. Please try again."
      render :new, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.expect(user: [:email_address, :password, :password_confirmation])
  end
end

After also adding the view, when we visit /registrations/new we get the following:

Sign up screen

You can safely ignore the OAuth links, but if you want to implement the feature, check our social login with Rails with the Rails auth generator article.

I also used the view_component and class_variants gem out of habit, but you can probably get away with partials because we will define a single component.

In case you wish to copy and paste the code, install those dependencies too:

bundle add view_component && bundle add class_variants && bundle install

Now that we have the gems installed and a working sign up view, let's add a field to store the user input:

User preferences

For this step, we will add a preferences JSON attribute to our user model and use the ActiveRecord::Store API to handle serialization and access to the preferences.

Your requirements may be more complex, so you would have to consider how to store the user input in the corresponding steps. However, the process is similar.

We start by adding and running the migration:

bin/rails generate migration add_preferences_to_users preferences:text && bin/rails db:migrate

Then, we will use Active Record's store with a JSON code to parse and set the preferences:

class User < ApplicationRecord
  # Rest of the code
  store :preferences, accessors: [:preferred_language, :experience_level, :bio, :time_commitment], coder: JSON
end

Now, we can access and store preferences by doing:

user.update(preferred_language: "Ruby")
user.preferred_language # => Returns "Ruby"

We can also add the ability to query against the preferences in case we want to retrieve users based on them later. Notice that I'm using SQLite for this example, your implementation may vary if you're using something else:

class User < ApplicationRecord
  store :preferences, accessors: [:preferred_language, :bio, :time_commitment], coder: JSON
  scope :with_preference, -> (key, value) { where("json_extract(preferences, '$.#{key}') = ?", value) }
end

Then, we can retrieve every user who prefers Ruby to send them a welcome message:

ruby_programmers = User.with_preference(:preferred_language, "Ruby")

Onboarding layout

To make the onboarding experience a bit better, we will add a custom layout exclusively for it.

The layout consists of a left sidebar with the steps that the user is taking, where the current step has vivid colors while the inactive steps are shades of gray.

The result looks like this:
Custom onboarding layout with left sidebar

To achieve this, we need to create an onboarding layout with a sidebar where we define the steps. For this particular example, I built a StepComponent, but you can probably get away with a partial if you would rather not add the dependency:

# app/components/step_component.rb
class StepComponent < ViewComponent::Base
  def initialize(name:, description:, icon:, active: false, last: false)
    @name = name
    @description = description
    @icon = icon
    @active = active
    @last = last
  end

  def active?
    @active
  end

  def container_classes
    ClassVariants.build(
      base: "flex items-center space-x-4 px-3 py-1 rounded-lg hover:bg-slate-700 transition-colors"
    ).render()
  end

  def icon_container_classes
    ClassVariants.build(
      base: "rounded-lg min-h-[48px] min-w-[48px] flex justify-center items-center relative #{@active ? 'bg-indigo-400 before:animate-pulse-indigo-bg before:absolute before:inset-0 before:rounded-lg before:bg-indigo-300/30 before:-z-10' : 'bg-slate-500'}"
    ).render()
  end

  def icon_classes
    ClassVariants.build(
      base: "#{@active ? 'text-slate-900' : 'text-slate-700'}",
    ).render()
  end

  def name_classes
    ClassVariants.build(
      base: "text-base #{@active ? 'text-white font-bold' : 'text-slate-400 font-medium'}",
    ).render()
  end

  def description_classes
    ClassVariants.build(
      base: "text-sm #{@active ? 'text-slate-400' : 'text-slate-500'}",
    ).render()
  end

  def line_container_classes
    ClassVariants.build(
      base: "w-[48px] h-8 flex justify-center items-center"
    ).render()
  end

  def line_classes
    ClassVariants.build(
      base: "w-1 h-8 transition-all #{@active ? 'bg-indigo-400' : 'bg-slate-700'}"
    ).render()
  end
end

And the HTML:

<div class="transition-all">
  <div class="flex items-center space-x-4">
    <div class="<%= icon_container_classes %>">
      <i data-lucide="<%= @icon %>" class="<%= icon_classes %>"></i>
    </div>
    <div class="flex flex-col -mt-0.5">
      <span class="<%= name_classes %>"><%= @name %></span>
      <span class="<%= description_classes %>"><%= @description %></span>
    </div>
  </div>
  <% unless @last %>
    <div class="<%= line_container_classes %>">
      <div class="<%= line_classes %>"></div>
    </div>
  <% end %>
</div>

Then, the <body> of the onboarding layout:

<body>
  <div class="flex h-screen">
    <%= render "shared/onboarding/sidebar", step_array: @step_array %>

    <div class="flex-1 overflow-auto">
      <%= render "shared/onboarding/mobile_progress_bar", step_array: @step_array %>
      <%= render "shared/flash_messages" %>

      <main class="px-6 py-8 lg:px-12 lg:py-12">
        <%= yield %>
      </main>
    </div>
  </div>
</body>

The sidebar partial:

<div class="hidden md:flex w-60 lg:w-96 xl:w-96 bg-slate-900 text-white flex-col">
  <div class="px-8 pt-12 pb-6">
    <h1 class="text-3xl font-bold text-indigo-400 flex items-center gap-2">
      <i data-lucide="brain" class="w-7 h-7"></i>
      <span>AvoCasts</span>
    </h1>
  </div>
  <div class="flex-1 px-8 mt-6">
    <% step_array.each_with_index do |step, idx| %>
      <%= render StepComponent.new(name: step[:name], description: step[:description], icon: step[:icon], active: step[:active], last: idx == step_array.size - 1) %>
    <% end %>
  </div>
</div>

Then, we also create another partial to show the progress on mobile devices:

<div class="flex md:hidden">
  <nav class="w-full flex flex-col items-center px-2 py-4 bg-slate-900">
    <div class="flex items-center justify-between w-full mb-3">
      <% step_array.each_with_index do |step, idx| %>
        <div class="flex flex-col items-center flex-1">
          <div class="rounded-full w-8 h-8 flex items-center justify-center mb-1 <%= step[:active] ? 'bg-indigo-400' : 'bg-slate-700' %>">
            <i data-lucide="<%= step[:icon] %>" class="w-5 h-5 <%= step[:active] ? 'text-slate-900' : 'text-slate-400' %>"></i>
          </div>
          <span class="text-xs <%= step[:active] ? 'text-indigo-300 font-semibold' : 'text-slate-400' %>"><%= step[:name] %></span>
        </div>
      <% end %>
    </div>
    <div class="bg-slate-200 w-full rounded-full overflow-hidden">
      <div class="h-1.5 bg-indigo-500 rounded-full" style="width: <%= @progress %>%"></div>
    </div>
  </nav>
</div>

We will later set the @step_array and the @progress variables in the OnboardingController. The result on mobile will look like this:

Mobile onboarding view

You might have noticed that I added Create account as the first step of the onboarding flow. I did it to show a typical flow where the onboarding is actually presented as a part of the signup process.

So, to make it work, we need to assign the onboarding layout to the RegistrationsController and redirect the user to the onboarding after a successful signup as well:

class RegistrationsController < ApplicationController
  # Rest of the code
  layout "onboarding"

  # Code for the new action
  def create
    @user = User.new(user_params)
    if @user.save
      start_new_session_for @user
      redirect_to onboarding_path(:about_you), notice: "Welcome to AvoCasts! Please complete the onboarding process to get started."
    else
      flash[:alert] = "Something went wrong. Please try again."
      render :new, status: :unprocessable_entity
    end
  end
end

The actual onboarding will consist of the steps after the signup, but the user will not know that because it will all look like part of the same flow:

Sign up page with the onboarding layout

However, we haven't defined the @steps_array yet, but we need it to render the steps correctly, so here's the code for it:

class RegistrationsController < ApplicationController
  before_action :set_step_array, only: [:new]

  ## Rest of the code

  private

  def set_step_array
    @step_array = [
      {
        name: "Create account",
        description: "Just a few details to get started",
        icon: "user",
        active: true
      },
      {
        name: "About you",
        description: "Tell us about yourself",
        icon: "contact",
        active: false
      },
      {
        name: "Learning Goals",
        description: "What are you looking to achieve?",
        icon: "target",
        active: false
      },
      {
        name: "Time commitment",
        description: "How much learning time per week do you have?",
        icon: "clock",
        active: false
      },
      {
        name: "Learning style",
        description: "Select your preferred learning style.",
        icon: "check-circle",
        active: false,
        last: true
      }
    ]
  end
end

We will later replace the boolean calculation for the active parameter dynamically but, for now, that's all we need.

The flow finally looks like this:

Now that we are done with the setup, let's start with the actual wizard behavior.

Multistep form

The first step is to assign a route for our form. I will use the /onboarding route:

# config/routes.rb
resources :onboarding, only: [:show, :update]

Next, we have to create the OnboardingController, include the gem's behavior using Wicked::Wizard and define the steps for our wizard using the steps method:

# app/controllers/onboarding_controller.rb
class OnboardingController < ApplicationController
  include Wicked::Wizard
  before_action :resume_session, only: [:show, :update]

  steps :about_you, :learning_goals, :time_commitment, :preferences

  layout "onboarding"
end

The first thing we have to do is to add a view for each one of the steps under the app/views/onboarding folder. The names have to match the names we passed as arguments to the steps method.

Then, we define the show method where we set a @user variable and then call the render_wizard, the method in charge of rendering the view that corresponds to the step we're in:

def show
  @user = Current.user
  render_wizard
end

But if we navigate to /onboarding/about_you, we will get an error because we haven't defined the @step_array variable we're passing to the sidebar and mobile progress partials:

class OnboardingController < ApplicationController
  include Wicked::Wizard
  steps :about_you, :learning_goals, :time_commitment, :preferences, :review
  before_action :resume_session, only: [:show, :update]
  before_action :set_step_array, only: [:show]

  layout "onboarding"

  # Rest of the code

  private
  def set_step_array
    @step_array = [
      {
        name: "Create account",
        description: "Just a few details to get started",
        icon: "user",
        active: controller_name == "registrations"
      },
      {
        name: "About you",
        description: "Tell us about yourself",
        icon: "contact",
        active: @step == :about_you
      },
      {
        name: "Learning Goals",
        description: "What are you looking to achieve?",
        icon: "target",
        active: @step == :learning_goals
      },
      {
        name: "Time commitment",
        description: "How much learning time per week do you have?",
        icon: "clock",
        active: @step == :time_commitment
      },
      {
        name: "Learning style",
        description: "Select your preferred learning style.",
        icon: "check-circle",
        active: @step == :learning_style,
        last: true
      }
    ]
  end  
end

Wicked uses the step names as an id to render a view, not a partial, that matches it. When you're declaring the routes for the wizard, don't forget to use the resources helper: using resource will not work.

Then, we add the show action that will be responsible for rendering the actual steps:

def show
  @user = Current.user
  render_wizard
end

The render_wizard method can receive a resource that it will try to save before proceeding with the steps flow. In our case, we could pass the @user instance to it, but we will handle the data persistence in the update method.

Now, after adding some content to the about_you view step, when we navigate to /onboarding/about_you, or access via the onboarding path with the step we wish to render onboarding_path(:about_you), we should see the following:

About you step of the onboarding flow

Without going into too many details for the sake of length, let's see how the bio field of the about_you view is implemented:

# app/views/onboarding/about_you.html.erb
<%= form_with model: @user, url: onboarding_path(step), method: :patch do |f| %>
  <%# Rest of the form %>
  <section class="mt-4 md:mt-6 mb-16">
    <div class="flex flex-col">
      <span class="text-slate-900 text-lg font-semibold leading-5 mb-0">Bio</span>
      <span class="text-slate-500">Tell us a bit about yourself</span>
    </div>
    <%= f.text_area :bio, rows: 4, class: "w-full mt-2.5 placeholder:text-slate-400 text-slate-600 border border-slate-300 rounded-lg p-2.5" %>
  </section>
  <%= render "onboarding/steps", step_name: :about_you %>
<% end %>

We're defining a form with the @user as the model and the onboarding_path as the URL. The step which we're passing to the route helper is defined automatically by the Wicked gem, and it's available in the views of controllers that inherit its behavior.

Now, if we submit the form, we will reach the update action so let's define it to store the user preferences:

class OnboardingController < ApplicationController
  # Rest of the code

  def update
    @user = Current.user

    case step.to_sym
    when :about_you
      @user.experience_level = params.dig(:user, :experience_level)
      @user.preferred_language = params.dig(:user, :preferred_language)
      @user.bio = params.dig(:user, :bio)
    when :learning_goals
      @user.learning_goals = params.dig(:user, :learning_goals)
    when :time_commitment
      @user.time_commitment = params.dig(:user, :time_commitment)
    when :learning_style
      @user.learning_style = params.dig(:user, :learning_style)
    end

    @user.save!
    redirect_to next_wizard_path
  end
end

The step value, which is provided by the gem, corresponds to what we passed to the onboarding_path route helper so, in this case, we will execute the code for the :about_you branch of the switch statement and perform save! on the user instance and return a redirection to the next_wizard_path helper which is pretty self-explanatory.

Because we're using the gem to create an onboarding flow, we will not perform validations or make any fields required.

In case we needed to perform validation, we would have to conditionally redirect to the next step or redirect to the current step:

if @user.save!
  redirect_to next_wizard_path
else
  flash[:alert] = "There was something wrong with your submission. Please try again."
  redirect_to onboarding_path(step)
end

Step navigation

Right now, we have a functioning multistep form, but we haven't seen how the step navigation component works. Let's add it.

The first step is to create a _steps partial inside the onboarding view directory. In there, we will use the previous_wizard_path route helper to check whether we should show the back button or not.

We also use the wizard_steps array and the step_name to change the advance button text:

<div class="flex justify-start gap-x-4 mt-10 px-6 py-4 md:p-0 bg-white md:border-none border-t border-slate-300 absolute bottom-0 left-0 right-0 md:relative md:mt-8 md:flex-row md:gap-x-4">
  <% if previous_wizard_path != onboarding_path(step_name) %>
    <%= link_to previous_wizard_path, class: "flex items-center gap-x-2 rounded-full bg-white px-1 md:px-6 py-2.5 text-base font-semibold text-slate-700 hover:bg-slate-200 duration-400 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" do %>
      <i data-lucide="arrow-left" class="w-4 h-4"></i>
      <span>Back</span>
    <% end %>
  <% end %>
  <%= button_tag type: "submit", class: "flex items-center gap-x-2 rounded-full bg-indigo-600 px-6 py-2.5 text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" do %>
    <% if wizard_steps.index(step_name) == wizard_steps.count - 1 %>
      <span>Finish</span>
    <% else %>
      <span>Continue</span>
    <% end %>
    <i data-lucide="arrow-right" class="w-4 h-4"></i>
  <% end %>
</div>

If the previous_wizard_path matches the path for the current step, we know that there's no need to show the back button.

Then, we use the wizard_steps array to check if the current step is the last one to decide the text for the next step button.

Notice that we've used a button that submits the current form every time the user clicks continue. We could theoretically use a link here, but because we're processing everything in the update action, submitting the form makes sense.

Now we can include the partial in every step to navigate the form.

However, you might be wondering how we can track the progress to show a progress bar like we showed in the mobile version of the form. Let's see how to achieve that:

Tracking progress

At the beginning, we added a sidebar with the StepComponent which distinguishes the current step with a different color.

However, we also used the @progress value for the mobile version of the step component but we haven't defined how to set the value for that variable:

class OnboardingController < ApplicationController
  # Rest of the code
  before_action :set_progress, only: [:show, :update]

  # Rest of the code

  private
  def set_progress
    if wizard_steps.any? && wizard_steps.index(step).present?
      @progress = ((wizard_steps.index(step) + 1).to_d / wizard_steps.count.to_d) * 100
    else
      @progress = 0
    end
  end
end

Because we have access to the wizard_steps and the step values, we can calculate the progress of the step regarding the whole array.

Then, we transform that into a percentage by dividing the current step count by the actual step count. We can use this percentage value to paint a progress bar or a relative value like a circle percentage. That's up to you.

Summary

Multistep forms are a rite of passage if you're building a Rails application with actual customers or users.

They're very useful to gather information about your users in a friendly way, especially after they perform key actions like signing up or subscribing to your application.

There are multiple ways of approaching this issue. However, the wicked gem gives us a very straightforward way to solve it by defining the steps in a controller and handling most of the things for us.

Persisting the user information is usually required, but there are multiple ways to achieve that. We're using a preferences text field using SQLite that gets parsed to JSON to store and access the values.

Using the wicked gem, we built a multistep form using Rails by defining the steps within a controller and then creating a view for each one of those steps using a show and update action within the desired controller.

Within the show view, we rendered the onboarding flow and within the update action, we performed the changes to the User instance to gather useful information from users.

Finally, we learned how to keep track of the steps using a _steps partial and we also learned to keep track of the progress using helpers from the gem itself.

I hope the tutorial helps you implement a multistep form feature for your next Rails application.

If you have any doubts about doing so, let us know! Have a good one and happy coding!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.