class SessionsController < ApplicationController
def new
@user = User.new
end
def create
@user = User.authenticate_by(email: params[:email], password: params[:password])
if @user
start_new_session_for(@user)
redirect_out_of_frame(after_authentication_url)
else
flash.now[:alert] = "Invalid email or password"
render :new, status: :unprocessable_entity
end
end
private
def redirect_out_of_frame(url)
response.headers["Turbo-Location"] = url
@visit_url = url
render :create, status: :ok
end
end
<%# Escape the <turbo-frame> and navigate the whole document. %>
<%= turbo_stream.action :visit, @visit_url, action: "replace" %>
<%# turbo_stream.action :visit renders roughly:
<turbo-stream action="visit" url="..." data-visit-action="replace">
%>
import { StreamActions } from "@hotwired/turbo"
StreamActions.visit = function () {
const url = this.getAttribute("url")
if (!url) return
const historyAction = this.getAttribute("data-visit-action") || "advance"
Turbo.visit(url, { action: historyAction })
}
When a form lives inside a <turbo-frame>, a normal redirect_to after a successful submission is scoped to that frame: Turbo follows the redirect, extracts the matching frame from the response, and swaps only that region. That behavior is usually desirable, but sometimes a successful action should escape the frame entirely — for example, a modal login form that must reload the whole page, or a wizard step that should navigate the browser to a brand-new URL. The Turbo-Location response header is the mechanism for that: it tells Turbo which URL to push into the browser history and, combined with a Turbo Stream that targets the visit, forces a full navigation instead of a frame replacement.
The controller in SessionsController shows the two branches. On failure it re-renders the frame with :unprocessable_entity, keeping the error inside the modal. On success it calls the private redirect_out_of_frame helper, which sets response.headers["Turbo-Location"] to the target path and then renders a Turbo Stream. Setting the header alone is not enough — Turbo only reads it from a response it is already processing, so a stream response is what carries it.
The create.turbo_stream.erb template drives the actual navigation. It uses turbo_stream.action :visit (a custom stream action) so the client performs Turbo.visit(url, { action: "replace" }), replacing the current history entry rather than stacking a new one. Because the visit URL matches the Turbo-Location value, the history stays consistent and a browser refresh lands on the right page.
The turbo_visit_controller.js Stimulus-style action handler registers the visit stream action on the client, since Turbo ships stream actions like replace and append but not visit by default. It reads the url attribute from the <turbo-stream> element and delegates to Turbo.visit.
The key trade-off is that this pattern deliberately breaks frame containment, so it should be reserved for genuine navigations — modals, auth flows, completed wizards — not routine in-frame updates. A common pitfall is setting Turbo-Location on a plain HTML redirect and expecting a full-page visit; without the stream and the registered action, Turbo will still scope the result to the originating frame.
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.