Upgrading from Rails 6.1 to Rails 7 - The Modern Stack

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 install

Fix 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 calls

Enable YJIT (Ruby 3.1+)

# config/boot.rb

ENV['RUBY_YJIT_ENABLE'] = '1'

# Or set environment variable

export RUBY_YJIT_ENABLE=1

Performance 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 proceeding

Step 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' # MySQL
bundle update rails
bundle install

Step 2: Run the Update Task

rails app:update

Review changes to:

  • config/application.rb
  • config/environments/*.rb
  • config/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:webpack

Option 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:install

Turbo 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
end

Step 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+ matches

excluding 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

end

Step 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 = :zeitwerk

3. 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 .js

4. 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
end

Test 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
end

Step 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
end

Step 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:audit

Upgrade 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_to CSS 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 }
end

What’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


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.

Contact us for Rails upgrade consulting

Need help on your Ruby on Rails or React project?

Join Our Newsletter