Infinite scroll with django and htmx

https://images.fmacedo.com/infinite_scroll_cover.png

If you used Django and the Django template system before, you might think that to implement something like infinite-scroll you need to write some javascript code and/or to use a frontend framework, such as React or Vue. But that's not true. You can do it with just Django and htmx.

The following example will illustrate this with a set of products. The user will be able to scroll down and see more products every time they reach the end of the page. The products will be loaded from the server using htmx and the response will be rendered using Django templates. No javascript will be written in this post.

You can check the full example code here.

1 - The model

Let's say you have an existing django project like the one here called infinitescroll. You only have one app (core) and a single model, which holds the information of a very generic product:

# core/models.py

class Product(models.Model):
    name = models.CharField(max_length=255)
    price = models.FloatField(null=True, blank=True)
    description = models.TextField(null=True, blank=True)

In the example project there's a quick way to load some fake data in core/management/commands/load-fixtures.py.

To load fake 100 products:

python manage.py load-fixtures

2 - The view

Let's just show a product list in a view. In core/views.py:

# core/views.py

from django.views.generic import ListView
from core.models import Product

class HomeView(ListView):
    model = Product
    template_name = "core/index.html"
    context_object_name = "products"
    paginate_by = 10

and in core/urls.py:

# core/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path("", views.HomeView.as_view(), name="home"),
]

Remember to add core to your INSTALLED_APPS in settings.py and to the urlpatterns in the main infinitescroll/urls.py - check the example repo if you're not sure how to do this.

This will allow the template (core/index.html, which will be shown in the next step) to access your product list using the products variable. It will only show 10 products at a time (defined by the paginate_by). To request the next 10 products, you need to call for the next page, by adding ?page=PAGE_NUMBER to the end of the url. Django will take care of the rest: If PAGE_NUMBER is 2, it will show products 10 to 29. If PAGE_NUMBER is 3, it will show products 20 to 29, and so on.

3 - The template

Let's start by showing the products in our core/index.html page:

 <!-- core/templates/core/index.html -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Infinite Scroll with Django+htmx</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <script src="{% static 'js/htmx.min.js' %}" defer></script>
  </head>
  <body>
    <div>
        {% include 'core/product-list.html' with products=products %}
    </div>
  </body>
</body>
</html>

where core/product-list.html is:

<!-- core/templates/core/product-list.html -->

 {% for product in products %}
    <div>
    <h2>{{ product.name }}</h2>
    <p>{{ product.description }}</p>
    <p>{{ product.price }}</p>
</div>
{% endfor %}

It might look unnecessary to create an extra product-list.html template for this, but it will be necessary for the htmx integration, which will be explained later.

If you jump to http://127.0.0.1:8000 in your browser, you should see the first 10 products, something like this:

If you go to http://127.0.0.1:8000?page=2 you will see products 10 to 19 and so on. This is great, but we want to load the next 10 products when the user reaches the end of the page, and not by changing the url in the browser address bar. To do this, we need to add htmx first.

4 - Adding htmx

According to htmx docs, a quick way to use htmx in your project is by adding it's minified version to your static files. You can download the minified version from here and save it in static/js/htmx.min.js. Then, add it to your core/index.html (remember to load the static template tag first):

<!-- core/templates/core/index.html -->

<!-- load static -->
{% load static %}

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Infinite Scroll with Django+htmx</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">

    <!-- add htmx to the page -->
    <script src="{% static 'js/htmx.min.js' %}" defer></script>

  </head>
  <body>
    <div>
        {% include 'core/product-list.html' with products=products %}
    </div>
  </body>
</body>
</html>

You should also use the django-htmx package, for easy integration with the django views. Just make sure you install it, add it to the installed apps and to the middleware, in settings.py:

INSTALLED_APPS = [
    ...,
    "django_htmx",
    ...,
]

...

MIDDLEWARE = [
    ...,
    "django_htmx.middleware.HtmxMiddleware",
    ...,
]

