form-helpers
Expert guidance for building forms in Rails 8 applications. Use when creating forms, form_with, form helpers, nested forms, select helpers, file uploads, form builders, accepts_nested_attributes_for, fields_for, collection_select, grouped_collection_select, date/time selects, checkboxes, radio buttons, rich text areas, or any form-related view code. Covers model-backed forms, URL-based forms, complex nested attributes with _destroy, custom form builders, CSRF tokens, strong parameters for nested forms, and Stimulus integration.
How do I install this agent skill?
npx skills add https://github.com/thinkoodle/rails-skills --skill form-helpersIs this agent skill safe to install?
- Gen Agent Trust Hubpass
A professional developer skill for Rails 8 form construction. It provides patterns for form helpers, nested attributes, and file uploads. A minor security risk exists as the agent is instructed to match code patterns from the existing codebase, creating a surface for indirect prompt injection from potentially malicious comments in project files.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Rails Form Helpers Expert
Build correct, modern forms in Rails 8 using form_with and associated helpers.
When To Use This Skill
- Building any form in a Rails view (form_with, nested forms, selects, checkboxes)
- Adding nested attributes with fields_for and accepts_nested_attributes_for
- Choosing the right form helper (text_field, select, collection_select, etc.)
- Building custom form builders
- Integrating forms with Stimulus controllers
The One Rule
form_with is the ONLY form helper you use. Period.
- ❌
form_for— deprecated since Rails 5.1. Do not use. - ❌
form_tag— deprecated since Rails 5.1. Do not use. - ✅
form_with— the one true form helper.
If you see form_for or form_tag in existing code, migrate it to form_with when touching that file. If you're writing new code, there is zero reason to use anything else.
Two Modes of form_with
Model-backed forms (most common)
<%= form_with model: @article do |form| %>
<%= form.text_field :title %>
<%= form.submit %>
<% end %>
Rails infers everything: action URL, HTTP method (POST for new, PATCH for persisted), field name scoping (article[title]), submit button text ("Create Article" vs "Update Article").
URL-based forms (no model)
<%= form_with url: search_path, method: :get do |form| %>
<%= form.search_field :query %>
<%= form.submit "Search" %>
<% end %>
Use for search forms, external APIs, or any form not tied to a model.
Instructions
Step 1: Determine Form Type
| Scenario | Use |
|---|---|
| Creating/editing a model record | form_with model: @record |
| Search form | form_with url: path, method: :get |
| Form posting to external URL | form_with url: "https://...", authenticity_token: false |
| Namespaced resource (e.g. admin) | form_with model: [:admin, @article] |
| Nested resource | form_with model: [@parent, @child] |
Step 2: Check Existing Patterns
ALWAYS check the project's existing forms first:
# Find existing form patterns
rg "form_with" app/views/ --type erb
# Check for custom form builders
rg "FormBuilder" app/ --type ruby
# Check for any deprecated form helpers (migration candidates)
rg "form_for\|form_tag" app/views/ --type erb
Match existing project conventions. If the app uses a custom form builder, use it.
Step 3: Build the Form
Use the appropriate input helpers on the form builder object. Every helper takes the attribute name as its first argument.
Common input helpers:
<%= form_with model: @user do |form| %>
<%= form.text_field :name %>
<%= form.email_field :email %>
<%= form.password_field :password %>
<%= form.telephone_field :phone %>
<%= form.url_field :website %>
<%= form.textarea :bio, size: "70x5" %>
<%= form.number_field :age, in: 18..120 %>
<%= form.range_field :satisfaction, in: 1..10 %>
<%= form.date_field :birthday %>
<%= form.time_field :preferred_time %>
<%= form.datetime_local_field :available_at %>
<%= form.color_field :favorite_color %>
<%= form.hidden_field :referrer, value: "homepage" %>
<%= form.checkbox :terms_accepted %>
<%= form.submit %>
<% end %>
Step 4: Use Correct Select Helpers
Selects are where agents mess up most. Read the reference for full patterns.
Static options:
<%= form.select :status, ["Draft", "Published", "Archived"] %>
<%= form.select :status, [["Draft", "draft"], ["Published", "published"]] %>
From a collection (belongs_to):
<%= form.collection_select :city_id, City.order(:name), :id, :name %>
With option groups:
<%= form.grouped_collection_select :city_id, Country.order(:name), :cities, :name, :id, :name %>
With prompt/include_blank:
<%= form.select :category_id, categories, prompt: "Select a category" %>
<%= form.collection_select :author_id, Author.all, :id, :name, include_blank: "None" %>
Step 5: Handle Nested Attributes Correctly
This is the hardest part of Rails forms. Follow this pattern exactly.
1. Configure the model:
class Person < ApplicationRecord
has_many :addresses, inverse_of: :person, dependent: :destroy
accepts_nested_attributes_for :addresses, allow_destroy: true,
reject_if: :all_blank
end
2. Build empty children in controller:
def new
@person = Person.new
@person.addresses.build # at least one empty set of fields
end
def edit
@person = Person.find(params[:id])
@person.addresses.build if @person.addresses.empty?
end
3. Build the nested form:
<%= form_with model: @person do |form| %>
<%= form.text_field :name %>
<h3>Addresses</h3>
<%= form.fields_for :addresses do |address_form| %>
<div class="nested-fields">
<%= address_form.hidden_field :id %>
<%= address_form.text_field :street %>
<%= address_form.text_field :city %>
<%= address_form.label :_destroy, "Remove" %>
<%= address_form.checkbox :_destroy %>
</div>
<% end %>
<%= form.submit %>
<% end %>
4. Permit nested params:
def person_params
params.expect(person: [
:name,
addresses_attributes: [[:id, :street, :city, :_destroy]]
])
end
Critical notes on nested forms:
fields_forrenders NOTHING if the association is empty — you MUST build at least one childallow_destroy: truerequires the_destroyfield AND permitting:_destroyin strong paramsreject_if: :all_blankprevents saving empty nested records- Always include the hidden
:idfield for existing records (fields_for does this automatically) - The double array
[[:id, :street, ...]]inparams.expectis intentional — it means "array of hashes with these keys"
Step 6: Handle File Uploads
<%= form_with model: @user do |form| %>
<%= form.file_field :avatar %>
<%= form.submit %>
<% end %>
form_withautomatically setsenctype="multipart/form-data"when afile_fieldis present- In the controller,
params[:user][:avatar]is anActionDispatch::Http::UploadedFile - For production file handling, use Active Storage — don't roll your own
Multiple files:
<%= form.file_field :documents, multiple: true %>
Permit as array: params.expect(user: [documents: []])
Step 7: Strong Parameters for Complex Forms
Simple model:
params.expect(article: [:title, :body, :published])
With nested attributes:
params.expect(person: [
:name, :email,
addresses_attributes: [[:id, :street, :city, :state, :zip, :_destroy]]
])
With arrays (checkboxes, multi-select):
params.expect(article: [:title, tag_ids: []])
With rich text (Action Text):
params.expect(article: [:title, :body]) # :body is the rich text attribute name
Key Concepts
CSRF Protection
Every non-GET form automatically includes an authenticity_token hidden field. This is Rails' CSRF protection. Don't disable it unless posting to an external service:
<%# External API - disable token %>
<%= form_with url: "https://external.api/webhook", authenticity_token: false do |form| %>
HTTP Method Emulation
HTML forms only support GET and POST. Rails emulates PATCH, PUT, DELETE via a hidden _method field:
<%# This generates method="post" with a hidden _method="patch" %>
<%= form_with model: @article, method: :patch do |form| %>
For form_with model: @article on a persisted record, Rails sets PATCH automatically.
Record Identification
form_with model: @article figures out:
- New record? → POST to
/articles(create) - Persisted record? → PATCH to
/articles/:id(update) - Submit text → "Create Article" or "Update Article"
This requires resources :articles in your routes.
Namespaced & Nested Resources
# Admin namespace
form_with model: [:admin, @article]
# → POST /admin/articles or PATCH /admin/articles/:id
# Nested resource
form_with model: [@magazine, @article]
# → POST /magazines/:magazine_id/articles
# Deep nesting
form_with model: [:admin, @magazine, @article]
Custom Form Builders
When you repeat the same form patterns, create a custom builder:
# app/form_builders/application_form_builder.rb
class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
def text_field(attribute, options = {})
label(attribute) + super(attribute, options.merge(class: "form-input"))
end
end
Use it:
<%= form_with model: @user, builder: ApplicationFormBuilder do |form| %>
<%= form.text_field :name %> <%# Automatically includes label + CSS class %>
<% end %>
Or create a helper to use it by default:
# app/helpers/application_helper.rb
def app_form_with(**options, &block)
options[:builder] = ApplicationFormBuilder
form_with(**options, &block)
end
Rich Text (Action Text)
<%= form.rich_text_area :body %>
Requires Action Text to be installed (rails action_text:install). The attribute is declared on the model:
class Article < ApplicationRecord
has_rich_text :body
end
Radio Buttons and Checkboxes
Radio buttons — user picks one:
<%= form.radio_button :flavor, "vanilla" %>
<%= form.label :flavor_vanilla, "Vanilla" %>
<%= form.radio_button :flavor, "chocolate" %>
<%= form.label :flavor_chocolate, "Chocolate" %>
Collection radio buttons — from a collection:
<%= form.collection_radio_buttons :city_id, City.all, :id, :name %>
Checkboxes — single boolean:
<%= form.checkbox :terms_accepted %>
<%= form.label :terms_accepted, "I accept the terms" %>
Collection checkboxes — has_many or HABTM:
<%= form.collection_checkboxes :interest_ids, Interest.all, :id, :name %>
Note: checkbox (not check_box) generates a hidden field with value "0" so unchecked boxes still submit a value.
Date and Time Helpers
Prefer HTML5 native inputs (modern, mobile-friendly):
<%= form.date_field :born_on %>
<%= form.time_field :starts_at %>
<%= form.datetime_local_field :event_at %>
Select-based date/time (legacy, multi-select dropdowns):
<%= form.date_select :born_on %>
<%= form.time_select :starts_at %>
<%= form.datetime_select :event_at %>
Date selects produce multi-parameter attributes like born_on(1i), born_on(2i), born_on(3i). Active Record knows how to reassemble these.
Anti-Patterns
- Using
form_fororform_tag— alwaysform_with - Forgetting to build nested children —
fields_forrenders nothing for empty associations - Missing
_destroyin strong params — checkbox exists but destroy silently fails - Using
selectfor belongs_to without_idsuffix — the field name must be the foreign key - Hardcoding form action URLs — let
model:infer them, or use route helpers - Forgetting
allow_destroy: trueonaccepts_nested_attributes_for - Not adding
reject_if: :all_blank— empty nested forms create blank records - Manually setting
enctypefor file uploads —form_with+file_fieldhandles this - Using
_taghelpers inside a form builder block — use the form builder methods instead - Not permitting
idin nested attributes — needed to update (not duplicate) existing records
Dynamically Adding/Removing Nested Fields
For adding nested fields dynamically (without page reload), use Stimulus:
<%# Render a hidden template for new fields %>
<template data-nested-form-target="template">
<%= form.fields_for :addresses, Address.new, child_index: "NEW_RECORD" do |af| %>
<div class="nested-fields" data-new-record>
<%= af.text_field :street %>
<%= af.text_field :city %>
<%= af.hidden_field :_destroy, value: false, data: { nested_form_target: "destroy" } %>
<button type="button" data-action="nested-form#remove">Remove</button>
</div>
<% end %>
</template>
<div data-nested-form-target="container">
<%= form.fields_for :addresses do |af| %>
<div class="nested-fields">
<%= af.text_field :street %>
<%= af.text_field :city %>
<%= af.hidden_field :_destroy, value: false, data: { nested_form_target: "destroy" } %>
<button type="button" data-action="nested-form#remove">Remove</button>
</div>
<% end %>
</div>
<button type="button" data-action="nested-form#add">Add Address</button>
The Stimulus controller replaces NEW_RECORD in the template with a unique timestamp to ensure unique parameter names. See reference.md for the full Stimulus controller code.
Quick Reference: Input Helper → HTML Type
| Helper | HTML type |
|---|---|
text_field | text |
email_field | email |
password_field | password |
telephone_field | tel |
url_field | url |
search_field | search |
number_field | number |
range_field | range |
date_field | date |
time_field | time |
datetime_local_field | datetime-local |
month_field | month |
week_field | week |
color_field | color |
hidden_field | hidden |
file_field | file |
textarea | <textarea> |
checkbox | checkbox |
radio_button | radio |
Debugging Forms
# Check what params your form submits
# In controller:
Rails.logger.debug params.inspect
# Check generated HTML
# In browser dev tools, inspect the <form> element for:
# - action (correct URL?)
# - method (post?)
# - hidden _method field (patch/delete?)
# - authenticity_token present?
# - field names correct (model[attribute]?)
# Common param issues with nested forms:
# - addresses_attributes vs addresses (must be _attributes)
# - Missing id field (creates duplicates instead of updating)
# - _destroy not permitted (silently ignored)
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/thinkoodle/rails-skills/form-helpers">View form-helpers on skillZs</a>