LaunchKit
← All posts
· 17 min read · by The LaunchKit team · 3 views

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:

One ERB template holding the same value in an attribute and in content, sent down three compile paths. Standalone Herb::Engine with escape: true branches on position into __herb.attr(v) and __herb.h(v) and renders the attribute as title equals a ampersand hash 10 semicolon b, while Action View's Handlers::ERB::Herb blanks escapefunc and attrfunc and joins Erubi on one call to @output_buffer.append=, which escapes through ERB::Util.h and renders a literal newline.

_buf << '<a title="'.freeze; _buf << __herb.attr((v)); _buf << '">'.freeze; _buf << __herb.h((v));
Herb::Engine.attr("a\nb")  # => "a&#10;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.

#rails #views #upgrades

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.