Rails After All These Years – What Surprised Me Most
Ive been writing Rails since 2013. I watched it evolve from Rails 3 to Rails 8, through countless Rails is dead declarations, through the JavaScript framework wars, through the microservices hype, and back again.
Recently, after spending a few years working primarily on microservices architectures and frontend-heavy applications, I came back to a greenfield Rails project. And honestly? I was shocked by how much had changed – and how much hadn't.
Here's what surprised me most about modern Rails.
The Hotwire Revolution: Rails Got Its Mojo Back
Remember the Dark Ages?
Let me paint you a picture of Rails circa 2015-2020:
The prevailing wisdom was:
- Rails is just an API
- All your UI logic goes in React/Vue/Angular
- If you're writing Rails views, you're doing it wrong
- "Real" apps are SPAs
The typical stack looked like:
Rails API (JSON only)
↓
React frontend (separate repo)
↓
Webpack config hell
↓
Redux boilerplate
↓
API versioning nightmares
↓
CORS issues
↓
Authentication complexity
I built several apps this way. And you know what? It was exhausting.
Two codebases. Two deploys. Two different mental models. State management hell. The eternal "should this logic be in the frontend or backend?" debate.
And for what? So we could have a snappy SPA experience? Most of the time, we didn't even need it.
Enter Hotwire
Then DHH and the Rails team dropped Hotwire (Turbo + Stimulus), and I... ignored it. "Another Rails thing that'll fade away," I thought. "Everyone's doing React anyway."
I was wrong. Very wrong.
When I finally tried Hotwire on a real project, I had a moment of clarity:
This is what Rails should have been doing all along.
Here's a real example from a recent project. We needed to build a real-time dashboard with live updates, filtering, and dynamic forms.
The React way (what I would have done in 2020):
// Frontend: components/Dashboard.jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
function Dashboard() {
const [metrics, setMetrics] = useState([]);
const [filters, setFilters] = useState({});
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchMetrics();
}, [filters]);
const fetchMetrics = async () => {
setLoading(true);
const response = await axios.get('/api/v1/metrics', { params: filters });
setMetrics(response.data);
setLoading(false);
};
const updateFilter = (key, value) => {
setFilters({ ...filters, [key]: value });
};
return (
<div>
{loading && <Spinner />}
<FilterForm filters={filters} onChange={updateFilter} />
<MetricsList metrics={metrics} />
</div>
);
}
// Backend: app/controllers/api/v1/metrics_controller.rb
class Api::V1::MetricsController < ApiController
def index
metrics = Metric.where(filter_params)
render json: metrics.as_json(include: [:details, :stats])
end
end
Plus: webpack config, API serializers, CORS setup, authentication tokens, error handling on both sides...
The Hotwire way (what actually works in 2026):
# app/controllers/metrics_controller.rb
class MetricsController < ApplicationController
def index
@metrics = Metric.where(filter_params)
end
end
# app/views/metrics/index.html.erb
<%= turbo_frame_tag "metrics" do %>
<%= render "filters", filters: @filters %>
<%= render @metrics %>
<% end %>
# app/views/metrics/_filters.html.erb
<%= form_with url: metrics_path, method: :get,
data: { turbo_frame: "metrics", turbo_action: "advance" } do |f| %>
<%= f.select :category, Category.all %>
<%= f.date_field :start_date %>
<%= f.submit "Filter" %>
<% end %>
That's it. No JSON serialization. No state management. No useEffect hooks. No API versioning.
The form submits, Rails renders HTML, Turbo swaps it in. It just works.
What Actually Shocked Me
1. It's genuinely fast
I was skeptical. "Rendering HTML can't be as fast as a SPA!"
Wrong. For most applications, Turbo is faster than React because:
- No giant JavaScript bundle to download and parse
- No client-side routing overhead
- No hydration step
- Server-side rendering is just... rendering
I did a side-by-side comparison. Same feature, one in React, one in Hotwire:
MetricReact SPAHotwireInitial page load2.1s0.4sBundle size847KB42KBTime to interactive2.8s0.5sSubsequent navigation0.3s0.4s
The React version was marginally faster on subsequent navigation (by 0.1s), but who cares when the initial load is 5x faster?
2. It handles real-time updates elegantly
Remember when you needed WebSockets, ActionCable, and a ton of JavaScript to do real-time?
# Broadcast updates from anywhere
class Metric < ApplicationRecord
after_update_commit do
broadcast_replace_to "metrics",
target: "metric_#{id}",
partial: "metrics/metric",
locals: { metric: self }
end
end
# In your view
<%= turbo_stream_from "metrics" %>
<div id="metric_<%= metric.id %>">
<%= render metric %>
</div>
That's it. Update a record, and all connected clients get the update. No Redux. No Socket.io. No complexity.
I used this for a trading dashboard where prices update every second. 50 lines of code total. Would have been 500+ in React.
3. Complexity went way down
This is the big one. My Rails projects are simple again.
- One codebase
- One deploy
- One mental model
- HTML goes in views (like it should)
- Logic goes in controllers and models
- Sprinkle Stimulus for interactive bits
I can build features in 1/3 the time compared to Rails API + React.
When Hotwire Isn't Enough: Stimulus
Sometimes you need client-side interactivity. Dropdowns. Modals. Form validation.
Old me would reach for React. New me uses Stimulus.
Example: Auto-saving form
// app/javascript/controllers/autosave_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form", "status"]
save() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.submitForm()
}, 1000)
}
submitForm() {
this.statusTarget.textContent = "Saving..."
this.formTarget.requestSubmit()
}
}
<%= form_with model: @post,
data: { controller: "autosave",
action: "input->autosave#save" } do |f| %>
<%= f.text_field :title %>
<%= f.text_area :body %>
<span data-autosave-target="status"></span>
<% end %>
Clean. Simple. No build step (with import maps).
Import Maps: JavaScript Without the Build Step
This one blew my mind.
The Bad Old Days
Remember webpack? I still have nightmares.
// webpack.config.js (simplified version, real one was 200+ lines)
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
module.exports = {
entry: './app/javascript/packs/application.js',
output: {
path: path.resolve(__dirname, 'public/packs'),
publicPath: '/packs/',
filename: '[name]-[contenthash].js'
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
},
// ... 50 more lines of loader config
]
},
plugins: [
new MiniCssExtractPlugin(),
new WebpackManifestPlugin()
],
optimization: {
// ... another 30 lines
}
}
And don't get me started on:
- Node version conflicts
- npm vs yarn vs pnpm debates
node_modulesweighing 500MB- Build step breaking in CI
- "It works on my machine" because of subtle webpack config differences
I spent more time fighting build tools than writing code.
Import Maps: The Future (That Feels Like the Past)
Rails 7+ ships with import maps by default. And it's glorious.
// app/javascript/application.js import "@hotwired/turbo-rails" import "./controllers" import LocalTime from "local-time" LocalTime.start()
No build step. No webpack. No node_modules.
The browser just... imports the JavaScript. Like it was always meant to.
"But wait," you say, "how do you import npm packages?"
bin/importmap pin react ``` Done. Import map updated. Can import React in your JS files. **No npm install. No build step.** ### What Changed My Mind I was skeptical. "This can't work for real applications!" Then I built a production app with import maps: - Stimulus controllers for interactivity - Chart.js for data visualization - Trix for rich text editing - Custom JavaScript for complex UI **Total JavaScript bundle size: 187KB (ungzipped)** Compare to my last React app: 1.2MB (after tree-shaking and minification). **Load time went from 3.2s to 0.6s.** And here's the kicker: **Development is instant.** No waiting for webpack to rebuild. Change a JS file, refresh, see changes. Like the good old days. ### When Import Maps Aren't Enough Look, I'm not saying import maps work for everything. If you're building: - A highly interactive SPA with complex state - Something that needs code splitting across 50+ routes - A real-time collaborative editor ...you might still need a build step. **But 90% of Rails apps don't need that.** For most CRUD apps with sprinkles of JavaScript? Import maps are perfect. ## Less JavaScript Than Ever This is the theme that ties everything together. ### 2018: The JavaScript Maximum At my peak JavaScript phase (around 2018), a typical Rails project had: **JavaScript tooling:** - webpack (or Browserify, or Rollup) - Babel - ESLint - Prettier - Jest for testing - Lots of npm packages **Frontend framework:** - React (or Vue, or Angular) - State management (Redux, MobX, Vuex) - Router - Form library - HTTP client **Estimated JavaScript LOC in a medium project: 15,000-20,000 lines** ### 2026: The JavaScript Minimum Modern Rails project: **JavaScript tooling:** - None (import maps just work) **Frontend "framework":** - Hotwire (mostly HTML) - Stimulus controllers for interactivity **Estimated JavaScript LOC in same size project: 500-1,000 lines** **That's a 95% reduction in JavaScript code.** ### Real Example: Task Management Feature **2018 approach (React + Rails API):** ``` Frontend: - TaskList component (150 lines) - TaskItem component (80 lines) - TaskForm component (120 lines) - Redux actions (60 lines) - Redux reducers (80 lines) - API client (40 lines) - Tests (200 lines) Backend: - API controller (50 lines) - Serializer (30 lines) Total: ~810 lines Complexity: High Deployment: Two separate deploys ``` **2026 approach (Hotwire):** ``` Frontend: - Stimulus controller for inline editing (40 lines) Backend: - Controller (30 lines) - Views (50 lines) - Model (20 lines) Total: ~140 lines Complexity: Low Deployment: One deploy
Same functionality. 83% less code.
Why This Matters
Every line of JavaScript is:
- Code you have to write
- Code you have to test
- Code you have to maintain
- Code that can break
- Code that adds complexity
Less JavaScript = Less complexity = Faster development = Fewer bugs
I can ship features in days that would have taken weeks with the old approach.
What Hasn't Changed (And That's Good)
While all this changed, some things stayed beautifully the same:
ActiveRecord is still magic:
User.includes(:posts).where(posts: { published: true }).limit(10)
Still beats writing raw SQL any day.
Convention over configuration still works:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def show
@post = Post.find(params[:id])
end
end
# app/views/posts/show.html.erb exists
# Rails just knows to render it
No config files. No explicit routing of views. It just works.
The Rails community is still pragmatic:
Not chasing every hype trend. Not rewriting everything in Rust. Just building good software that solves real problems.
My Take: Rails Is Back (Was It Ever Gone?)
For a while there, Rails felt like it was losing its identity. Trying to be everything to everyone. API mode this, SPA that.
Modern Rails feels focused again:
"We're a full-stack framework. We render HTML. We embrace server-side rendering. We use JavaScript sparingly. And we're damn good at it."
After years of React fatigue, webpack hell, and microservices complexity, coming back to Rails feels like coming home.
Everything is simpler. Development is faster. Deployments are easier. And honestly? The UX is better.
Turns out, you don't need a massive JavaScript bundle and client-side routing to build great user experiences. You just need:
- Fast server responses
- Smart HTML updates (Turbo)
- Sprinkles of JavaScript where it makes sense (Stimulus)
Rails got this right. Finally.
Should You Go All-In on Hotwire?
Honest answer: It depends.
Hotwire is perfect for:
- CRUD applications (most web apps)
- Dashboards
- Admin panels
- Content management
- E-commerce (yes, really)
- Real-time collaboration (with Turbo Streams)
You might still need React/Vue for:
- Extremely interactive SPAs (think Figma, Google Docs)
- Apps that work offline
- Complex data visualization with heavy client-side computation
But here's the thing: Most of us aren't building Figma. We're building business applications. CRUD with extra steps.
For that? Hotwire is perfect.
What I Learned
After coming back to Rails after years away:
- Simplicity is underrated. We got caught up in complexity because it felt sophisticated. Simple is better.
- The pendulum swings. We went from server-rendered to SPAs. Now we're swinging back to enhanced server-rendering. Both extremes are wrong. Balance is right.
- Rails still innovates. While staying true to its philosophy, it adapts. Hotwire isn't Rails abandoning its principles – it's Rails doubling down on them.
- JavaScript fatigue is real. And Hotwire is the cure.
If you left Rails for the JavaScript ecosystem, I get it. I did too. But if you're tired of the complexity, the tooling, the constant churn?
Come back. Rails is good again.
Actually, scratch that. Rails is great again.