The letter A styled as Alchemists logo. lchemists
Published March 1, 2026 Updated March 1, 2026
Cover
htmx Infinite Scroll

Pagination is always a feature you have to tackle in web application development in one form or another. A common approach is to use a gem, like Pagy, to provide the following capabilities:

  • Load records in batches (a.k.a. pages).

  • Previous and next links.

  • Numbered links to specific pages.

  • Bookmarkable links complete with browser history.

Definitely useful to decrease server load since you’re only loading a small subset of records per request. The problem with this approach is:

  • Most people bail after reading through the first couple of pages and not finding what they want.

  • Includes a lot visual information, design-wise, that not only consumes real-estate within your UI but is seldom utilized.

We can do better. With infinite scrolling, we can provide a better user experience through a killer combination of search coupled with endless scroll. This allows folks to:

  • Endlessly scroll until they find what they want or bail when coming up empty after a page or two.

  • Search plus scroll to further refine the results.

Let’s explore building a gallery of photos for browsing and searching by using an API client to obtain images from a remote resource. We’ll do this by using htmx to provide an infinite scrolling experience.

Setup

To implement this functionality, add the htmx library and the HTMX gem to your stack. Both are compatible with whatever Ruby web framework you decide to use.

Additionally, the htmx team has an example if you need a visual primer. When you inspect the example’s DOM, you’ll see the following:

<tr hx-get="/contacts/?page=2" hx-trigger="revealed" hx-swap="afterend" data-hx-revealed="true">
  <td>Agent Smith</td>
  <td>void29@null.org</td>
  <td>A0GG98CEAC</td>
</tr>

This breaks down as follows:

  • hx-get: Contains the HTTP request for the next page of information.

  • hx-trigger: Triggers the GET request when the element is revealed in the viewport.

  • hx-swap: Describes swap behavior. In this case, new content from the HTTP response will be swapped in after the target element (i.e. the tr you see above).

That’s a lot of power for only a few lines of HTML!

Implementation

Our implementation will consist of a Hanami action, view, and template. For htmx, you can load either via a CDN, Node package, or an import map. The following sections will focus on the Ruby code.

Action

Now that you have an idea of the desired functionality on the frontend, let’s discuss how to implement this on the backend. For demonstration purposes, we’ll use a Hanami Actions index action. If using a different framework, the Ruby code should be trivial to adapt for your own implementation. Here’s the entire action:

Implementation
require "initable"

module Demo
  module Actions
    module Gallery
      class Index < Hanami::Action
        include Deps[:htmx, :client]
        include Initable[model: Demo::Models::Photo]

        params do
          optional(:query).filled :string
          optional(:page).filled :integer
        end

        def handle request, response
          parameters = request.params

          load(parameters).either -> photo { render request, photo, response },
                                  -> message { render_error parameters, message, response }
        end

        private

        def load parameters
          case parameters
            in query:, page: then client.photos(search: query, page:)
            in query: then client.photos search: query
            in page: then client.photos(page:)
            else client.photos
          end
        end

        def render request, photo, response
          query, page = request.params.to_h.values_at :query, :page

          if htmx.request(**request.env).request?
            htmx.response! response.headers,
                           push_url: routes.path(:gallery, query:, page:)
            response.render view, photo:, query:, page:, layout: false
          else
            response.render view, photo:, query:, page:
          end
        end

        def render_error parameters, message, response
          response.flash.now[:alert] = message
          response.render view, photo: model.empty, **parameters.to_h.slice(:query, :page)
        end
      end
    end
  end
end

Let’s explore each section/method of the action since this is worth explaining further. First, we start by injecting the htmx, API client, and model dependencies. We’ll use an API client to scroll through photos which has pagination built-in. We don’t need to understand the client implementation, only the interface. Here’s our dependencies:

include Deps[:htmx, :client]
include Initable[model: Demo::Models::Photo]

💡 The Initable gem is used to inject the model dependency without having to write an initializer since we only need access to the constant (we’ll make use of the model, later, when rendering errors).

Due to our index action being a HTTP GET request we can allow the following optional parameters and type casting:

params do
  optional(:query).filled :string
  optional(:page).filled :integer
end

The query parameter allows us to search for specific records while the page parameter limits the search results by page. Now we can handle the request and response:

