Ruby

Rails Forms Guide: Simple Form vs form_with (Rails 8)

Rails Forms Guide: Simple Form vs form_with (Rails 8)

Simple Form is a Rails gem that generates complete form markup — labels, hints, errors, and wrappers — from a single f.input call, replacing form_with’s manual field-by-field markup. Install it with bundle add simple_form, then rails generate simple_form:install. It works with Rails 7 and 8, with built-in Bootstrap support.

This guide helps you pick between form_with, Simple Form, and component-based form builders, then covers Simple Form in depth: installation, everyday usage, validation errors under Turbo, and custom inputs.

form_with vs Simple Form vs Form Components

Rails apps build forms in one of three ways today:

  • form_with ships with Rails. You write every label, field, hint, and error message yourself, which gives you full control over the markup.
  • Simple Form builds on the Rails form builder. One f.input call per attribute renders the label, the field with the right input type, the hint, and any validation error.
  • Component-based views with ViewComponent or Phlex move form markup into plain Ruby classes you can unit test. For Phlex, the Superform gem builds directly on phlex-rails.

Here is how the three approaches compare:

form_withSimple FormViewComponent / Phlex
Ships with RailsYesNo, one gemNo, one or more gems
Markup per fieldManual label, field, and error markupOne f.input call generates everythingWhatever your component renders
Validation errorsYou render object.errors yourselfInline span.error per field, automaticYou decide, per component
CSS framework supportManualBootstrap installer flag, configurable wrappersAnything, hand-rolled
Best forA few forms, custom designsCRUD-heavy apps with uniform formsDesign systems, reusable form components

A practical rule of thumb: default to form_with while your app has a handful of forms or a bespoke design. Reach for Simple Form once you maintain many uniform forms, such as admin panels and internal tools. Consider ViewComponent or Phlex when forms belong to a shared design system. ViewComponent is maintained by GitHub, is in long-term support as of version 4, and its docs report component unit tests running about 100x faster than the equivalent controller tests.

The rest of this guide covers the Simple Form path.

Does Simple Form Support Rails 8?

Yes. Simple Form 5.4.0 (October 2025) added official support for Rails 7.2, 8.0, and 8.1, and dropped Rails versions before 7. The current release, 5.4.1 (January 2026), adds Ruby 4.0 compatibility. The gemspec requires activemodel >= 7.0 and actionpack >= 7.0, so any Rails 7 or 8 app can use it.

Every snippet in this article ran against Simple Form 5.4.1 on Rails 8.1.3 and Ruby 3.4.10.

Installing Simple Form

Add the gem and run the install generator:

Shell
bundle add simple_form
bin/rails generate simple_form:install

The generator creates config/initializers/simple_form.rb, which holds the wrapper configuration: the HTML structure Simple Form builds around every input. The defaults work out of the box.

If your app uses Bootstrap, run the generator with the --bootstrap flag instead:

Shell
bin/rails generate simple_form:install --bootstrap

This adds a config/initializers/simple_form_bootstrap.rb initializer that makes Simple Form emit Bootstrap form classes like form-label, form-control, and invalid-feedback.

Basic Usage of Simple Form

Let’s build a signup form in a fresh Rails 8 app. First, generate a User model and migrate:

Shell
bin/rails g model User username:string password:string email:string remember_me:boolean
bin/rails db:migrate

A production app should store a password_digest and use has_secure_password. A plain password column keeps this example focused on how Simple Form detects column types.

Next, a controller with new, create, and index actions:

Ruby
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def new
    @user = User.new
  end
 
  def create
    @user = User.new(user_params)
 
    if @user.save
      redirect_to users_path, notice: "Welcome, #{@user.username}!"
    else
      render :new, status: :unprocessable_entity
    end
  end
 
  def index
    @users = User.all
  end
 
  private
 
  def user_params
    params.expect(user: [:username, :password, :email, :remember_me])
  end
end
Ruby
# config/routes.rb
Rails.application.routes.draw do
  resources :users, only: [:new, :create, :index]
end

The form view is where Simple Form comes in. simple_form_for replaces form_with, and each field is one f.input call:

