Rails 8 + TailwindCSS: The Default Frontend Stack Done Right
Rails has supported TailwindCSS via the --css tailwind flag since Rails 7, but it's with Rails 8 that the pairing has really hit its stride. By the time Rails 8.1.2 arrived in January 2026, the combination had become - in my experience at least - one of the most common frontend setups I encounter in new Rails projects. Not a hard default, you can still reach for Bootstrap, Sass, or anything else you like, but it feels like a clear indication of where the Rails frontend story is heading.
I want to be upfront: what follows is my opinion, shaped by rolling this stack out across multiple client projects over the past year. But it's also something I've seen reflected across the teams and communities I'm part of. If you're evaluating how to approach frontend in a modern Rails app, I think this combination deserves serious consideration.
Why Tailwind Became a Rails First Class Option
Rails has always had opinions about frontend. From the Asset Pipeline to Webpacker to the current Propshaft based approach, the framework has tried to offer sensible defaults that let small teams move fast without drowning in JavaScript toolchain complexity.
TailwindCSS fits that philosophy. With v4.1 (the current stable release), Tailwind has shifted to a CSS first configuration model, no more tailwind.config.js by default. The minimal entrypoint can be a single CSS file:
@import "tailwindcss";
That's enough to get started, with additional configuration available via CSS directives like @theme and @source as your project grows. The tailwindcss-rails gem handles the rest. No Node.js required for day to day builds, some upgrade paths or plugins may temporarily require Node, but for typical development the gem wraps the standalone Tailwind CLI binary. No Webpack. No esbuild. It integrates with Rails' asset pipeline (Propshaft by default in Rails 8). For teams that have spent years battling JavaScript build tooling, this is a breath of fresh air.
The integration is deliberately lightweight. Run rails new myapp --css tailwind, and you get a working Tailwind setup with a Procfile.dev that starts both your Rails server and the Tailwind watcher. Run bin/dev and you're building.
But here's the nuance: Tailwind isn't the only option, and for good reason. Some teams prefer component libraries like Bootstrap. Others have design systems built on custom Sass. The Rails team hasn't forced anyone's hand, they've simply made Tailwind a well supported path. In my experience, it happens to be the path that produces the fastest results for the kind of server rendered, Hotwire-driven apps that Rails 8 is optimised for. Your mileage may vary, and that's perfectly fine.
Integrating Tailwind with Turbo and Stimulus
Where this stack really starts to sing is when you combine Tailwind with Hotwire — Turbo and Stimulus specifically. Rails 8 leans heavily into server-rendered HTML enhanced with Turbo Frames, Turbo Streams, and lightweight Stimulus controllers. Tailwind's utility classes are a natural fit for this model because your styling lives directly in the markup that Turbo is swapping in and out.
Consider a typical Turbo Frame pattern. You have a list of records, and clicking "Edit" swaps the show partial for an edit form inline:
<%= turbo_frame_tag dom_id(project) do %>
<div class="flex items-center justify-between p-4 bg-white rounded-lg shadow-xs">
<div>
<h3 class="text-lg font-semibold text-gray-900"><%= project.name %></h3>
<p class="text-sm text-gray-500"><%= project.description.truncate(80) %></p>
</div>
<%= link_to "Edit", edit_project_path(project),
class: "px-3 py-1.5 text-sm font-medium text-indigo-600 bg-indigo-50 rounded-md
hover:bg-indigo-100 transition-colors" %>
</div>
<% end %>
When Turbo swaps in the edit form, the styles come with it. No stylesheet race conditions. No wondering if the CSS for this partial has loaded. The utility classes are self contained in the markup.
With Stimulus, the pattern is similar. A Stimulus controller might toggle visibility or manage a dropdown, and Tailwind's state variants (data-[state=active]:, open:) keep the styling declarative:
<div data-controller="dropdown" class="relative">
<button data-action="click->dropdown#toggle"
class="flex items-center gap-1 px-3 py-2 text-sm rounded-md hover:bg-gray-100">
Options
</button>
<div data-dropdown-target="menu"
class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg ring-1 ring-black/5">
<!-- menu items -->
</div>
</div>
The Stimulus controller handles behaviour (toggling the hidden class), and Tailwind handles presentation. Clean separation, minimal JavaScript, and the whole thing works beautifully with Turbo's page morphing and stream updates.
I've shipped this pattern across several client engagements, from internal reporting platforms to customer facing tools, and in my experience it consistently delivers fast, maintainable UIs without reaching for a JavaScript framework.
Componentising with ViewComponent and Phlex
Here's where things get interesting, and where I think the Rails community is still finding its footing.
Raw Tailwind in ERB partials works well for small projects. But once your application grows, you'll start seeing the same clusters of utility classes repeated across dozens of templates. A button with px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 shows up everywhere, and copy-pasting it is a maintenance headache.
This is where component libraries come in. The two main options in the Rails ecosystem are ViewComponent (now in Long Term Support as of v4, considered feature complete) and Phlex (actively developed, with phlex-rails 2.4.0 released in January 2026).
ViewComponent
ViewComponent gives you Ruby classes paired with ERB templates. Each component is a Ruby object with an explicit initialiser that defines its interface:
# app/components/button_component.rb
class ButtonComponent < ViewComponent::Base
VARIANTS = {
primary: "bg-indigo-600 text-white hover:bg-indigo-500",
secondary: "bg-white text-gray-900 ring-1 ring-gray-300 hover:bg-gray-50",
danger: "bg-red-600 text-white hover:bg-red-500"
}.freeze
def initialize(variant: :primary, size: :md, **attrs)
@variant = variant
@size = size
@attrs = attrs
end
def classes
[
"inline-flex items-center justify-center rounded-md font-semibold",
"focus-visible:outline-2 focus-visible:outline-offset-2",
VARIANTS.fetch(@variant),
size_classes
].join(" ")
end
private
def size_classes
case @size
when :sm then "px-3 py-1.5 text-sm"
when :md then "px-4 py-2 text-sm"
when :lg then "px-5 py-2.5 text-base"
end
end
end
The advantage is clear: your Tailwind classes are centralised, testable, and have a documented interface. The template stays clean.
Phlex
Phlex takes a different approach, everything is Ruby. No ERB templates at all. Your markup is generated by method calls on a Ruby object:
# app/components/button.rb
class Components::Button < Phlex::HTML
VARIANTS = {
primary: "bg-indigo-600 text-white hover:bg-indigo-500",
secondary: "bg-white text-gray-900 ring-1 ring-gray-300 hover:bg-gray-50",
danger: "bg-red-600 text-white hover:bg-red-500"
}.freeze
def initialize(variant: :primary, size: :md, href: nil)
@variant = variant
@size = size
@href = href
end
def view_template(&block)
if @href
a(href: @href, class: classes, &block)
else
button(class: classes, &block)
end
end
private
def classes
[
"inline-flex items-center justify-center rounded-md font-semibold",
"focus-visible:outline-2 focus-visible:outline-offset-2",
VARIANTS.fetch(@variant),
size_classes
].join(" ")
end
def size_classes
case @size
when :sm then "px-3 py-1.5 text-sm"
when :md then "px-4 py-2 text-sm"
when :lg then "px-5 py-2.5 text-base"
end
end
end
In some published Rails 8 benchmarks, Phlex renders modestly faster than ViewComponent, roughly 10% in one commonly cited benchmark, though results vary by application and workload. The all Ruby approach also means you get better IDE support, easier refactoring, and no context switching between Ruby and ERB.
Which should you choose?
Honestly, this is mostly personal preference. Both work well with Tailwind and integrate smoothly with Turbo and Stimulus. I've used ViewComponent on larger teams where the ERB based templates felt more familiar, and Phlex on projects where I wanted tighter control and faster rendering. Neither is a wrong choice.
What I'd strongly recommend is picking one and being consistent. The worst outcome is having half your components in partials, a quarter in ViewComponent, and the rest in Phlex. That's a codebase that's hard to navigate.
Common Pitfalls
Having shipped this stack multiple times, here are the traps I've seen teams fall into:
Utility class bloat in templates
Without componentisation, your ERB files become walls of class names. I've seen individual div tags with 15+ utility classes that are impossible to scan. If you find yourself scrolling horizontally in a template, it's time to extract a component.
Design inconsistency
Tailwind gives you all the tools but no guardrails. Without a shared set of component definitions, one developer's button might use rounded-md and another's uses rounded-lg. One card has shadow-sm, another has shadow-md. Over time, the UI drifts.
The fix is establishing a small design token layer. Tailwind v4's CSS first theming makes this straightforward:
@import "tailwindcss";
@theme {
--color-brand-600: #4f46e5;
--color-brand-500: #6366f1;
--radius-default: 0.375rem;
}
Combined with a component library, this keeps your design language consistent across the application.
Forgetting about Tailwind's purge behaviour
Tailwind v4 automatically detects classes in your source files. But if you're generating class names dynamically in Ruby say, interpolating a status colour, those classes won't be detected:
# This won't work — Tailwind can't detect dynamic classes
def status_class(status)
"bg-#{status}-100 text-#{status}-800"
end
Instead, use a mapping:
STATUS_CLASSES = {
"active" => "bg-green-100 text-green-800",
"pending" => "bg-yellow-100 text-yellow-800",
"archived" => "bg-gray-100 text-gray-800"
}.freeze
This is a well-documented gotcha, but it still catches people out regularly.
Over engineering the build setup
I've seen teams pull in Vite, PostCSS plugins, and custom Webpack configs when the tailwindcss-rails gem handles everything out of the box. For most Rails apps, you don't need a JavaScript build tool at all. The gem wraps the standalone Tailwind CLI binary, it runs outside of Node entirely. Trust the defaults until you have a concrete reason not to.
Example: Building a Styled CRUD Dashboard
To bring this all together, here's how I'd approach a simple project management dashboard with this stack. Let's assume Rails 8.1.2, Ruby 3.4 (or 4.0 if you're feeling adventurous), and Tailwind v4.
The setup
rails new dashboard --css tailwind
cd dashboard
rails generate scaffold Project name:string description:text status:integer
rails db:migrate
Rails scaffolds work cleanly with Tailwind and can be easily restyled. But the default scaffolds are basic, let's improve the index view.
A better index with Turbo
<%# app/views/projects/index.html.erb %>
<div class="max-w-4xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-2xl font-bold text-gray-900">Projects</h1>
<%= link_to "New Project", new_project_path,
class: "px-4 py-2 text-sm font-semibold text-white bg-indigo-600
rounded-md hover:bg-indigo-500 transition-colors" %>
</div>
<div id="projects" class="space-y-3">
<%= render @projects %>
</div>
</div>
<%# app/views/projects/_project.html.erb %>
<%= turbo_frame_tag dom_id(project) do %>
<div class="flex items-center justify-between p-4 bg-white rounded-lg
shadow-xs ring-1 ring-gray-950/5">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-gray-900 truncate">
<%= project.name %>
</h3>
<span class="<%= status_badge_classes(project.status) %>">
<%= project.status.humanize %>
</span>
</div>
<p class="mt-1 text-sm text-gray-500 truncate">
<%= project.description %>
</p>
</div>
<div class="flex items-center gap-2 ml-4">
<%= link_to "Edit", edit_project_path(project),
class: "text-sm text-indigo-600 hover:text-indigo-500" %>
<%= button_to "Delete", project_path(project), method: :delete,
class: "text-sm text-red-600 hover:text-red-500",
data: { turbo_confirm: "Remove this project?" } %>
</div>
</div>
<% end %>
Each project is wrapped in a turbo_frame_tag, so clicking "Edit" swaps in the form inline. Turbo Streams handle creation and deletion. The Tailwind classes keep the styling self contained in each partial.
Adding a Stimulus-powered filter
<div data-controller="filter" class="mb-6">
<div class="flex gap-2">
<% %w[all active pending archived].each do |status| %>
<button data-action="click->filter#select"
data-filter-status-param="<%= status %>"
class="px-3 py-1.5 text-sm rounded-full transition-colors
data-[active]:bg-indigo-100 data-[active]:text-indigo-700
text-gray-600 hover:bg-gray-100">
<%= status.capitalize %>
</button>
<% end %>
</div>
</div>
A lightweight Stimulus controller toggles data-active attributes, and Tailwind's data-[active]: variant handles the visual state. No custom CSS needed.
Where This Stack Shines (and Where It Doesn't)
This combination of Rails 8 + Tailwind + Turbo/Stimulus + a component library (ViewComponent or Phlex) is, in my opinion, the sweet spot for server rendered applications: admin panels, SaaS dashboards, internal tools, content platforms, and customer-facing CRUD apps.
Where it's less of a natural fit is highly interactive single page experiences, collaborative editors, real time data visualisations, complex drag and drop interfaces. For those, you might still reach for React or Vue, potentially with Inertia.js as the bridge to Rails. That's a completely valid choice.
The point isn't that Tailwind + Hotwire is the only way. It's that for a very large class of applications, and across the teams I've worked with, this covers the majority of what gets built, this stack delivers remarkable productivity with very little frontend complexity.
Wrapping Up
Rails 8's frontend story is stronger than it's been in years. Tailwind v4's zero config setup pairs beautifully with the tailwindcss-rails gem. Turbo and Stimulus keep interactivity server-driven. ViewComponent and Phlex give you proper componentisation when you need it.
Is it the right choice for every project? No. But having shipped it multiple times across different teams and domains, I can say it's a stack that scales well, onboards quickly, and keeps frontend complexity from creeping into your Ruby codebase.
If you're starting a new Rails project or modernising an existing one, give it a serious look. And if you've been burned by JavaScript build tooling in the past, you might find that this is the frontend setup you've been waiting for.
I've rolled out Tailwind + Turbo at scale across multiple client engagements, from healthcare integrations to public sector reporting platforms. If your team needs a jumpstart with this stack, or you're looking for a fractional Rails developer to help modernise your frontend, let's talk.