class TurboFailureApp < Devise::FailureApp
def respond
if turbo_request?
redirect_for_turbo
else
super
end
end
def redirect_for_turbo
store_location!
flash[:alert] = i18n_message
self.status = 303
self.response_body = ""
self.headers["Location"] = scope_url
end
private
def turbo_request?
request.headers["Turbo-Frame"].present? ||
request.headers["Accept"].to_s.include?("text/vnd.turbo-stream.html")
end
def scope_url
new_user_session_path
end
end
class TurboAuthMiddleware
STREAM_MIME = "text/vnd.turbo-stream.html".freeze
def initialize(app)
@app = app
end
def call(env)
status, headers, response = @app.call(env)
return [status, headers, response] unless intercept?(status, env)
login_url = "/users/sign_in"
body = <<~HTML
<turbo-stream action="append" target="body">
<template>
<div data-controller="redirect" data-redirect-url-value="#{login_url}"></div>
</template>
</turbo-stream>
HTML
headers = headers.dup
headers["Content-Type"] = "#{STREAM_MIME}; charset=utf-8"
headers["Content-Length"] = body.bytesize.to_s
headers["Turbo-Location"] = login_url
[200, headers, [body]]
end
private
def intercept?(status, env)
status.to_i == 401 &&
env["HTTP_ACCEPT"].to_s.include?(STREAM_MIME)
end
end
require_relative "boot"
require "rails/all"
Bundler.require(*Rails.groups)
module Shop
class Application < Rails::Application
config.load_defaults 7.1
# Rewrite escaped 401s into a Turbo-driven full redirect
config.middleware.insert_before(
ActionDispatch::ShowExceptions,
TurboAuthMiddleware
)
config.to_prepare do
Devise::SessionsController.respond_to :html, :turbo_stream
end
end
end
import { Controller } from "@hotwired/stimulus";
import * as Turbo from "@hotwired/turbo";
export default class extends Controller {
static values = { url: String };
connect() {
const url = this.urlValue;
if (!url) return;
// Force a real navigation so the document, CSRF token and cookies rebuild.
try {
Turbo.visit(url, { action: "replace" });
} catch (_error) {
window.location.assign(url);
}
// Drop the injected node so it never lingers in the Turbo cache.
this.element.remove();
}
}
When a session expires mid-session, a Turbo Drive fetch request that receives a 302 to the login page silently follows the redirect and swaps the login form into a <turbo-frame> or the <body>, producing a broken half-rendered page instead of a real navigation. The reason is that Turbo treats XHR-style responses differently from top-level browser navigation: it will not perform a full document replacement for a redirect the way the browser would for a plain link click. The fix is to detect an unauthenticated Turbo request on the server, respond with a status Turbo cannot silently absorb, and tell the browser to do a hard window.location change instead.
In TurboFailureApp, the Devise failure app is overridden so that when the incoming request is a Turbo Drive request it emits a redirect_to wrapped for Turbo. The key detail is request_format and the check on the Turbo-Frame / text/vnd.turbo-stream.html accept header: normal HTML navigations keep Devise's default 302 behaviour, while Turbo requests are steered toward a response Turbo will honour as a genuine visit.
TurboAuthMiddleware is the belt-and-suspenders layer for any 401 that escapes the Devise path — API-ish endpoints, custom head :unauthorized, or Pundit failures. It inspects the response after the app runs, and when it sees a 401 on a request whose Accept header contains text/vnd.turbo-stream.html, it rewrites the response into a 200 carrying a small turbo-stream that drives a redirect. Returning 200 matters: a 401 body is not rendered as a stream by Turbo, so the middleware normalises the status while preserving intent. The Turbo-Location style header and the stream action both point at new_user_session_path.
redirect_controller.js is the Stimulus companion that executes the actual hard navigation. When the injected element connects, it reads its data-redirect-url-value and calls Turbo.visit(url, { action: 'replace' }), falling back to window.location.assign so the entire document reloads and the CSRF token, cookies, and layout are all rebuilt cleanly.
The trade-off is a little duplicated redirect logic across the failure app and the middleware, but each covers a different escape hatch. A common pitfall is forgetting that Turbo caches pages: forcing action: 'replace' avoids leaving the expired page in the back-forward cache. This pattern is worth reaching for whenever authentication or authorization can fail asynchronously inside a Turbo-driven app.
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
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
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
Share this code
Here's the card — post it anywhere.