Check the demo repo if you have questions.

5 - Adding the infinite scroll

Now that you have htmx installed in your project, let's first describe what we want to achieve in plain english:

Every time a user reaches the end of the page I want to get the next 10 products from the server and put them in the end of the page.

To translate this to htmx and django lingo, we can split it into 4 steps:

  1. Tell htmx when to trigger this request with the hx-trigger attribute
  2. Get the next 10 products with the hx-get attribute
  3. Tell htmx what to do with this response with the hx-swap attribute
  4. Ensure the adequate response exists in our django view.

1 - Tell htmx when to trigger this request

To trigger a request when the user reaches the end of the page, we should add this to the last rendered element, which in our case is the last product that is rendered in each page. In core/product-list.html:

<!-- core/templates/core/product-list.html -->

{% for product in products %}
  {% if forloop.last %}
    <div
        hx-trigger="revealed"
    >
  {% else %}
    <div>
  {% endif %}
      <h2>{{ product.name }}</h2>
      <p>{{ product.description }}</p>
      <p>{{ product.price }}</p>
    </div>
{% endfor %}

This will tell htmx to trigger a request whenever this div is revealed: When the user scrolls down and the last product is visible, htmx will trigger some request to the server. But what request?

2 - set an hx-get attribute in the element to get the next 10 products

In the same div, we need to add the hx-get attribute:

<!-- core/templates/core/product-list.html -->

{% for product in products %}
  {% if forloop.last %}
    <div
        hx-trigger="revealed"
        hx-get="{% url 'core:home' %}?page={{ page_obj.number|add:1 }}"
    >
  {% else %}
    <div>
  {% endif %}
      <h2>{{ product.name }}</h2>
      <p>{{ product.description }}</p>
      <p>{{ product.price }}</p>
    </div>
{% endfor %}

This will tell htmx to make a GET request to the home page (the same page that we are currently on) but asking for the the next 10 products. The page_obj django variable is available in the template since we are using the paginate_by in a ListView.

3 - Tell htmx what to do with this response

So far, we are able to call the view for the next page data, but we are not telling htmx where to render it's response. In our case, we want to render the response after the last product, so we can use htmx hx-swap attribute to inject our response after the current element (afterend). So, again in core/product-list.html:

<!-- core/templates/core/product-list.html -->

{% for product in products %}
  {% if forloop.last %}
    <div
        hx-trigger="revealed"
        hx-get="{% url 'core:home' %}?page={{ page_obj.number|add:1 }}"
        hx-swap="afterend"
    >
  {% else %}
    <div>
  {% endif %}
      <h2>{{ product.name }}</h2>
      <p>{{ product.description }}</p>
      <p>{{ product.price }}</p>
    </div>
{% endfor %}

4 - Give htmx something to render

Now that we have the frontend logic implemented, we just need to tell django what to do. Since we have split our product rendering logic into a different template (product-list.html), we just need to tell django to render that same template whenever the request comes from htmx. We don't need to mess with the request object, since it already comes with the correct page request and, therefore, with the correct products queryset.

In our core/views.py, we can override the get_template_names method, to render the correct template:

# core/views.py

from django.views.generic import ListView
from core.models import Product

class HomeView(ListView):
    model = Product
    template_name = "core/index.html"
    context_object_name = "products"
    paginate_by = 10
    ordering = "pk"

    # new method added ⬇️
    def get_template_names(self, *args, **kwargs):
        if self.request.htmx:
            return "core/product-list.html"
        else:
            return self.template_name

The htmx attribute is added to the request by the django-htmx package. We are basically saying:

If the request comes from htmx, render the product-list.html template with the products that are in the request. Otherwise, render the index.html template as usual.

Jump back to http://127.0.0.1:8000 and try it out. You should be able to scroll down and see more products every time you reach the end of the page.

Feel free to contact me if you have any questions!


Click here to share this article with your friends on X if you liked it.