Rails 7 represents a philosophical shift: No Node.js required for modern JavaScript. Import Maps, Hotwire (Turbo + Stimulus), and encrypted attributes make Rails 7 the most developer-friendly version yet.
This is also the first Rails version that requires Ruby 2.7+ and encourages Ruby 3+ adoption.
Note: This is Part 4 of our Rails Upgrade Series. Read Part 1: Planning Rails Upgrade for the overall strategy.
Before We Start
Expected Timeline: 1-3 weeks for medium-sized applications (smoothest upgrade yet!)
Medium-sized application: 20,000-50,000 lines of code, 30-100 models, moderate test coverage, 2-5 developers. Smaller apps may take 1-2 weeks, larger enterprise apps 6-12 weeks.
Prerequisites:
- Currently on Rails 6.1 (upgrade from 6.0 first)
- Ruby 2.7.0+ installed (Ruby 3+ strongly recommended)
- Test coverage of 80%+
- Understanding of JavaScript setup
Step 0: Upgrade Ruby First (Required)
Rails 7 requires Ruby 2.7.0 minimum, but Ruby 3.0 or 3.1 is strongly recommended.
Important: Upgrade Ruby before upgrading Rails to avoid compatibility issues.
Why Ruby 3+?
Ruby 3.0 (December 2020) - Major milestone:
- 3x faster than Ruby 2.0 (goal achieved!)
- YJIT - Just-In-Time compiler (20-40% performance boost)
- Ractor - Parallel execution without GIL
- Fiber Scheduler - Async I/O support
- Keyword arguments - Breaking change from 2.7
- RBS - Type signatures
Ruby 3.1 (December 2021):
- YJIT enabled by default (production ready)
- Shorthand hash syntax:
{x:, y:}instead of{x: x, y: y} - Better error messages
- Pattern matching improvements
Upgrade Ruby
# Check current Ruby version
ruby -v
# Install Ruby 3.1 (recommended)
rbenv install 3.1.4
rbenv local 3.1.4
# Or Ruby 3.0
rbenv install 3.0.6
rbenv local 3.0.6
# Verify
ruby -v
# => ruby 3.1.4
# Update bundler
gem install bundler
bundle installFix Ruby 3.0 Keyword Arguments
Ruby 3.0 makes keyword arguments stricter (warnings from Ruby 2.7 become errors).
Common issue:
# This worked in Ruby 2.7 with warning
def create_user(name:, email:)
User.create(name: name, email: email)
end
options = { name: "John", email: "[email protected]" }
create_user(options) # Error in Ruby 3.0!
# CORRECT - use double splat
create_user(**options)Find and fix keyword argument issues:
# Run tests with Ruby 3.0 to find issues
ruby -w bin/rails test 2>&1 | grep "warning"
# Common patterns to fix:
# 1. Hash as last argument -> use **
# 2. Method definitions with options hash
# 3. Splat operators in method callsEnable YJIT (Ruby 3.1+)
# config/boot.rb
ENV['RUBY_YJIT_ENABLE'] = '1'
# Or set environment variable
export RUBY_YJIT_ENABLE=1Performance gain: 20-40% faster request processing with YJIT enabled.
Test with Ruby 3 Before Upgrading Rails
# Run full test suite with Ruby 3
bundle exec rails test
# Or with RSpec
bundle exec rspec
# Fix any Ruby 3 compatibility issues before proceedingStep 1: Update the Gemfile
# Gemfile
# Update Rails
gem 'rails', '~> 7.0.0'
# Import Maps (new default)
gem 'importmap-rails'
# Hotwire (Turbo + Stimulus)
gem 'turbo-rails'
gem 'stimulus-rails'
# Or keep Webpacker if preferred
# gem 'webpacker', '~> 5.0'
# gem 'jsbundling-rails' # Alternative to Webpacker
# CSS bundling (optional)
gem 'cssbundling-rails'
# Update common gems
gem 'sprockets-rails' # Still needed for CSS
gem 'puma', '~> 5.0'
gem 'bootsnap', require: false
# Database adapters
gem 'pg', '~> 1.1' # PostgreSQL
# or
gem 'mysql2', '~> 0.5' # MySQLbundle update rails
bundle installStep 2: Run the Update Task
rails app:updateReview changes to:
config/application.rbconfig/environments/*.rbconfig/initializers/new_framework_defaults_7_0.rb
Step 3: Choose the JavaScript Strategy
Rails 7 offers three approaches for JavaScript:
Option 1: Import Maps (Default - No Node.js)
Best for: Traditional Rails apps with minimal JavaScript
# Install Import Maps
rails importmap:install# config/importmap.rb
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
# Pin the JavaScript files
pin_all_from "app/javascript/controllers", under: "controllers"
# Pin npm packages from CDN
pin "lodash", to: "https://ga.jspm.io/npm:[email protected]/lodash.js"// app/javascript/application.js
import "@hotwired/turbo-rails"
import "./controllers"<!-- app/views/layouts/application.html.erb -->
<%= javascript_importmap_tags %>Option 2: Keep Webpacker/jsbundling-rails
Best for: Apps with complex JavaScript, React, Vue
# Keep existing Webpacker setup
# Or migrate to jsbundling-rails
rails javascript:install:esbuild
# or
rails javascript:install:webpackOption 3: Hybrid Approach
Use Import Maps for simple JavaScript and Webpacker for complex components.
Recommendation: Start with Import Maps. Migrate to bundling only if needed.
Step 4: Install Hotwire
Hotwire = Turbo + Stimulus (modern, reactive UI without much JavaScript)
Install Turbo and Stimulus
rails turbo:install
rails stimulus:installTurbo Drive (Automatic)
Turbo Drive is enabled by default and makes navigation faster:
<!-- Links are automatically Turbo-enabled -->
<%= link_to "Users", users_path %>
<!-- Disable Turbo for specific links -->
<%= link_to "External", "https://example.com", data: { turbo: false } %>Turbo Frames (Partial Page Updates)
<!-- app/views/posts/index.html.erb -->
<%= turbo_frame_tag "posts" do %>
<% @posts.each do |post| %>
<%= render post %>
<% end %>
<% end %>
<!-- app/views/posts/_post.html.erb -->
<%= turbo_frame_tag post do %>
<h2><%= post.title %></h2>
<%= link_to "Edit", edit_post_path(post) %>
<% end %>
<!-- Clicking "Edit" only updates that frame -->Turbo Streams (Real time Updates)
# app/controllers/posts_controller.rb
def create
@post = Post.create(post_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to @post }
end
end<!-- app/views/posts/create.turbo_stream.erb -->
<%= turbo_stream.prepend "posts", @post %>
<%= turbo_stream.update "form", "" %>Stimulus Controllers
// app/javascript/controllers/hello_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "name", "output" ]
greet() {
this.outputTarget.textContent =
`Hello, ${this.nameTarget.value}!`
}
}<!-- Use in views -->
<div data-controller="hello">
<input data-hello-target="name" type="text">
<button data-action="click->hello#greet">Greet</button>
<span data-hello-target="output"></span>
</div>Step 5: ActiveRecord Encryption
Rails 7 adds built in encryption for sensitive data.
Enable Encryption
# Generate encryption keys
rails db:encryption:init
# This outputs keys - add to credentials# Edit credentials
rails credentials:edit# config/credentials.yml.enc
active_record_encryption:
primary_key: [generated_key]
deterministic_key: [generated_key]
key_derivation_salt: [generated_salt]Encrypt Attributes
# app/models/user.rb
class User < ApplicationRecord
encrypts :email
encrypts :ssn, deterministic: true # For searching
encrypts :credit_card, ignore_case: true
end# Usage
user = User.create(email: "[email protected]", ssn: "123-45-6789")
# Stored encrypted in database
user.email # => "[email protected]" (decrypted automatically)
# Search with deterministic encryption
User.find_by(ssn: "123-45-6789") # Works!Migrate Existing Data
# db/migrate/xxx_encrypt_user_emails.rb
class EncryptUserEmails < ActiveRecord::Migration[7.0]
def up
User.find_each do |user|
user.encrypt # Encrypts all encrypted attributes
end
end
endStep 6: Query Method Improvements
sole and find_sole_by
Find exactly one record or raise an error:
# Returns the only user or raises
User.sole
# Raises if 0 or 2+ users exist
# Find by attribute
User.find_sole_by(email: "[email protected]")
# Raises if 0 or 2+ matchesexcluding Method
# Exclude specific records
Post.excluding(archived_posts)
# Exclude by ID
Post.excluding(post_to_hide)Async Queries
# Load data asynchronously
def index
@posts = Post.all.load_async
@users = User.all.load_async
# Both queries run in parallel
# Data is loaded when accessed
endStep 7: Breaking Changes
1. ActiveStorage::Current Removed
# WRONG - removed in Rails 7
ActiveStorage::Current.host = "https://example.com"
# CORRECT - set in config
# config/environments/production.rb
config.action_controller.default_url_options = { host: "example.com" }2. Rails.application.config.autoloader
# Zeitwerk is now the only autoloader
# This config is removed
# config.autoloader = :zeitwerk3. Sprockets 4
Rails 7 uses Sprockets 4 (if using Sprockets):
# Gemfile
gem 'sprockets-rails'
# Manifest files work the same
# app/assets/config/manifest.js
//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_directory ../javascripts .js4. button_to Rendering
# Rails 7 renders button_to as <button> instead of <input>
# May affect CSS styling
# Before (Rails 6)
button_to "Delete", post_path(@post), method: :delete
# => <input type="submit" value="Delete">
# After (Rails 7)
# => <button type="submit">Delete</button>Step 8: Testing Updates
System Tests with Hotwire
# test/system/posts_test.rb
require "application_system_test_case"
class PostsTest < ApplicationSystemTestCase
test "creating a post with Turbo" do
visit posts_url
click_on "New Post"
fill_in "Title", with: "My Post"
fill_in "Body", with: "Content"
click_on "Create Post"
# Turbo updates the page without full reload
assert_text "Post was successfully created"
assert_text "My Post"
end
endTest Turbo Streams
# test/controllers/posts_controller_test.rb
class PostsControllerTest < ActionDispatch::IntegrationTest
test "should create post with turbo stream" do
assert_difference('Post.count') do
post posts_url, params: { post: { title: "Test" } },
as: :turbo_stream
end
assert_response :success
assert_match "turbo-stream", response.body
end
endStep 9: Performance Improvements
After upgrading to Rails 7 + Ruby 3.1 with YJIT:
- 20-40% faster request processing (YJIT)
- Faster page loads with Turbo Drive
- Reduced JavaScript bundle size with Import Maps
- Better memory usage with Ruby 3.1
Benchmark YJIT
# Benchmark with and without YJIT
# config/boot.rb
# Test without YJIT
# ENV['RUBY_YJIT_ENABLE'] = '0'
# Test with YJIT
ENV['RUBY_YJIT_ENABLE'] = '1'
# Check YJIT stats
if defined?(RubyVM::YJIT)
puts "YJIT enabled: #{RubyVM::YJIT.enabled?}"
# After running the app
puts RubyVM::YJIT.runtime_stats
endStep 10: Deployment Considerations
Import Maps and CDN
# config/environments/production.rb
# Preload import maps
config.importmap.cache_sweepers << config.root.join("app/javascript")
# Use CDN for assets
config.asset_host = "https://cdn.example.com"Precompile Assets
# Precompile assets (includes import maps)
RAILS_ENV=production rails assets:precompile
# Check import map
rails importmap:auditUpgrade Checklist
Note: This checklist covers the most common changes. Depending on the application’s gems, custom code, and architecture, we may encounter additional issues. Always test thoroughly in a staging environment.
- Upgrade Ruby to 2.7+ (3.1 recommended)
- Enable YJIT (Ruby 3.1+)
- Update Gemfile with Rails 7.0
- Run
rails app:update - Choose JavaScript strategy (Import Maps recommended)
- Install Hotwire:
rails turbo:install stimulus:install - Fix Ruby 3 keyword argument errors
- Update
button_toCSS if needed - Test Turbo Drive/Frames/Streams
- Set up encryption for sensitive data (optional)
- Run full test suite
- Test in staging environment
- Deploy to production with monitoring
Common Gotchas
1. Turbo and Forms
<!-- Forms are automatically Turbo-enabled -->
<%= form_with model: @post do |f| %>
<!-- Submits via Turbo -->
<% end %>
<!-- Disable Turbo for specific forms -->
<%= form_with model: @post, data: { turbo: false } do |f| %>
<!-- Regular form submission -->
<% end %>2. Import Maps and npm Packages
# Not all npm packages work with Import Maps
# Check compatibility first
# Pin from CDN
rails importmap:pin lodash
# Or use jspm.io
pin "lodash", to: "https://ga.jspm.io/npm:[email protected]/lodash.js"3. Turbo and Redirects
# Controller redirects work with Turbo
def create
@post = Post.create(post_params)
redirect_to @post # Works with Turbo
end
# Check for Turbo specific responses
respond_to do |format|
format.turbo_stream
format.html { redirect_to @post }
endWhat’s Next
We’ve successfully upgraded from Rails 6.1 to Rails 7 with Import Maps, Hotwire, and Ruby 3.
In the final post, we’ll tackle Rails 7.2 to Rails 8, which brings Solid Queue, built in authentication, and Ruby 3.3 performance.
Coming up:
- Part 5: Rails 7.2 to Rails 8 - Solid Queue, Solid Cache, authentication generator, Ruby 3.3 YJIT improvements
Resources
- Official Rails 7.0 Release Notes
- Rails Upgrade Guide
- Hotwire Documentation
- Import Maps Guide
- Ruby 3.0 Release Notes
- RailsDiff 6.1 to 7.0
At Saeloun, we’ve helped numerous teams modernize their Rails applications with Hotwire and Ruby 3.
If we can help with expert guidance for the Rails 7 upgrade, we’re here to help.
