Forms
The Data-Input Workflow
To understand forms we take a look at the data workflow. Grasping it well will help to understand what forms do.
Example application:
$ rails new testapp
[...]
$ cd testapp
$ bin/rails generate scaffold Person first_name last_name
[...]
$ bin/rails db:migrate
[...]
$ bin/rails server
[...]
Most of the time we create forms using the scaffold generator. Let’s go through the data flow.
Request the people#new form
When we request the http://localhost:3000/people/new URL the router picks the following route:
new_person GET /people/new(.:format) people#new
The controller app/controllers/people_controller.rb runs this
code:
# GET /people/new
def new
@person = Person.new
end
So a new instance of Person is created and stored in the instance
variable @person.
Rails takes @person and starts processing the view file
app/views/people/new.html.erb:
<% content_for :title, "New person" %>
<h1>New person</h1>
<%= render "form", person: @person %>
<br>
<div>
<%= link_to "Back to people", people_path %>
</div>
render "form", person: @person renders the file
app/views/people/_form.html.erb and sets the local variable
person to the value of @person:
<%= form_with(model: person) do |form| %>
<% if person.errors.any? %>
<div style="color: red">
<h2><%= pluralize(person.errors.count, "error") %> prohibited this person from being saved:</h2>
<ul>
<% person.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :first_name, style: "display: block" %>
<%= form.text_field :first_name %>
</div>
<div>
<%= form.label :last_name, style: "display: block" %>
<%= form.text_field :last_name %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
form_with(model: person) embeds the two text_fields
(:first_name and :last_name) plus a submit button.
The resulting HTML:
[...]
<form action="/people" accept-charset="UTF-8" method="post" data-turbo="true">
<input type="hidden" name="authenticity_token" value="lSt...hbIg==" />
<div>
<label style="display: block" for="person_first_name">First name</label>
<input type="text" name="person[first_name]" id="person_first_name" />
</div>
<div>
<label style="display: block" for="person_last_name">Last name</label>
<input type="text" name="person[last_name]" id="person_last_name" />
</div>
<div>
<input type="submit" name="commit" value="Create Person" data-disable-with="Create Person" />
</div>
</form>
[...]
This form uses the HTTP POST method to upload the data to the
server.
Rails 5 used form_tag + form_for, Rails 5.1 replaced both
with form_with. Rails 5.x defaulted form_with to
Ajax-submitted (remote: true) behavior, which is why the
older scaffold passed local: true. Since Rails 6.1 the
default is a plain local submission; in Rails 7+ that
submission is also wrapped by Turbo. You no longer need
local: true. To opt out of Turbo on a specific form
write form_with(model: person, data: { turbo: false }).
|
Push the Data to the Server
We enter "Stefan" in the first_name field and "Wintermeyer" in
the last_name field and click the submit button. The browser
POSTs the data to the URL /people. The log shows:
Started POST "/people" for ::1 at 2026-04-19 12:56:46 +0200
Processing by PeopleController#create as TURBO_STREAM
Parameters: {"authenticity_token"=>"0wS2r9...", "person"=>{"first_name"=>"Stefan", "last_name"=>"Wintermeyer"}, "commit"=>"Create Person"}
Person Create (0.6ms) INSERT INTO "people" ("first_name", "last_name", "created_at", "updated_at") VALUES (?, ?, ?, ?)
Redirected to http://localhost:3000/people/1
Completed 302 Found in 9ms (ActiveRecord: 1.6ms)
What happened in Rails?
The router answers the request with this route:
POST /people(.:format) people#create
The controller app/controllers/people_controller.rb runs this
code:
def create
@person = Person.new(person_params)
respond_to do |format|
if @person.save
format.html { redirect_to @person, notice: "Person was successfully created." }
format.json { render :show, status: :created, location: @person }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @person.errors, status: :unprocessable_entity }
end
end
end
[...]
# Only allow a list of trusted parameters through.
def person_params
params.expect(person: [ :first_name, :last_name ])
end
A new instance @person is created from the parameters the browser
sent. Those parameters are filtered in the person_params method
which acts as an allow-list. This prevents a user from injecting
parameters we don’t want to be injected.
Once @person is saved a redirect_to @person is triggered. That
would be http://localhost:3000/people/1 in this example.
When the save fails — for example because a validation rejected the
input — the controller re-renders the :new template with HTTP
status 422 Unprocessable Entity. Turbo recognises that status
and swaps the form back into the page without a full reload, so
the user keeps whatever they typed. This is why the scaffold sets
status: :unprocessable_entity explicitly.
Present the new Data
The redirect to http://localhost:3000/people/1 is traceable in the log file:
Started GET "/people/1" for ::1 at 2026-04-19 12:56:47 +0200
Processing by PeopleController#show as HTML
Parameters: {"id"=>"1"}
Person Load (0.2ms) SELECT "people".* FROM "people" WHERE "people"."id" = ? LIMIT ?
Rendering layout layouts/application.html.erb
Rendering people/show.html.erb within layouts/application
Rendered people/show.html.erb within layouts/application
Completed 200 OK in 27ms (Views: 20.8ms | ActiveRecord: 0.2ms)
The router answers this request with:
person GET /people/:id(.:format) people#show
Which gets handled by the show action in
app/controllers/people_controller.rb.
Generic Forms
A form doesn’t have to be tied to an ActiveRecord object. You can
use form_with(url: …) to create a free-standing form. From the
official Rails form helpers guide
(https://guides.rubyonrails.org/form_helpers.html), here is a
search form that is not connected to a model:
<%= form_with(url: "/search", method: :get) do |f| %>
<%= f.label(:q, "Search for:") %>
<%= f.text_field(:q, id: :q) %>
<%= f.submit("Search") %>
<% end %>
Resulting HTML:
<form action="/search" accept-charset="UTF-8" method="get">
<label for="q">Search for:</label>
<input id="q" name="q" type="text" />
<input name="commit" type="submit" value="Search" />
</form>
To handle this you’d create a new route in config/routes.rb and
an action in a controller.
Older versions of this book mentioned form_tag. It was
deprecated in Rails 5.1 and removed in Rails 7. form_with
covers both the "bound to a model" and the "free-standing"
cases: pass model: for the former, url: for the latter.
|
Useful form_with Options
A few options you will reach for often:
form_with(model: person, data: { turbo: false })-
Force a full page reload on submit. Useful if you need the browser’s default POST behaviour (downloads, for example).
form_with(model: person, data: { turbo_frame: "new_person" })-
Target the response at a specific Turbo Frame. Great for modals and inline-edit UI.
form_with(model: person, url: people_path(format: :json), format: :json)-
Submit JSON to a JSON endpoint.
form_with(model: [post, comment])-
The two-element
model:array tells Rails to build a URL for the nested resourcepost/:id/comments.
FormBuilder Helpers
Inside the form_with do |form| … end block, form is a
FormBuilder. It offers dozens of helpers: text_field,
text_area, email_field, password_field, number_field,
date_field, color_field, select, collection_select,
check_box, radio_button, file_field, and so on. Because we
use scaffold to generate most forms there is no need to memorize
them. It is just important to know where to look in case you need
something else.
The full reference for form-builder methods is at https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html
Alternatives
Many Rails developers use Simple Form as an alternative to the standard way of defining forms. It can really save time and most of the time it’s just easier. Simple Form is available at https://github.com/heartcombo/simple_form
For a modern, utility-first look you may also want to combine
scaffolds with Tailwind CSS via the tailwindcss-rails gem; rails
new myapp --css=tailwind sets that up from the start.