Linter Rule: Disallow nested forms
Rule: html-no-nested-forms
Description
Disallow placing one <form> element inside another <form> element. HTML does not support nested forms. Doing so results in invalid markup and unpredictable behavior.
<%= form_with model: @mission do |form| %>
<%= form.submit "Update" %>
<%= button_to "Delete", mission_path(@mission), method: :delete %><% end %>`button_to` renders its own `<form>` element and cannot be nested inside another `<form>`. Move it outside of the enclosing `<form>`.Rationale
Nesting forms is invalid according to the HTML specification. Browsers will automatically close any open <form> tag when encountering a new <form> start tag, often leading to:
- broken form submissions,
- incomplete or missing form fields,
- confusing DOM structure,
- inconsistent behavior across browsers.
Even if some browsers attempt to handle this situation, the resulting form behavior is unreliable and prone to subtle bugs.
The rule also covers Rails helpers that the Action View helper registry identifies as rendering a <form> element: form_with, form_for, form_tag, and button_to. The helper case is particularly easy to miss because the nested <form> never appears in the template. A common example is button_to inside a form_with block: button_to generates its own <form>, browsers drop it during parsing, and clicking the button silently submits the outer form instead.
This rule ensures that each form is properly isolated.
Examples
✅ Good
<form>
<input type="text" name="name"></form>
<form>
<input type="text" name="email"></form><%= form_with model: @user do |form| %>
<%= form.text_field :name %>
<%= form.submit %>
<% end %>
<%= button_to "Delete", user_path(@user), method: :delete %><form id="account-form">
<input type="hidden" name="token" value="abc">
</form>
<button type="submit" form="account-form">Save</button>🚫 Bad
<form>
<input type="text" name="name">
<form> <input type="text" name="nested"> </form>
</form><%= form_with model: @user do |user_form| %> <%= form_with model: @address do |address_form| %> <%= address_form.text_field :street %>
<% end %>
<% end %><%= form_with model: @mission do |form| %>
<%= form.submit "Update" %>
<%= button_to "Delete", mission_path(@mission), method: :delete %><% end %>Across call sites
This rule also considers where a file is rendered. When the linter runs over a whole project it resolves the HTML ancestors that each call site places a file inside, following render calls and each template's conventional layout yield.
A <form>, or a form helper that renders one, is reported when every call site renders its file inside another <form>.
Action View helpers that render an element count as ancestors, so a content_tag, tag.div or link_to block nests what it wraps just like the equivalent HTML would.
The rule stays quiet whenever there is not enough information to be sure. A file nothing renders, and a chain that never reaches a layout, are both left alone. When only some call sites nest the file, the offense is still reported and the call chain points at one that does, since the nesting is real on that code path.
Layout resolution follows Rails' naming convention and cannot see a controller declaring layout "..." or layout false.