erb javascript 68 lines · 3 tabs

Disable Turbo Drive on external links

Shared by codesnips Jan 2026
3 tabs
<!DOCTYPE html>
<html>
  <head>
    <title><%= content_for(:title) || "App" %></title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= javascript_importmap_tags %>
  </head>

  <body
    data-controller="external-links"
    data-action="click->external-links#intercept"
    data-external-links-hosts-value="<%= ["cdn.example.com", "help.example.com"].to_json %>">
    <%= yield %>
  </body>
</html>
3 files · erb, javascript Explain with highlit

Turbo Drive intercepts every same-origin <a> click and turns it into a fetch-and-swap navigation, which is exactly what breaks external links, mailto: links, and links meant to open in a new tab. Turbo already skips cross-origin URLs, but many real apps route outbound traffic through a first-party redirect endpoint (for click tracking or affiliate rewrites), so the URL stays same-origin while the destination is not. This snippet shows a small, reusable way to opt those links out of Turbo without scattering data-turbo="false" by hand.

In external_links_controller.js, a Stimulus controller connects at the document level and inspects links lazily on click rather than tagging them up front. The intercept handler walks up from event.target with closest("a") so it works even when the click lands on a child element like an icon. The isExternal predicate builds a URL against window.location.origin and compares hosts; it also treats non-HTTP schemes (mailto:, tel:) and any link carrying a target attribute as external. When a link qualifies, the controller sets data-turbo="false" on it, which is the attribute Turbo Drive reads to bypass its click handler. Because Turbo's own listener runs after this one on the same event, mutating the element in time is enough to hand the navigation back to the browser.

The hosts value lets the app declare extra domains that should be treated as internal even though they differ from the current origin — useful for a CDN or a sibling subdomain that should still be Turbo-visited.

In application.html.erb, the controller is attached to <body> with data-controller="external-links" and a single delegated click action, so one listener covers the whole page instead of one per anchor. The data-external-links-hosts-value supplies the allowlist as JSON, matching Stimulus's typed value convention.

The index.html.erb tab shows the payoff: ordinary internal links use link_to untouched, the tracked outbound link and the mailto: link are left alone in markup, and the controller decides at click time. The trade-off is that this runs JavaScript on every click; the check is cheap, but an app that already knows a link is external at render time is better served by setting data-turbo: false directly in the link_to options. This delegated approach shines when link destinations are dynamic or come from user-generated content where per-link annotation is impractical.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Disable Turbo Drive on external links — share card
Link copied