<div id="articles">
<%= render @articles %>
</div>
<% if @next_page.present? %>
<%= turbo_frame_tag 'next_page', src: articles_path(page: @next_page), loading: :lazy do %>
<div class="py-6 text-center text-gray-500">Loading…</div>
<% end %>
<% end %>
<%= turbo_stream.append 'articles' do %>
<%= render @articles %>
<% end %>
<%= turbo_stream.replace 'next_page' do %>
<% if @next_page.present? %>
<%= turbo_frame_tag 'next_page', src: articles_path(page: @next_page), loading: :lazy do %>
<div class="py-6 text-center text-gray-500">Loading…</div>
<% end %>
<% else %>
<div class="py-6 text-center text-gray-400">End.</div>
<% end %>
<% end %>
Turbo Frames can implement infinite scrolling without a JS router. I render the first page normally and append a “next page” frame at the bottom with loading: :lazy. When the user scrolls and the frame enters view, Turbo fetches the next page automatically. The server returns only the list items plus another next-page frame if there’s more. This keeps pagination semantics intact (you can still use ?page=2), and it works nicely with caching since each page is a distinct response. The main gotcha is to ensure the lazy frame has a stable id (like next_page) and that the response includes a frame with the same id.