erb
<!-- app/views/users/new.html.erb -->
<%= simple_form_for @user do |f| %>
  <%= f.input :username, label: "Your username" %>
  <%= f.input :password, hint: "No special characters." %>
  <%= f.input :email, placeholder: "user@example.com" %>
  <%= f.input :remember_me, inline_label: "Yes, remember me" %>
  <%= f.button :submit %>
<% end %>

Note what you did not write: no input types, no label tags, no wrapper divs. Simple Form picks the input type from each column, renders a <label> for every field, and wraps each pair in a classed <div> you can style.

Each of these options is optional. Without a label:, Simple Form humanizes the attribute name. The hint: renders a <span class="hint"> under the field, placeholder: sets the HTML attribute, and inline_label: puts a boolean’s label next to its checkbox instead of above it. You can also switch parts off per field: <%= f.input :password_confirmation, label: false %>.

Start the app with bin/rails s and open localhost:3000/users/new to try the form.

How Column Types Map to Form Fields

We never specified a type for any field. Simple Form maps each column type (and some attribute names) to an input type. Here are the most used mappings:

Column TypeGenerated HTML ElementComment
stringinput[type=text]
(passwords) stringinput[type=password]Any column whose name contains “password”
(emails) stringinput[type=email]Any column whose name contains “email”
booleaninput[type=checkbox]
texttextarea
integer, floatinput[type=number]
datetimedatetime select
timetime select

The Simple Form README lists every available input type and default.

Booleans

Boolean attributes render as checkboxes by default. When you want radio buttons or a select instead, pass the as: option:

erb
<%= f.input :remember_me, as: :radio_buttons %>

That single line generates this HTML:

html
<div class="input radio_buttons optional user_remember_me">
  <label class="radio_buttons optional">Remember me</label>
  <input type="hidden" name="user[remember_me]" value="" />
  <span class="radio">
    <label for="user_remember_me_true">
      <input
        class="radio_buttons optional"
        type="radio"
        value="true"
        name="user[remember_me]"
        id="user_remember_me_true"
      />Yes
    </label>
  </span>
  <span class="radio">
    <label for="user_remember_me_false">
      <input
        class="radio_buttons optional"
        readonly="readonly"
        type="radio"
        value="false"
        name="user[remember_me]"
        id="user_remember_me_false"
      />No
    </label>
  </span>
</div>

One short Ruby line becomes a complete, labeled, accessible control group.

Customizing the Wrapper and Input HTML

As the previous example shows, Simple Form wraps each field in a <div> with predictable classes. You can customize both the wrapper and the input with the wrapper_html: and input_html: options:

erb
<%= f.input :username, wrapper_html: { class: "signup-field" }, input_html: { maxlength: 20 } %>

input_html: takes any HTML attribute: maxlength, value, id, data-* attributes for Stimulus controllers, and so on.

Displaying Validation Errors

Simple Form’s biggest everyday win is error display. Add a validation to the model:

Ruby
# app/models/user.rb
class User < ApplicationRecord
  validates :username, presence: true
end

Simple Form reads that validation and marks the field as required with an asterisk (an <abbr title="required"> element) next to the label.

When a save fails, the create action from earlier re-renders the form:

Ruby
if @user.save
  redirect_to users_path, notice: "Welcome, #{@user.username}!"
else
  render :new, status: :unprocessable_entity
end

On re-render, Simple Form attaches each error to its field automatically. The failed username field comes back as:

html
<div class="input string required user_username field_with_errors">
  <label class="string required" for="user_username">
    <abbr title="required">*</abbr> Your username
  </label>
  <input
    class="string required"
    aria-invalid="true"
    type="text"
    value=""
    name="user[username]"
    id="user_username"
  />
  <span class="error">can't be blank</span>
</div>

You get the field_with_errors wrapper class, aria-invalid on the input, and the message in a span.error, with no per-field error markup in your view.

Displaying Validation Errors with Turbo

The status: :unprocessable_entity argument in the create action is not optional. Since Rails 7, Turbo Drive intercepts form submissions. After a submission, Turbo expects either a redirect or an error status; a form re-rendered with a plain 200 OK is ignored. The symptom is a form that appears to do nothing: the user submits the form, the request succeeds in the log, and the page never changes.

