Rails Phlex: Ruby-First View Components That Beat ERB and ViewComponent on Speed
Rails Phlex writes views in pure Ruby — no templates, no DSL surprises. Faster than ERB, smaller than ViewComponent, fully type-safe. Here's the playbook.
The first time I deleted an ERB partial and replaced it with a Phlex component, the page render time on a hot controller dropped from 38 ms to 11 ms. No caching change. No database change. Just Ruby instead of a templating language pretending to be Ruby. After nineteen years of Rails I have watched the view layer go through a lot of fashions — RHTML, HAML, Slim, ERB with helpers, ViewComponent, partials within partials within partials — and Rails Phlex is the first one in a long time that made me think: this is what the view layer should have always looked like.
This post is the playbook I wish I had when I started moving production apps to Phlex. What it is, why it is faster than ERB, how it compares to ViewComponent honestly (not the marketing version), how to write your first component, how to render Phlex from a controller, how to test it, and the cases where you should not bother migrating.
What Rails Phlex Actually Is
Rails Phlex is a view library that lets you write HTML in pure Ruby instead of an ERB or HAML template. There is no separate .html.erb file. A component is a plain Ruby class that subclasses Phlex::HTML and defines a view_template method. Inside that method you call methods that match HTML tags — div, h1, a, button — and Phlex builds the output string. The whole render pipeline is Ruby method dispatch, not string interpolation and template compilation.
That sounds like a small thing. It is not. Once your view is Ruby:
- You can pass arguments with real Ruby semantics — keyword args, defaults, type checks, splats — instead of locals that silently arrive as
nil. - Components compose by method call, not by
render partial:with a magic locals hash that nobody can grep for. - Refactoring is regular Ruby refactoring. Rename a method, your editor finds every caller. Try doing that with ERB partials.
- The view runs through the same profiler, the same call stack, the same exceptions, and the same backtrace as the rest of your app. No
_form.html.erb:42lines that hide which controller invoked them.
ERB, by contrast, is a string templating language that happens to evaluate Ruby. ViewComponent is somewhere in between — Ruby class plus a sidecar template file — and you still pay for the template engine. Rails Phlex is the only one of the three where the view is the Ruby.
Phlex vs ViewComponent vs ERB, Honestly
I am not going to pretend the three are equivalent and the choice is just taste. They are not. Here is the comparison I actually use when deciding.
ERB is the default. It ships with Rails, every Rails dev can read it, and for small apps with mostly server-rendered pages it is fine. It gets painful at scale because partials hide their inputs, and helpers turn into a junk drawer. The render cost is real but rarely the bottleneck unless the page is huge.
ViewComponent gives you a Ruby class around a template. You get testability, you get encapsulation, you get explicit inputs via the initializer. You also get two files per component (button_component.rb plus button_component.html.erb), which is the thing I personally hate most about it. And you still pay for the ERB compile step on every component.
Rails Phlex gives you the Ruby class, no template file, faster rendering, real type checks at the boundary, and a single file per component. The downside is that the syntax looks unusual the first day and your team needs to know basic Ruby — which, in a Rails shop, should not be a high bar but sometimes is.
The benchmark numbers vary by app, but on a real Rails 8 dashboard I migrated last year, replacing the heaviest ViewComponent tree with Phlex equivalents cut p50 render time on that controller from 22 ms to 7 ms. The wins are not free — they come from skipping the template compile pass and from inlining everything as plain Ruby method calls.
Your First Phlex Component
Install the gem and add the Rails integration:
# Gemfile
gem "phlex-rails"
bundle install
bin/rails generate phlex:install
The generator creates app/components/application_component.rb as your base class. Now write your first component. A button:
# app/components/button_component.rb
class ButtonComponent < ApplicationComponent
def initialize(label:, href: nil, variant: :primary)
@label = label
@href = href
@variant = variant
end
def view_template
if @href
a(href: @href, class: classes) { @label }
else
button(type: "button", class: classes) { @label }
end
end
private
def classes
base = "inline-flex items-center px-4 py-2 rounded-lg font-medium"
color = case @variant
when :primary then "bg-indigo-600 text-white hover:bg-indigo-500"
when :ghost then "bg-transparent text-indigo-600 hover:bg-indigo-50"
end
"#{base} #{color}"
end
end
Render it from a controller view or from another component:
<%= render ButtonComponent.new(label: "Save", variant: :primary) %>
<%= render ButtonComponent.new(label: "Cancel", href: root_path, variant: :ghost) %>
What is happening: view_template is a regular method. a, button, href:, class: are method calls Phlex provides for every HTML element. The block syntax is the element’s children. There is no template compilation. The whole thing is Ruby.
The keyword arguments in the initializer are the public API of the component. If a caller forgets label:, they get ArgumentError: missing keyword: :label at the call site, not a mysterious nil rendering as an empty span. That single property catches more bugs than any test suite I have written.
Composing Components, Layouts, and Slots
Real apps need layout-shaped components — a card with a header, body, and footer. In Phlex you compose via blocks, the same way you compose Ruby:
class CardComponent < ApplicationComponent
def initialize(title:)
@title = title
end
def view_template(&block)
div(class: "rounded-xl border border-zinc-200 bg-white shadow-sm") do
div(class: "px-5 py-3 border-b") { h3(class: "font-semibold") { @title } }
div(class: "p-5", &block)
end
end
end
Use it from another component:
class DashboardComponent < ApplicationComponent
def initialize(user:)
@user = user
end
def view_template
div(class: "grid grid-cols-2 gap-6") do
render CardComponent.new(title: "Recent activity") do
ul do
@user.recent_events.each do |event|
li { event.summary }
end
end
end
render CardComponent.new(title: "Open invoices") do
render InvoiceTableComponent.new(invoices: @user.open_invoices)
end
end
end
end
That is the entire slot API. No with_header, no with_footer, no DSL. A block is a slot. Multiple slots are multiple method arguments. It composes the way Ruby composes.
If you need a layout, write a layout component the same way and yield to the page content:
class ApplicationLayout < ApplicationComponent
def view_template(&block)
doctype
html(lang: "en") do
head do
meta(charset: "utf-8")
title { "TTB Dashboard" }
link(rel: "stylesheet", href: helpers.asset_path("application.css"))
end
body(class: "bg-zinc-50 text-zinc-900", &block)
end
end
end
The helpers proxy gives you access to standard Rails view helpers — asset_path, image_tag, form_with, anything from ApplicationHelper. You do not lose Rails. You just stop fighting it.
Rendering Phlex Components Directly From a Controller
The most underused feature in Rails Phlex is that you can return a component directly from a controller action and skip the view layer entirely:
class DashboardsController < ApplicationController
def show
render DashboardComponent.new(user: Current.user)
end
end
No dashboards/show.html.erb. No layout template. Phlex finds your application layout (when you set it up that way) and renders straight to the response. On a dashboard with twenty components this saves a noticeable amount of allocations per request — fewer intermediate strings, fewer template instantiations, fewer method calls into ActionView’s render resolver.
This is also where the type-safety of Phlex starts to pay off in production. Each component declares its inputs. If the controller forgets one, the request fails at the controller, not deep in a partial three levels down with a backtrace that points to ERB line numbers nobody can read.
Testing Phlex Components
Phlex components are Ruby objects. You test them like Ruby objects.
require "rails_helper"
RSpec.describe ButtonComponent do
it "renders a button when no href is given" do
output = ButtonComponent.new(label: "Save").call
expect(output).to include("<button")
expect(output).to include("Save")
expect(output).to include("bg-indigo-600")
end
it "renders an anchor when href is given" do
output = ButtonComponent.new(label: "Home", href: "/", variant: :ghost).call
expect(output).to include('href="/"')
expect(output).to include("bg-transparent")
expect(output).not_to include("<button")
end
end
.call returns the rendered HTML as a string. No render_inline, no Capybara, no headless browser. The test runs in milliseconds and you can run thousands of them in a CI build that finishes before you switch tabs. If you need DOM querying for complex components, Nokogiri over the output string is fine — but I find I rarely need it. Most component tests are “does the right class appear, does the label render, does the optional branch take the right path.”
For integration coverage you still want a system spec that walks through the page in a browser. But the unit level — which is where most of the bugs live — is plain Ruby.
Migrating From ERB or ViewComponent Without Stopping the World
Do not migrate everything at once. Phlex coexists with ERB and ViewComponent in the same app. The strategy that works:
- Start with leaf components. Buttons, badges, avatars, icons. Things with no slots and no children. Migrate them one at a time and replace the call sites.
- Move up to mid-level components. Cards, form fields, table rows. These have slots — block arguments to
view_template— but no business logic. - Convert the heaviest pages last. The dashboard, the listing pages, the pages that render hundreds of components per request. This is where the performance wins land.
- Leave the long-tail ERB alone. Admin screens, settings pages, anything that renders once a day. The migration cost is real and the perf benefit is zero.
This is also a good moment to internal-link to the ViewComponent post — if you are already on ViewComponent and happy, Phlex is the next step, not a rewrite. If you are still on raw ERB partials, ViewComponent is a fine intermediate stop, but jumping straight to Phlex is also reasonable.
One concrete tactic I use: a single PR per component family. “Migrate all button variants to Phlex.” “Migrate all card patterns to Phlex.” Small, reviewable, revertible. Not “migrate the whole frontend in a four-week branch nobody can review.”
When NOT to Use Rails Phlex
Phlex is not free. The honest list of reasons to keep ERB:
- Your team has zero Ruby fluency and reads ERB as “HTML with some funny tags.” Phlex is more Ruby, not less. If the people writing your views are designers who learned just enough ERB, do not force this on them.
- The app is mostly Turbo and Stimulus glue over a small set of templates. The win is too small.
- You have a designer-driven workflow where designers edit templates directly. They will not edit Phlex.
- You are building a CMS or a marketing site with content authors. ERB or Markdown is the right tool.
For everything else — dashboards, admin tools, internal apps, SaaS frontends, anything where engineers own the view layer — Phlex pays back in months. The performance wins are nice. The Ruby-everywhere mental model is what actually matters.
FAQ
Is Rails Phlex faster than ViewComponent?
Yes, measurably. The ViewComponent class instantiates and then compiles its ERB template via ActionView. Phlex renders by direct method dispatch with no template compile step. On component-heavy pages — dashboards, listing views with many cards — I have seen 2x to 4x speedups on the view layer specifically. End-to-end response time gains depend on how much of your request is database vs. view, but the view-layer win is real.
Can I use Phlex and ERB in the same Rails app?
Yes. They coexist with no configuration. A controller can render an ERB template that calls render ButtonComponent.new(...). A Phlex component can render an ERB partial through helpers.render. The migration path is incremental, and I would not recommend anything else.
Does Rails Phlex work with Hotwire, Turbo, and Stimulus?
Yes. Phlex outputs HTML. Hotwire reads HTML. There is nothing to integrate. You write turbo_frame_tag as turbo_frame_tag(id: "foo") { ... } and pass data: { controller: "modal" } as a normal Ruby hash. The Hotwire side does not know or care that the HTML came from Phlex.
How do I render Rails view helpers like form_with from Phlex?
Through the helpers proxy: helpers.form_with(model: @user) do |form| ... end. Every helper that works in ERB works in Phlex through that proxy. For frequently used helpers you can also define them as instance methods on your ApplicationComponent and skip the proxy in callers.
Should I migrate my existing Rails app to Phlex?
Migrate the hot paths, leave the cold ones in ERB. The pattern that works is: identify the three or four templates that show up most often in your APM render-time chart, convert their components to Phlex, ship, measure, decide whether the rest is worth it. Do not do a big-bang rewrite. Do not let “we should switch to Phlex” become a six-month project that ships nothing else.
Need help making your Rails view layer fast, type-safe, and a pleasure to work in? TTB Software builds, refactors, and scales production Rails apps — including frontend modernizations with Phlex, ViewComponent, and Hotwire. We have been doing this for nineteen years.
Related Articles
Rails Pessimistic Locking: SELECT FOR UPDATE, with_lock, and Preventing Race Conditions
Rails pessimistic locking with SELECT FOR UPDATE, lock! and with_lock — prevent race conditions on balances, inventor...
Rails Strong Migrations: Catch Unsafe Database Changes Before They Lock Production
Rails Strong Migrations: catch unsafe Postgres changes — NOT NULL adds, renames, non-CONCURRENTLY indexes — before th...
Rails pg_stat_statements: Find Slow Queries in Production Before Users Do
Rails pg_stat_statements setup, query, and analysis guide: find the slow queries actually hurting production, normali...