def handle request, response
  parameters = request.params

  load(parameters).either -> photo { render request, photo, response },
                          -> message { render_error parameters, message, response }
end

The API client answers a monad (i.e. Dry Monads), so we’ll either have a Success or Failure which is an elegant way to handle the inevitable fork in the road when making a HTTP request that may pass or fail.

Next, we can leverage the power of Ruby Pattern Matching to determine what request we’ll make of our API client (ℹ️ All methods are private from this point forward):

def load parameters
  case parameters
    in query:, page: then client.photos(search: query, page:)
    in query: then client.photos search: query
    in page: then client.photos(page:)
    else client.photos
  end
end

Notice how pattern matching makes short work of how we construct our API request based on what parameters are supplied. The result of any of these requests will answer a monad that is either a Success or Failure. If you refer to the #handle method again, this means we’ll either render the HTML response or an error as implemented via these private methods. Example:

def render request, photo, response
  query, page = request.params.to_h.values_at :query, :page

  if htmx.request(**request.env).request?
    htmx.response! response.headers,
                   push_url: routes.path(:gallery, query:, page:)
    response.render view, photo:, query:, page:, layout: false
  else
    response.render view, photo:, query:, page:
  end
end

def render_error parameters, message, response
  response.flash.now[:alert] = message
  response.render view, photo: model.empty, **parameters.to_h.slice(:query, :page)
end

This flow breaks down as follows:

  • Success

    • If a htmx request, add the appropriate headers (especially the push_url so browser history is preserved) and render without any layout information because you only want the HTML fragment — not the entire page — in order to keep your response as small as possible.

    • Otherwise, when not a htmx request, render the full page as you’d normally do.

  • Failure

    • Render the page with a flash message as to what went wrong along with an empty photo (i.e. no results found). This is made possible by the model dependency we injected earlier by sending the .empty message which answers an empty record which leverages the power of the Null Object Pattern.

View

For the view, we only need to expose the photo, query, and page to the template. This is done as follows:

module Demo
  module Views
    module Gallery
      class Index < Hanami::View
        expose :photo
        expose :query, decorate: false
        expose :page, decorate: false
      end
    end
  end
end

By default, Hanami will decorate all parts but for the query and page we only need the primitives.

Template

For the template, we only need to iterate over the photos and render each. Only when there are no photos do we need to display that none were found as shown below:

<% if photo.data.any? %>
  <ul>
    <% photo.data.each do |item| %>
      <%= render :item, item: %>
    <% end %>

    <% if photo.meta.more? %>
      <%= tag.li **HTMX[
                   get: routes.path(:gallery, page: photo.meta.next_page, query:),
                   select: ".card",
                   trigger: "revealed",
                   swap: "this",
                   push_url: true
                 ] do %>
        Loading...
      <% end %>
    <% end %>
  </ul>
<% else %>
  <div>
    <%= render "shared/empty", message: "No photos found." %>
  </div>
<% end %>

With the above we leverage the HTMX gem to build the htmx attributes for us. Additionally, use of the htmx select, swap attributes allow you to only select card element in the HTTP response then immediately swap the current element (i.e. this) with the new element. This not only keeps your DOM clean but ensures you are always using list items because the HTML specification doesn’t allow any other HTML element within a ordered or unordered list. To illustrate further, here’s what the resulting HTML looks like:

<ul>
  <li id="1" class="card">...</li>
  <li id="2" class="card">...</li>
  <li id="3" class="card">...</li>
  <li class="card"
      hx-get="/gallery?page=2&query=landscapes"
      hx-select=".card"
      hx-trigger="revealed"
      hx-swap="this"
      hx-push-url="true">
    Loading...
  </li>
</ul>

This means you can scroll down the page — and when the li element with the htmx attributes is revealed — a new HTTP request is made to replace that element and pull in new data ending with another li element with a hx-get request for the next page.

You also get a nice "Loading…​" message while waiting for the HTTP request to complete. For bonus points, you could swap out the text message with a CSS loader class to enhance the user experience further.

Conclusion

Reducing the use of JavaScript within your application is a good in terms speed, reduced page size, and reduced complexity in your application. With htmx you are able to focus on HTML which is much easier to write and test. More importantly, you get all of the benefits of pagination, browser history, and a cleaner user interface. Enjoy!