Rendering with a 422 status tells Turbo to replace the page with the re-rendered form, errors included. Rails 8.1 scaffolds generate status: :unprocessable_content, the current name for HTTP 422, and the older :unprocessable_entity symbol still works. Use one of them on every failed create and update render.

In production, failed form submissions rarely look like they do in dev — they surface as silent 422s, ParameterMissing exceptions, and Turbo responses users never see. AppSignal’s Rails error tracking groups these by controller action, so you can see which forms actually fail for real users.

Custom Inputs and Additional Options

Simple Form ships with a full set of input types, and you can add your own. A custom input is a class in app/inputs inheriting from SimpleForm::Inputs::Base. Here is a social handle input that prefixes the field with an “@”:

Ruby
# app/inputs/social_handle_input.rb
class SocialHandleInput < SimpleForm::Inputs::Base
  def input(wrapper_options)
    merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
 
    "@ #{@builder.text_field(attribute_name, merged_input_options)}".html_safe
  end
end

Use it by naming it in the as: option:

erb
<%= f.input :network_handle, as: :social_handle %>

If your User model has no network_handle column, add one through a migration or declare an attr_accessor in the model.

Associations get the same one-line treatment. Given a Team model and a belongs_to :team association on User, f.association renders a select with every team as an option:

erb
<%= f.association :team %>

Forms for Plain Ruby Objects

Simple Form is not limited to ActiveRecord models. Any class that includes ActiveModel::Model works as a form object:

Ruby
# app/models/signup.rb
class Signup
  include ActiveModel::Model
 
  attr_accessor :company_name
end
erb
<%= simple_form_for Signup.new, url: "/users" do |f| %>
  <%= f.input :company_name %>
<% end %>

Pass url: explicitly, since a plain Ruby object has no resource route. This pattern suits multi-model forms and search forms that never touch a table.

i18n Support

Multi-language apps don’t need per-field options scattered through views. Simple Form follows the Rails i18n conventions: define labels, hints, and placeholders under a simple_form key in your locale files, and every form picks them up.

YAML
# config/locales/simple_form.en.yml
en:
  simple_form:
    labels:
      user:
        username: "User name"
    hints:
      user:
        password: "No special characters, please."
    placeholders:
      user:
        username: "Your username"

The same structure works for prompts and include_blanks on select inputs. Options passed directly to f.input override the locale values.

Wrapping Up

You now have a decision rule and a working setup. form_with covers small apps and custom designs, component libraries cover design systems, and Simple Form covers the wide middle: apps full of uniform forms that should stay consistent without repeated markup. Let f.input infer your field types, and always render failed saves with a 422 status so Turbo shows your validation errors.

The Simple Form README documents the full wrapper API and every input type. And once your forms are live, AppSignal’s Rails integration tracks the errors and slow requests behind them in production.

Happy coding!

P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Frequently asked questions

Does Simple Form support Rails 8?
Yes. Simple Form 5.4 officially supports Rails 7.2, 8.0, and 8.1, and its gemspec requires ActiveModel and ActionPack 7.0 or newer. The current release, 5.4.1 from January 2026, also adds Ruby 4.0 compatibility.
How do I install Simple Form in a Rails app?
Run bundle add simple_form, then rails generate simple_form:install. The generator creates config/initializers/simple_form.rb with the default wrapper configuration. For Bootstrap projects, run the generator with the --bootstrap flag to get Bootstrap-ready wrappers instead.
When should I use Simple Form instead of form_with?
Use Simple Form when your app has many CRUD-style forms and you want labels, hints, and validation errors generated from one f.input call per field. Stick with form_with for a handful of forms, heavily customized designs, or zero extra dependencies.
Why don’t my Rails form validation errors show up with Turbo?
Turbo only renders a re-submitted form when the response carries a 422 status. Render your failed create or update with status: :unprocessable_entity, or :unprocessable_content on Rails 8.1. Without it, the page silently stays unchanged.

Published , Updated

Wondering what you can do next?

  • Share this article on social media
Thomas Riboulet

Thomas Riboulet

Our guest author Thomas is a Consultant Backend and Cloud Infrastructure Engineer based in France. For over 13 years, he has worked with startups and companies to scale their teams, products, and infrastructure. He has also been published several times in France's GNU/Linux magazine and on his blog.

All articles by Thomas Riboulet

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal