Herb in Rails 8.2, run against 207 templates
Every .html.erb in a Rails application is currently compiled by a tool that cannot see HTML.
Erubi finds <% and %>, and everything between one closing tag and the next opening tag is an
opaque string to it, which is why an unclosed <div> inside an if branch produces a page that
renders, passes every request spec, and lays out wrong for the one user who took the branch nobody
tested. Rails 8.2 changes the compiler, and the interesting question is not what the new one catches.
It is what it refuses to compile in the view layer you already have.
Everything below was run against rails/rails main at commit 7d52e01, dated 2026-09-25, in a
generated application on config.load_defaults 8.2, with the herb gem at 0.11.0 (herb version
prints herb gem v0.11.0, libprism v1.9.0, libherb v0.11.0 (Ruby C native extension)). The
templates under test are this site's own: 227 .erb files, of which 207 are .html.erb, 15 .text,
2 .turbo_stream, and one each of .xml, .json and .atom. Ruby 4.0.5, Apple M2 Max, 12 cores,
macOS arm64-darwin25.
Nothing here is released, and the release notes do not mention it
Rails 8.2 has no tag and no stable branch. The newest rails gem on rubygems.org is 8.1.4, published 2026-09-24T14:22:14Z, and the one before it is 8.1.3.1 from 2026-07-29T15:02:41Z, which is what this site actually runs. Everything in this article describes an alpha and can be renamed or reverted before a release candidate exists.
The change is also invisible in the place most people will look for it. The edge release notes page
at https://edgeguides.rubyonrails.org/8_2_release_notes.html is 44,530 bytes and contains the
string "herb" zero times, "erubi" zero times and "erb_implementation" zero times. A sibling post,
Rails 8.2 read from the source, counts the rest of what that page
leaves out. The primary sources for this one are actionview/CHANGELOG.md on main, the generated
config/initializers/new_framework_defaults_8_2.rb, and the Herb repository itself.
Two of those sources disagree slightly about how firm the change is. The Rails World 2026 programme
entry for Marco Roth's talk, "Herb in Rails 8.2: Your ERB views, now HTML-aware", Track 1 on
24 September, says Herb is "on track to become an opt-in view engine in Rails 8.2". Herb's README
says, in the present tense, "With the Rails 8.2 framework defaults, Rails renders HTML templates with
Herb::Engine." The source settles it in the README's favour, and the difference is worth holding
on to: opt-in is what config.action_view.erb_implementation = :herb is on 8.1 defaults, and a
framework default is what it becomes when you bump load_defaults.
What config.load_defaults 8.2 switches, in one line
railties/lib/rails/application/configuration.rb:389 carries the whole change:
if respond_to?(:action_view)
action_view.erb_implementation = :herb
end
actionview.gemspec on main now declares both compilers, lines 39 and 40:
s.add_dependency "erubi", "~> 1.11"
s.add_dependency "herb", ">= 0.10"
Erubi does not leave. ActionView::Base.erb_implementation= accepts :erubi, :herb or a class,
and the :herb branch applies to the HTML format only. Every .text.erb, .json.erb,
.xml.erb and .turbo_stream.erb still compiles through Erubi whatever this setting says. On
actionview 8.1.3.1 the gemspec lists erubi and not herb, so none of this is reachable from a
released gem.
bin/rails runner reports Erubi on an app that is running Herb
The first thing anyone will do after bumping load_defaults is ask the application which compiler it
is using. That question has a wrong answer ready for you:
$ bin/rails runner 'puts ActionView::Template::Handlers::ERB.erb_implementation'
ActionView::Template::Handlers::ERB::Erubi
The app is on config.load_defaults 8.2. Rails.application.config.action_view.erb_implementation
is :herb in the same process. The reading is wrong because config.action_view.* is applied by an
ActiveSupport.on_load(:action_view) hook, and in bin/rails runner nothing has loaded
ActionView::Base yet, so the hook has not run and the handler still holds its compile-time default.
Force the load and the same expression changes its mind:
$ bin/rails runner '
> puts "before: #{ActionView::Template::Handlers::ERB.erb_implementation}"
> ActionView::Base.new(ActionView::LookupContext.new([]), {}, nil)
> puts "after: #{ActionView::Template::Handlers::ERB.erb_implementation}"'
before: ActionView::Template::Handlers::ERB::Erubi
after: ActionView::Template::Handlers::ERB::Herb
This cost about twenty minutes, spent looking for a missing gem and a broken initializer that were
never missing or broken. puts Herb::VERSION in that same first console raises
uninitialized constant Herb, for the same reason, and two independent-looking symptoms agreeing
with each other makes the false diagnosis very convincing. Read the config value, not the handler
constant, or instantiate a view first.
7 of 207 templates fail bin/rails herb:check
Rails 8.2 ships a task for exactly the question this article is about. Point it at a real view layer
and it is fast and specific. Copying this site's app/views into the 8.2 application and running it:
$ bin/rails herb:check
7 HTML+ERB templates failed to compile through Herb.
app/views/admin/ai/index.html.erb:
55:73 - ERB output tags (<%= %>) are not allowed in attribute position. - Suggestion: Use control flow (<% %>) with static attributes instead.
app/views/admin/analytics/_experiment.html.erb:
37:19 - ERB output in attribute names is not allowed for security reasons. - Suggestion: Use static attribute names with dynamic values instead.
app/views/admin/onboarding/show.html.erb:
60:28 - ERB output in attribute names is not allowed for security reasons. - Suggestion: Use static attribute names with dynamic values instead.
app/views/admin/quiz_answers/_choice_row.html.erb:
4:77 - ERB output in attribute names is not allowed for security reasons. - Suggestion: Use static attribute names with dynamic values instead.
app/views/admin/setup/_step.html.erb:
90:18 - ERB output in attribute names is not allowed for security reasons. - Suggestion: Use static attribute names with dynamic values instead.
app/views/dev/ui/show.html.erb:
9:68 - ERB output tags (<%= %>) are not allowed in attribute position. - Suggestion: Use control flow (<% %>) with static attributes instead.
app/views/layouts/landing.html.erb:
57:86 - ERB output in attribute names is not allowed for security reasons. - Suggestion: Use static attribute names with dynamic values instead.
Not one of the seven is an unclosed tag. Every single one is the same Rails idiom, written by different people in different years, which is a boolean HTML attribute conditionally emitted from a string literal:
<%# app/views/dev/ui/show.html.erb:9 %>
<% @themes.each do |theme| %><option value="<%= theme %>" <%= "selected" if theme == "modern" %>><%= theme %></option><% end %>
<%# app/views/admin/onboarding/show.html.erb:60 %>
<button type="submit" <%= "disabled" unless Rails.env.local? %>
<%# app/views/layouts/landing.html.erb:57 %>
<body class="min-h-screen antialiased <%= "lp-scope" if variant %>" <%= "data-lp-variant=#{variant}".html_safe if variant %>
The last of those three is the one that deserves the security label. It emits an attribute name
from interpolated Ruby and calls html_safe on the result, so a variant carrying a space
introduces a second attribute. The other six emit an attribute name that happens to be a frozen
string literal with no interpolation in it at all, and Herb rejects them on shape rather than on
risk.
The two different messages are a parser artefact worth recognising, because they look like two
different problems and are one. SecurityValidator has two entry points,
visit_html_open_tag_node and visit_html_attribute_name_node. An <%= %> that is the last thing
before the > lands on the first and reports "not allowed in attribute position"; one followed by
another attribute on the same tag gets read as an attribute name and reports "in attribute names is
not allowed for security reasons". Same idiom, same fix, two sentences.
Grep cannot draw this line for you, and that is the argument for a parser. This repository has 42
occurrences of <%= "literal" if condition %> across 24 template files. Only 7 of them are in
attribute position. The other 35 sit inside a quoted attribute value, like
class="base <%= "lp-scope" if variant %>", which Herb reads as a value rather than a slot and does
not flag. Confirmed by putting both forms in front of the checker: the quoted one passes, the bare
one fails at column 5.
The seven failures render perfectly well
bin/rails herb:check rejecting a template is not the same thing as Rails 8.2 refusing to render it,
and on this codebase the two never coincided. Take admin/ai/index.html.erb's <option> loop,
reduced to a partial, and render it in the 8.2 app with erb_implementation on :herb:
--- selected: RENDERED ---
<select>
<option value="a" selected>a</option><option value="b" >b</option>
</select>
The dynamic attribute name renders too, and correctly: <body data-variant=dark>x</body>. All 207
templates compiled through the Herb handler with zero failures in the benchmark further down, which
is the same result from the other direction.
The reason is in actionview/lib/action_view/herb_checker.rb, which is 36 lines long and does one
thing the render path does not:
visitors = [::Herb::Engine::Validators::SecurityValidator.new]
handler.call(template, template.source, implementation: Template::Handlers::ERB::Herb,
validate_ruby: true, visitors: visitors)
Herb::Engine::Validators says so itself, in a comment at the top of the file: "Nothing here runs
unless a caller asks for it. Herb::Engine compiles whatever passes it is given and holds no
opinion about validation". ActionView::Template::Handlers::ERB::Herb#initialize asks for nothing.
So SecurityValidator runs when you type bin/rails herb:check and never runs when a request comes
in, and herb:check is strictly stricter than the engine it exists to prepare you for.
Whether that is a bug or the design is a fair question and the answer does not change the advice.
The check is a lint gate that Rails is shipping as an upgrade aid, its five validators default to
fatal: true when a caller does switch them on, and the wording in
new_framework_defaults_8_2.rb is "When it reports none, the application is ready for the new
default." Treat a clean herb:check as necessary and not sufficient, and treat a red one as a
style debt with a deadline nobody has set yet.
The failure that does break rendering is an omitted </li>
What actually stops a page from rendering on 8.2 defaults is a parse error, and the cheapest way to produce one is to write HTML the way HTML5 permits. Four lines, valid markup, every browser agrees on what it means:
<ul>
<li>one
<li>two
</ul>
Under Erubi that renders as written. Under Herb it raises:
ActionView::SyntaxErrorInTemplate
Encountered a syntax error while rendering template located at: app/views/probe/_omitted_li.html.erb
with a cause of Herb::Engine::ParseError and a detailed_message that is the best part of the
whole feature:
✘ [OmittedClosingTagError] Element `<li>` at (2:3) has its closing tag omitted. While valid HTML,
consider adding an explicit `</li>` closing tag at (3:2) for clarity, or set `strict: false` to
allow this.
[source]:2:3:
2 │ <li>one
╵ ~~~
<p> behaves identically: <div><p>one<p>two</div> raises. So does the case the feature is actually
sold on, an unclosed <div> inside an if branch, which reports [MissingClosingTagError] with the
opening tag's line and column and an "Add the closing tag, or make it self-closing" fix line.
The suggestion to "set strict: false" is not addressed to you. That is a Herb parser option, and
ActionView::Template::Handlers::ERB::Herb passes no parser_options at all, so Rails takes Herb's
default, which is strict. There is no config.action_view setting to loosen it in the source as of
7d52e01. The whole-or-nothing escape hatch is erb_implementation = :erubi.
That is the real upgrade cost and it is not distributed evenly. This codebase happened to survive it,
with zero parse errors across 207 templates, which is luck rather than discipline: nobody here made a
rule about closing <li>. An application with a decade of hand-written tables and lists in it will
find a different number, and it will find it in one command rather than in production.
Herb's README promises attribute-aware escaping and Action View turns it off
Herb's README, line 30, describes Herb::Engine as the thing "which renders your templates the way
Erubi does and escapes each value for where it sits". Escaping by position is the genuinely new
capability: a value landing in an attribute needs \n, \r and \t escaped as well as the five
characters ERB::Util.h handles, and only a parser that knows where the value landed can do that.
Standalone Herb does it. Compiling <a title="<%= v %>"><%= v %></a> with escape: true gives two
different escape functions for the two positions:
_buf << '<a title="'.freeze; _buf << __herb.attr((v)); _buf << '">'.freeze; _buf << __herb.h((v));
Herb::Engine.attr("a\nb") # => "a b"
Herb::Engine.h("a\nb") # => "a\nb"
Action View does not. actionview/lib/action_view/template/handlers/erb/herb.rb sets four
properties to nothing, under a comment that states the intent plainly:
# Leave all escaping to ActionView::OutputBuffer.
properties[:escapefunc] = ""
properties[:attrfunc] = nil
properties[:jsfunc] = nil
properties[:cssfunc] = nil
The compiled output from the real Rails handler, the two engines side by side on that same template, differ by two space characters:
# Herb
@output_buffer.safe_append='<a title="'.freeze; @output_buffer.append=(v); @output_buffer.safe_append='">'.freeze; @output_buffer.append=(v);
# Erubi
@output_buffer.safe_append='<a title="'.freeze; @output_buffer.append=( v ); @output_buffer.safe_append='">'.freeze; @output_buffer.append=( v );
Both call @output_buffer.append=, so both escape through ERB::Util.h in both positions. Rendering
that partial under each engine with "plain", a"b, a\nb, a<b>&c and a\tb produced
byte-identical output five times out of five, and the attribute in the a\nb case carries a literal
newline under Herb exactly as it does under Erubi.
Which is the right call for Action View, since html_safe is Rails' escaping contract and having two
escape authorities in one buffer would be worse than having a weaker one. It does mean the sentence
in the README is true of the gem and false of the integration named in its next paragraph, and that
"HTML-aware" in Rails 8.2 buys you structural errors at compile time and not safer attributes at
render time.
Three ways to write a boolean attribute, and what each emits
Rewriting the seven is where the upgrade time goes, and the three candidate forms are not interchangeable. Measured by rendering each one:
| Written as | herb:check |
Renders |
|---|---|---|
<option value="x" <%= "selected" if c %>> |
rejected, column 18 | <option value="x" selected> |
<option value="x" <% if c %>selected<% end %>> |
passes | <option value="x" selected> |
<option value="x" <%= tag.attributes(selected: c) %>> |
passes | <option value="x" selected="selected"> |
<option value="x" <%= tag.attributes(selected: true) if c %>> |
rejected, its own message | <option value="x" selected="selected"> |
tag.attributes gets a carve-out in SecurityValidator#tag_attributes_prism_node?, which walks the
Prism node and allows a CallNode named :attributes whose receiver is a call to tag. Wrap that
same call in a modifier if and a second branch catches it with a different sentence, "Avoid using
conditional tag.attributes in attribute position", and the suggestion
<% if ... %><%= tag.attributes(...) %><% end %>.
The cost of preferring tag.attributes is in the fourth column. selected becomes
selected="selected", which is equivalent HTML and not an equivalent string, so any request spec
asserting on raw markup breaks. The control-flow form emits exactly what the old code emitted and is
the boring, correct migration for a codebase with view assertions in it. For
layouts/landing.html.erb, whose problem is a dynamic attribute name rather than a value, the
control-flow wrapper around tag.attributes is the only form that passes, and it changes
data-lp-variant=dark into data-lp-variant="dark".
Compiling 207 templates costs 127 ms through Herb and 18 ms through Erubi
Herb parses HTML and Ruby into one tree, so it does strictly more work than Erubi's scanner, and the
question is how much. Compiling every one of the 207 .html.erb templates (640,385 bytes of source)
through the real ActionView::Template::Handlers::ERB handler, five timed runs after three warmups,
in the 8.2 application:
templates: 207 bytes: 640385
herb ok=207 fail=0 generated=815708 bytes best=127.2ms median=129.2ms
erubi ok=207 fail=0 generated=821064 bytes best=18.2ms median=18.8ms
Seven times slower, and it does not matter. That is 0.61 ms per template against 0.09 ms, paid once
per template per process, on the first request that renders it. In development, editing a partial
costs an extra half millisecond before the page comes back. In production with
cache_template_loading on, the difference is 109 ms spread across the first requests after a
deploy, on an application whose boot is measured in seconds.
The generated Ruby is 0.7% smaller under Herb, which is the more interesting number: the two
compilers emit the same instructions into the same @output_buffer, so nothing about render
throughput changes and there was no point measuring it. If a Herb adoption costs an application
anything at runtime, it will not be this.
The formatter rewrote 202 of the 207 files
Herb is five tools and only the engine is in Rails 8.2. The formatter is the one most likely to be
proposed in the same pull request, and it is worth knowing its size before agreeing to that. Running
npx @herb-tools/formatter (node v26.4.0) over a scratch copy of the whole view layer:
Summary:
Checked 207 files
Files 202 formatted | 5 unchanged (207 total)
Duration 577ms
10,367 lines went in and 18,682 came out. The reflow is defensible line by line: multi-line <%# %>
comments get their delimiters on their own lines, block-level elements inside an each get their own
line, and blank lines are inserted between top-level blocks. It also correctly left
<span class="n"><%= 42 %></span> inline rather than breaking an inline element across lines, which
was the failure mode worth checking for and did not happen.
An 8,315-line diff across the view layer of a running application, from a tool whose own banner reads
"Experimental Preview: The formatter is in early development", is a separate decision from switching
the compiler. Running bin/rails herb:check again on the formatted copy returned the same 7
failures, at new line numbers. The formatter fixes none of the upgrade.
herb:check stops at format == :html
ActionView::HerbChecker filters with unbound.format == :html && unbound.handler == :erb, and a
deliberately broken .turbo_stream.erb containing an unclosed <div> inside a <template> proved
it: bin/rails herb:check still reported 7, not 8. The same directory through the standalone CLI,
herb analyze app/views, checks 209 files, because its default include patterns cover
**/*.turbo_stream.erb as well.
Turbo Stream templates are the ones most likely to contain a fragment of HTML with no surrounding document to make an imbalance obvious, which makes the gap an awkward one. Rails is consistent here, at least: those templates compile through Erubi at render time too, so nothing checks them and nothing was going to.
What to do on 8.1 today
Install the gem outside the Gemfile and point the CLI at your views:
$ gem install herb
$ herb analyze app/views
On this repository that took 174.55 ms for 209 files and produced the same 7 findings, which is the
whole upgrade report for the price of one command and no dependency in Gemfile.lock. The list is
not identical to bin/rails herb:check's, in both directions: analyze covers Turbo Stream
templates that Rails ignores, and it runs whichever validators Herb.configuration has enabled
rather than the single SecurityValidator the Rails task hardcodes. Treat it as the wider net, not
as a preview of the task's output.
Do not set erb_implementation = :herb on an 8.1 app yet. The parse errors it raises are real bugs
worth fixing, but they are render-time failures in a strict mode with no Rails-level switch, on a
gem at 0.11.0 whose Rails integration lives on an untagged branch. What would change that position is
a released 8.2 whose new_framework_defaults_8_2.rb either keeps strict deliberately and says so,
or exposes it as a config.action_view setting. Until then the parser is worth running and the
engine is worth waiting on.
What this page does not cover
Not covered: the Herb linter and its rule set, which is a separate npm package with its own
configuration and deserves its own measurement; the language server and the editor experience; the
dev server; ReActionView, which the README pairs with Herb::Engine and which is not in Rails;
Herb::Engine's component tags, slots and scoped styles, none of which Action View's handler
enables; and whether any of the seven rejected templates in this repository will be rewritten, which
is a decision about an unreleased default and not an emergency.
Nothing here is a claim about what 8.2 will contain when it ships. The version string is
8.2.0.alpha and the framework is telling you what that means. Re-run bin/rails herb:check against
the release candidate rather than trusting this page about a version that does not have a tag.
Comments
No comments yet. Be the first.