<!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>
<h1>Latest Articles</h1>
<ul>
<% @articles.each do |article| %>
<li>
<%= link_to article.title, article_path(article) %>
<% if article.source_url.present? %>
—
<%= link_to "read the original",
outbound_path(url: article.source_url),
rel: "nofollow noopener" %>
<% end %>
</li>
<% end %>
</ul>
<footer>
<%= link_to "Docs", "https://help.example.com/guide" %>
<%= link_to "Email support", "mailto:support@example.com" %>
<%= link_to "View on GitHub", "https://github.com/example/app", target: "_blank" %>
</footer>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { hosts: Array }
intercept(event) {
const link = event.target.closest("a")
if (!link || !link.href) return
if (this.isExternal(link)) {
link.setAttribute("data-turbo", "false")
}
}
isExternal(link) {
if (link.hasAttribute("target")) return true
const url = new URL(link.href, window.location.origin)
if (!url.protocol.startsWith("http")) return true
if (url.origin === window.location.origin) return false
return !this.allowedHosts.includes(url.host)
}
get allowedHosts() {
return this.hasHostsValue ? this.hostsValue : []
}
}
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
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.