<div class="bookmarks">
<h1>Saved Bookmarks</h1>
<table>
<tbody id="bookmarks">
<%= render partial: "bookmark", collection: @bookmarks %>
</tbody>
</table>
<%= link_to "Add bookmark", new_bookmark_path, class: "btn" %>
</div>
<tr id="<%= dom_id(bookmark) %>" class="bookmark-row">
<td>
<%= link_to bookmark.title, bookmark.url, target: "_blank", rel: "noopener" %>
</td>
<td class="muted"><%= bookmark.url %></td>
<td class="actions">
<%= link_to "Delete",
bookmark_path(bookmark),
class: "link-danger",
data: {
turbo_method: :delete,
turbo_confirm: "Remove this bookmark?"
} %>
</td>
</tr>
class BookmarksController < ApplicationController
before_action :set_bookmark, only: :destroy
def index
@bookmarks = current_user.bookmarks.order(created_at: :desc)
end
def destroy
@bookmark.destroy
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.remove(@bookmark)
end
format.html do
redirect_to bookmarks_path, notice: "Bookmark removed."
end
end
end
private
def set_bookmark
@bookmark = current_user.bookmarks.find(params[:id])
end
end
<!DOCTYPE html>
<html>
<head>
<title>Bookmarks</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
This example shows how a plain anchor tag can trigger a DELETE request in a Rails/Hotwire application without wrapping it in a <form>, and how the server responds with a Turbo Stream that surgically removes the record's row from the page. The data-turbo-method attribute is Turbo's replacement for the older Rails UJS data-method: when Turbo intercepts a click on a link carrying data-turbo-method="delete", it builds a synthetic form, injects the CSRF token, and submits it as a real HTTP DELETE.
In bookmarks/index.html.erb, each bookmark is rendered inside a partial and wrapped in a turbo_frame_tag-independent <tr> whose id comes from dom_id(bookmark). The delete link uses link_to with data: { turbo_method: :delete, turbo_confirm: "Remove this bookmark?" }. The turbo_confirm value drives a native confirmation dialog before the request fires, which matters because DELETE is destructive and easy to trigger accidentally. Note there is no method: :delete here — that is the legacy UJS API and is ignored once @rails/ujs is dropped in favor of Turbo.
The _bookmark.html.erb partial is deliberately tiny and keyed on dom_id, because the same DOM id is what the Turbo Stream targets on removal. Keeping the id generation in one place (dom_id) avoids the classic mismatch bug where the markup says bookmark_12 but the stream tries to remove bookmarks_12.
In BookmarksController, the destroy action calls @bookmark.destroy and then a respond_to block branches on format. The format.turbo_stream branch renders a turbo_stream.remove targeting the record, so only the affected row leaves the DOM — no full-page reload, no re-render of the list. The format.html fallback keeps the endpoint usable for non-Turbo clients and tests by redirecting with a flash. This dual response is what makes the feature degrade gracefully.
A key trade-off: because Turbo synthesizes the form client-side, the endpoint still relies on standard Rails CSRF protection, so the meta CSRF tag must be present in the layout. Also, data-turbo-method links are not crawlable actions and search bots won't follow them, which is desirable for destructive routes. Reach for this pattern whenever a list needs inline delete affordances with minimal JavaScript and precise DOM 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
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.