<div class="layout" data-controller="sidebar">
<aside class="sidebar">
<%= render "shared/sidebar", active: params[:controller] %>
</aside>
<main class="content-area">
<%= turbo_frame_tag "content",
data: { action: "turbo:frame-load->sidebar#highlight" } do %>
<h1>Welcome back, <%= current_user.name %></h1>
<p>Select a report from the sidebar to get started.</p>
<% end %>
</main>
</div>
<% nav_link = ->(label, path, controller) do %>
<% is_active = active == controller %>
<%= link_to label, path,
class: class_names("nav-item", active: is_active),
aria: { current: ("page" if is_active) },
data: { turbo_frame: "content", sidebar_target: "link" } %>
<% end %>
<nav class="nav" aria-label="Primary">
<%= nav_link.call("Overview", dashboard_path, "dashboard") %>
<%= nav_link.call("Sales Reports", reports_path(kind: :sales), "reports") %>
<%= nav_link.call("Traffic", reports_path(kind: :traffic), "reports") %>
<%= nav_link.call("Settings", settings_path, "settings") %>
</nav>
class ReportsController < ApplicationController
before_action :authenticate_user!
def index
@kind = params.fetch(:kind, "sales")
@report = ReportBuilder.new(current_user, @kind).call
if turbo_frame_request?
render partial: "reports/report",
locals: { report: @report, kind: @kind }
else
render :index
end
end
end
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["link"]
highlight() {
const path = window.location.pathname + window.location.search
this.linkTargets.forEach((link) => {
const matches = link.getAttribute("href") === path
link.classList.toggle("active", matches)
if (matches) {
link.setAttribute("aria-current", "page")
} else {
link.removeAttribute("aria-current")
}
})
}
}
This snippet shows how a sidebar-driven layout keeps navigation snappy by only swapping the main content region, not the whole page, using Turbo Frames. The core idea is that a Turbo Frame acts as an independently-updatable region of the DOM: any link or form inside it whose response contains a matching <turbo-frame> will have just that region replaced, leaving the surrounding chrome untouched. The sidebar links themselves stay outside the content frame so they persist across navigations and never flicker.
In dashboard/index.html.erb, the page is split into a static <aside> and a <turbo-frame id="content">. The sidebar links each carry data: { turbo_frame: "content" }, which tells Turbo to target the content frame even though the link lives outside it. This is the crucial detail: without that attribute a link inside the sidebar would try to replace a frame wrapping the sidebar, or drive a full-page visit. A small Stimulus controller reference (data-controller="sidebar") handles active-state highlighting.
The _sidebar.html.erb partial renders the actual navigation entries via a nav_link helper. Each entry compares the current controller/action to decide its aria-current and active class, and every link explicitly declares turbo_frame: "content" so the behavior is consistent regardless of where the partial is included.
On the server side, ReportsController responds to those frame requests. The key trick is turbo_frame_request?, which lets the action render only the inner frame partial when Turbo asks for a frame, while still serving a full standalone page on a hard refresh or direct URL visit. This preserves deep-linkability and progressive enhancement: the app degrades gracefully to normal navigation if JavaScript is disabled.
The trade-off is that shared state like the active sidebar link must be reconciled after each swap, since the sidebar DOM is never re-rendered by the frame response. Turbo's turbo:frame-load event, wired through Stimulus, keeps highlighting in sync. This pattern is ideal for admin panels and dashboards where the shell is stable and only the working area changes, avoiding heavyweight SPA frameworks while keeping URLs, back-button behavior, and bookmarks fully functional.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.