https://alchemists.io/articles/htmx_infinite_scroll
The letter A styled as Alchemists logo. lchemists [ ]
* Articles
* Projects
* Talks
* Books
* Services
* Hire!
* Articles
* htmx Infinite Scroll
* Previous
* Next
Avatar of Brooke wrapped in a grey scarf and wearing a black jacket
with a red zipper against a slightly blurred background of fall
colors of mainly orange leaves. Brooke Kuhlmann
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.
Table of Contents
* Setup
* Implementation
+ Action
+ View
+ Template
* Conclusion
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:
| Agent Smith |
void29@null.org |
A0GG98CEAC |
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? %>
<% 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 %>
<% else %>
<%= render "shared/empty", message: "No photos found." %>
<% 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:
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!
* (c) 2026 Alchemists LLC
+ A white and orange syndication icon. Syndication
+ A white and blue Mastodon icon. Social Media
+ A white and blue email icon. Email
* Culture
+ Oath
+ Rigor
+ Values
+ Mission
+ Community
* Policies
+ Code of Conduct
+ Contributions
+ Developer Certificate of Origin
+ License
+ Security
* About
+ Team
+ Stack
+ Location
+ History