Modern web applications need to feel fast and responsive. Users expect instant feedback, smooth transitions, and pages that don't reload unnecessarily. Turbo Frames deliver exactly this—decomposing pages into independent sections that can update without a full page refresh, all without writing custom JavaScript.
What Are Turbo Frames?
Turbo Frames are containers that scope navigation. When a link or form inside a frame is activated, only that frame's content gets replaced—not the entire page. This creates the snappy feel of a single-page application while keeping the simplicity of server-rendered HTML.
Think of frames as mini-browsers within your page. Each frame can independently load, update, and refresh content. The server returns standard HTML, and Turbo handles swapping it into the right place.
Basic Frame Setup: Inline Editing
A common pattern is inline editing—clicking an element transforms it into an editable form, then saves without leaving the page. Here's how to implement this for a task list:
# app/views/tasks/_task.html.erb
<%= turbo_frame_tag dom_id(task) do %>
<%= task.title %>
<%= link_to "Edit", edit_task_path(task), class: "edit-link" %>
<% end %>
# app/views/tasks/edit.html.erb
<%= turbo_frame_tag dom_id(@task) do %>
<%= form_with model: @task, class: "task-form" do |f| %>
<%= f.text_field :title, autofocus: true %>
<%= f.submit "Save" %>
<%= link_to "Cancel", task_path(@task) %>
<% end %>
<% end %>The magic happens through matching frame IDs. When clicking "Edit," Turbo fetches the edit page, finds the frame with the matching ID (task_123), and swaps only that content. The controller remains standard Rails:
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def edit
@task = Task.find(params[:id])
end
def update
@task = Task.find(params[:id])
if @task.update(task_params)
redirect_to task_path(@task)
else
render :edit, status: :unprocessable_entity
end
end
private
def task_params
params.require(:task).permit(:title)
end
endAfter a successful update, the redirect renders the show view, and Turbo swaps the form back to the display version. No JavaScript event handlers, no JSON APIs—just HTML.
Lazy Loading with Turbo Frames
Some content is expensive to generate or not immediately needed. Turbo Frames support lazy loading through the src attribute and loading: :lazy option. The frame loads its content only when it enters the viewport.
# app/views/dashboard/show.html.erb
Dashboard
<%= turbo_frame_tag "recent_activity",
src: dashboard_activity_path,
loading: :lazy do %>
Loading recent activity...
<% end %>
<%= turbo_frame_tag "performance_metrics",
src: dashboard_metrics_path,
loading: :lazy do %>
Loading metrics...
<% end %>
# app/views/dashboard/_activity.html.erb
<%= turbo_frame_tag "recent_activity" do %>
Recent Activity
<% @activities.each do |activity| %>
- <%= activity.description %> - <%= time_ago_in_words(activity.created_at) %> ago
<% end %>
<% end %>The placeholder content displays immediately while the expensive queries run in the background. This approach keeps the initial page load fast while progressively enhancing with additional data.
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
def show
# Fast - just renders the shell
end
def activity
@activities = Activity.includes(:user)
.order(created_at: :desc)
.limit(10)
render partial: "activity"
end
def metrics
@metrics = MetricsCalculator.new(current_user).compute
render partial: "metrics"
end
end
# config/routes.rb
resource :dashboard, only: [:show] do
get :activity
get :metrics
endPagination Without Page Reloads
Turbo Frames transform pagination from a jarring full-page experience into smooth content swaps. Wrap the paginated content in a frame, and pagination links automatically update only that section:
# app/views/articles/index.html.erb
Articles
<%= turbo_frame_tag "articles_list" do %>
<% @articles.each do |article| %>
<%= render article %>
<% end %>
<%= link_to "Previous", articles_path(page: @page - 1) if @page > 1 %>
Page <%= @page %> of <%= @total_pages %>
<%= link_to "Next", articles_path(page: @page + 1) if @page < @total_pages %>
<% end %>
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
PER_PAGE = 20
def index
@page = (params[:page] || 1).to_i
@total_pages = (Article.count.to_f / PER_PAGE).ceil
@articles = Article.order(created_at: :desc)
.offset((@page - 1) * PER_PAGE)
.limit(PER_PAGE)
end
endClicking pagination links fetches the new page, but only the frame content updates. The header, navigation, and footer remain untouched. Browser history updates automatically, so the back button works as expected.
Breaking Out of Frames
Sometimes a link inside a frame should navigate the entire page—like clicking through to a full article view. Use data-turbo-frame="_top" to break out:
# app/views/articles/_article.html.erb
<%= link_to article.title, article_path(article), data: { turbo_frame: "_top" } %>
<%= truncate(article.body, length: 150) %>
The _top target tells Turbo to replace the entire page rather than searching for a matching frame.
Common Mistakes to Avoid
Mismatched frame IDs: The source page and target page must have frames with identical IDs. A common debugging step is inspecting the HTML response to verify the frame exists.
Missing frame in response: If the server response doesn't contain the expected frame, Turbo displays an error. Always ensure controller actions render views with matching frame tags.
Forms without error handling: When validation fails, return status: :unprocessable_entity to trigger Turbo's form error handling. Without this status code, Turbo may not re-render the form with errors.
Over-framing: Not everything needs a frame. Use frames for discrete, independently-updatable sections. Wrapping the entire page in frames adds complexity without benefit.
Summary and Next Steps
Turbo Frames provide powerful tools for building responsive interfaces without JavaScript complexity. The key patterns covered here—inline editing, lazy loading, and seamless pagination—address the most common interactivity needs in web applications.
To go further, explore combining Turbo Frames with Turbo Streams for real-time updates from background jobs, or investigate Stimulus for the cases where a small amount of JavaScript enhances the experience. The Rails 8 Hotwire stack offers a complete solution for modern web development while keeping the server-rendered simplicity that makes Rails productive.