Infinite Scroll with Phlex and Turbo Frames in Rails
Building infinite scroll pagination in Rails has traditionally required custom JavaScript with Intersection Observers, complex Stimulus controllers, or heavy JavaScript frameworks. But with Turbo's lazy-loading frames and Phlex components, we can build a clean, reusable infinite scroll system with almost no JavaScript.
The Stack
- Rails 8+ with Turbo (Hotwire)
- Phlex for view components
- Pagy for pagination
- Turbo Frames with loading: :lazy for automatic fetching
How It Works
The magic is in Turbo's loading: :lazy attribute on frames. When a lazy-loading frame enters the viewport, Turbo automatically fetches its src URL. We use this to:
- Render the initial page with a lazy-loading frame at the bottom
- When the frame becomes visible, Turbo fetches the next page
- The response appends new rows and replaces the frame with a new lazy-loading frame for the next page
- Repeat until there are no more pages
The Base Component
First, let's create a reusable base component that handles the Turbo Stream logic:
# app/components/infinite_scroll_stream.rb module Components class InfiniteScrollStream < Components::Base include Phlex::Rails::Helpers::TurboFrameTag include Phlex::Rails::Helpers::TurboStream attr_reader :items, :pagy def initialize(items:, pagy:) @items = items @pagy = pagy end def view_template # Append new rows to the table body turbo_stream.append(table_body_id) do render_rows end # Replace the frame with a new lazy frame, or remove it when done if pagy&.next turbo_stream.replace(frame_id) do turbo_frame_tag(frame_id, src: next_page_url, loading: :lazy) do render_loading_indicator end end else turbo_stream.remove(frame_id) end end private # Subclasses must implement these methods def table_body_id = raise NotImplementedError def frame_id = raise NotImplementedError def next_page_url = raise NotImplementedError def render_row(_item) = raise NotImplementedError def render_rows items.each { |item| render_row(item) } end def render_loading_indicator div(class: "flex justify-center py-8") do div(class: "flex items-center gap-2 text-gray-500") do span(class: "loading loading-spinner loading-md") span { "Loading more..." } end end end end end
Blog Post Example
Let's build infinite scroll for a blog posts listing.
The Stream Component
# app/views/posts/infinite_scroll_stream.rb module Views module Posts class InfiniteScrollStream < Components::InfiniteScrollStream def initialize(posts:, pagy:) super(items: posts, pagy:) end private def table_body_id = "posts-list" def frame_id = "posts_load_more" def next_page_url = posts_path(page: pagy.next, format: :turbo_stream) def render_row(post) article(class: "border-b border-gray-200 py-6") do header(class: "mb-2") do h2(class: "text-xl font-semibold") do a(href: post_path(post), class: "hover:text-blue-600") { post.title } end p(class: "text-sm text-gray-500") do "#{post.author.name} · #{post.published_at.strftime('%B %d, %Y')}" end end p(class: "text-gray-700 line-clamp-3") { post.excerpt } footer(class: "mt-3") do a(href: post_path(post), class: "text-blue-600 hover:underline") do "Read more →" end end end end end end end
The Index Component
The initial page needs to render the first batch of posts and include the lazy-loading frame:
# app/views/posts/index.rb module Views module Posts class Index < Views::Base include Phlex::Rails::Helpers::TurboFrameTag def initialize(posts:, pagy:) @posts = posts @pagy = pagy end def view_template div(class: "max-w-3xl mx-auto px-4 py-8") do h1(class: "text-3xl font-bold mb-8") { "Blog" } # Container for posts with an ID for Turbo Stream appends div(id: "posts-list") do @posts.each do |post| render_post(post) end end # Lazy-loading frame for the next page render_load_more_frame if @pagy.next end end private def render_post(post) article(class: "border-b border-gray-200 py-6") do header(class: "mb-2") do h2(class: "text-xl font-semibold") do a(href: post_path(post), class: "hover:text-blue-600") { post.title } end p(class: "text-sm text-gray-500") do "#{post.author.name} · #{post.published_at.strftime('%B %d, %Y')}" end end p(class: "text-gray-700 line-clamp-3") { post.excerpt } footer(class: "mt-3") do a(href: post_path(post), class: "text-blue-600 hover:underline") do "Read more →" end end end end def render_load_more_frame turbo_frame_tag( "posts_load_more", src: posts_path(page: @pagy.next, format: :turbo_stream), loading: :lazy ) do div(class: "flex justify-center py-8") do div(class: "flex items-center gap-2 text-gray-500") do span(class: "loading loading-spinner loading-md") span { "Loading more..." } end end end end end end end
The Controller
The controller handles both HTML (initial page) and Turbo Stream (subsequent pages) requests:
# app/controllers/posts_controller.rb class PostsController < ApplicationController POSTS_PER_PAGE = 10 def index @pagy, @posts = pagy( Post.published.includes(:author).order(published_at: :desc), limit: POSTS_PER_PAGE ) respond_to do |format| format.html do render Views::Posts::Index.new(posts: @posts, pagy: @pagy) end format.turbo_stream do render Views::Posts::InfiniteScrollStream.new( posts: @posts, pagy: @pagy ), layout: false end end end end
How the Flow Works
- Initial page load: User visits /posts, controller renders Views::Posts::Index with the first 10 posts and a lazy-loading frame pointing to /posts.turbo_stream?page=2
- User scrolls down: When the loading indicator enters the viewport, Turbo automatically fetches /posts.turbo_stream?page=2
- Turbo Stream response: The server responds with two Turbo Stream actions:
- turbo_stream.append("posts-list") - Appends the next 10 posts
- turbo_stream.replace("posts_load_more") - Replaces the frame with a new lazy frame for page 3
- Repeat: Steps 2-3 repeat until there are no more pages
- End of content: When there's no next page, we use turbo_stream.remove("posts_load_more") to remove the loading indicator
Adding Search Support
To support search queries that persist across pages:
# app/views/posts/infinite_scroll_stream.rb module Views module Posts class InfiniteScrollStream < Components::InfiniteScrollStream def initialize(posts:, pagy:, search_query: nil) super(items: posts, pagy:) @search_query = search_query end private def next_page_url params = { page: pagy.next, format: :turbo_stream } params[:q] = @search_query if @search_query.present? posts_path(params) end # ... rest of the implementation end end end
Benefits of This Approach
- No custom JavaScript: Turbo handles all the fetching and DOM updates
- Progressive enhancement: Works without JavaScript (falls back to regular pagination)
- Reusable pattern: The base component can be used for any list/table
- Clean separation: Each stream component only knows about its own domain
- Type-safe views: Phlex gives us Ruby's type checking and IDE support
Gotchas
- Remember layout: false: Turbo Stream responses should not include the application layout
- Use .turbo_stream format: The URL must request the turbo_stream format
- Unique IDs: Each infinite scroll on a page needs unique table_body_id and frame_id values
- Include the right helpers: Don't forget Phlex::Rails::Helpers::TurboFrameTag and Phlex::Rails::Helpers::TurboStream
Conclusion
By combining Turbo's lazy-loading frames with Phlex's component model, we get a clean, maintainable infinite scroll implementation that requires zero custom JavaScript. The base component pattern makes it easy to add infinite scroll to any list in your application.
The key insight is that Turbo Frames with loading: :lazy give us automatic intersection observer behavior for free. We just need to structure our Turbo Stream responses to append content and replace the lazy frame with a new one for the next page.
Testsing