module LayoutlessTurboFrame
extend ActiveSupport::Concern
included do
layout :layout_for_turbo
end
private
def layout_for_turbo
# false disables the layout entirely; nil defers to normal resolution.
turbo_frame_request? ? false : nil
end
def turbo_frame_request?
request.headers["Turbo-Frame"].present?
end
end
class ApplicationController < ActionController::Base
include LayoutlessTurboFrame
before_action :authenticate_user!
helper_method :turbo_frame_request?
end
class MessagesController < ApplicationController
before_action :set_message, only: %i[edit update]
def edit
end
def create
@message = current_user.messages.build(message_params)
if @message.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to messages_path, notice: "Message sent." }
end
else
render :new, status: :unprocessable_entity
end
end
def update
if @message.update(message_params)
redirect_to messages_path, notice: "Message updated."
else
render :edit, status: :unprocessable_entity
end
end
private
def set_message
@message = current_user.messages.find(params[:id])
end
def message_params
params.require(:message).permit(:body)
end
end
<%= turbo_frame_tag dom_id(@message) do %>
<%= form_with model: @message do |form| %>
<% if @message.errors.any? %>
<div class="errors">
<%= @message.errors.full_messages.to_sentence %>
</div>
<% end %>
<%= form.text_area :body, rows: 3 %>
<div class="actions">
<%= form.submit "Save" %>
<%= link_to "Cancel", messages_path %>
</div>
<% end %>
<% end %>
Turbo Frames replace a single fragment of the page, so responding to a frame request with the full application layout wastes bandwidth and can even break the DOM when the returned markup includes a second <body> or duplicate navigation. The Rails convention is to detect the frame request server-side and render the action's template without any layout, returning just the <turbo-frame> and its contents. These files show a small, reusable way to do that across every controller.
In LayoutlessTurboFrame concern, the logic is packaged as a controller concern so it can be included once in ApplicationController. Turbo sends the header Turbo-Frame on every frame-driven request, exposed by the turbo_frame_request? helper. The concern registers a layout block via layout :layout_for_turbo, which returns false (meaning "no layout") when the request came from a frame and nil otherwise, letting Rails fall back to its normal layout resolution. Using nil rather than "application" is deliberate: it preserves per-controller layout declarations instead of hard-coding one.
ApplicationController simply includes the concern, so the behavior is inherited everywhere without touching individual actions. This keeps the decision in one place and avoids scattering render layout: false calls through the codebase, which is easy to forget and hard to audit.
MessagesController demonstrates the payoff. The create action responds to a normal HTML form with a redirect, but when the same form lives inside a <turbo-frame id="new_message">, Turbo intercepts the submission and the controller renders create.turbo_stream or the frame template directly. Because the layout is suppressed automatically, the response body is only the frame markup, and edit likewise returns just the inline edit form.
messages/edit.html.erb shows the matching view: the entire template is wrapped in turbo_frame_tag, so a full-page visit still renders inside the layout while a frame request returns the same <turbo-frame> alone. The trade-off is subtle — the template must be self-contained within its frame, and any layout-provided assets (flash, nav) will not appear in frame responses, which is exactly what makes them cheap. This pattern is the idiomatic bridge between server-rendered Rails and Hotwire's partial-page updates.
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.