Turbo Streams and Rails Pagination: Binding query limits to incremental updates
0 reputation · 18 Nov 2020, 17:28 UTC
0 reputation · 18 Nov 2020, 17:28 UTC
We want to deliver a large dataset to the browser in paginated chunks using Turbo Streams while keeping the server‑side query bounded by limit/offset or cursor logic.
Turbo only renders the HTML fragment; it does not enforce database limits. Pagination helpers such as paginate set limit and offset, but those values must be propagated through the request URL or form data. Without the data-turbo-action="advance" attribute, the link triggers a full page reload. Turbo also lacks built‑in cursor‑based pagination, so developers must embed cursor tokens in data attributes or hidden fields.
When a user clicks a “load more” link that is transformed into a Turbo request, how does the server know which page to fetch? If the link includes only a data-turbo-action="advance" attribute, does Rails automatically read the page query parameter, or must the controller explicitly parse it? Additionally, how should the server embed the next cursor value so that the client can request the following page without exposing raw database offsets?
Pass the pagination value (page number or signed cursor) as a normal request parameter in the URL or form data of the Turbo Stream link. The controller reads params[:page] or params[:cursor] and feeds it to the paginator or a manual limit/offset query, so the database stays bounded.
# app/controllers/posts_controller.rb
def index
page = params[:page].to_i > 0 ? params[:page].to_i : 1
per = params[:per_page] || 20
@posts = Post.order(:id).offset((page - 1) * per).limit(per)
respond_to do |format|
format.turbo_stream # renders index.turbo_stream.erb
format.html
end
end
data-turbo-action="advance" attribute:<!-- app/views/posts/index.turbo_stream.erb -->
<%= turbo_stream.append "posts" do %>
<%= render @posts %>
<% end %>
<%= link_to "Load more",
posts_path(page: params[:page].to_i + 1, per_page: params[:per_page]),
data: { turbo_action: "advance" },
class: "btn" %>
# config/initializers/cursor_verifier.rb
CursorVerifier = ActiveSupport::MessageVerifier.new(Rails.application.credentials.secret_key_base)
# app/helpers/cursor_helper.rb
module CursorHelper
def encode_cursor(last_id)
CursorVerifier.generate([last_id].to_json)
end
def decode_cursor(token)
data = CursorVerifier.verify(token)
JSON.parse(data).first rescue nil
end
end
def index
per = params[:per_page] || 20
if params[:cursor].present?
last_id = CursorHelper.decode_cursor(params[:cursor])
unless last_id
head :bad_request and return
end
@posts = Post.where("id > ?", last_id).order(:id).limit(per)
else
@posts = Post.order(:id).limit(per)
end
respond_to do |format|
format.turbo_stream
format.html
end
end
<%= turbo_stream.append "posts" do %>
<%= render @posts %>
<% end %>
<% if @posts.any? %>
<%= link_to "Load more",
posts_path(cursor: encode_cursor(@posts.last.id), per_page: params[:per_page]),
data: { turbo_action: "advance" },
class: "btn" %>
<% end %>
page or cursor parameter and return only the next batch.ActiveSupport::MessageVerifier::InvalidSignature (or return 400) and not expose data.EXPLAIN ANALYZE on the generated SQL to confirm a LIMIT clause (or WHERE id > ?) and absence of a full table scan.If you are using a different pagination gem (e.g., Kaminari or WillPaginate), replace the manual limit/offset with Post.page(params[:page]).per(params[:per_page]); the same parameter passing applies.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.