Pagination

Most UIs that display a list of user-created data will grow to the point that they should paginate their data.

Overview

With the introduction of HTMX and Alpine as preferred front-end libraries in late 2024, and phasing out Knockout beginning in 2025, newer pages using paginated data should rely on HTMX + django-tables2 for handling pagination moving forward.

HQ has previously relied on a custom pagination component that uses Knockout Components. See pagination.js for full documentation. We will cover that example here, as it's still relevant to understand older code using this component.

Pagination with HTMX and Django Tables

As covered in the HTMX + Alpine.js guide, HTMX works by sending partial HTML responses to asynchronous requests triggered by special hx- attributes added to buttons, forms, links, and other elements.

To assist with rendering these partial HTML responses, we use django-tables2 to paginate, sort, and format tabular data.

An Example

See it in action: Open demo

Here is an example of what this solution looks like in practice. We’ll go over its components in the sections below. Please also review the inline comments in the code examples.

Table Definition

First, we begin with the table definition—ExampleFakeDataTable in this example. It subclasses BaseHtmxTable, which itself inherits from django-tables2's Table. This class defines the table's columns, their display names, and the template / styling used to render rows.

The column attribute names match the keys provided by generate_example_pagination_data() (see below), and the verbose_name specifies the label shown to the user. The template and default styling are provided by BaseHtmxTable.Meta, so most use cases only need to define columns when starting from BaseHtmxTable.

Python
from django.utils.translation import gettext_lazy
from django_tables2 import columns

from corehq.apps.hqwebapp.tables.htmx import BaseHtmxTable


class ExampleFakeDataTable(BaseHtmxTable):
    """
    This defines the columns for the table rendered by `ExamplePaginatedTableView`.

    The variable names for each column match the keys available in
    `generate_example_pagination_data` below, and the `verbose_name` specifies
    the name shown to the user.

    We are using the `BaseHtmxTable` parent class and defining its Meta class
    below based on `BaseHtmxTable.Meta`, as it provides some shortcuts
    and default styling for our use of django-tables2 with HTMX.
    """
    class Meta(BaseHtmxTable.Meta):
        pass

    name = columns.Column(
        verbose_name=gettext_lazy("Name"),
    )
    color = columns.Column(
        verbose_name=gettext_lazy("Color"),
    )
    big_cat = columns.Column(
        verbose_name=gettext_lazy("Big Cats"),
    )
    dob = columns.Column(
        verbose_name=gettext_lazy("Date of Birth"),
    )
    app = columns.Column(
        verbose_name=gettext_lazy("Application"),
    )
    date_opened = columns.Column(
        verbose_name=gettext_lazy("Opened On"),
    )
    owner = columns.Column(
        verbose_name=gettext_lazy("Owner"),
    )
    status = columns.Column(
        verbose_name=gettext_lazy("Status"),
    )

Table View

Next we have the table view, ExamplePaginatedTableView. This view renders a single page of ExampleFakeDataTable based on GET parameters and a queryset.

Since we are using HTMX, ExamplePaginatedTableView only returns a partial template: the table itself plus page navigation and page size selection—no outer layout or navigation. The SelectablePaginatedTableView parent class handles page size selection and saving that choice in a cookie. It inherits from django-tables2's classes and mixins, which handle pagination within a given queryset.

Python
from corehq.apps.hqwebapp.tables.pagination import SelectablePaginatedTableView
from corehq.apps.styleguide.examples.bootstrap5.htmx_pagination_data import generate_example_pagination_data
from corehq.apps.styleguide.examples.bootstrap5.htmx_pagination_table import ExampleFakeDataTable


class ExamplePaginatedTableView(SelectablePaginatedTableView):
    """
    This view returns a partial template of a table, along with its
    page controls and page size selection. Its parent classes handle
    pagination of a given queryset based on GET parameters in the request.

    This view will be fetched by the "host" `HtmxPaginationView`
    via an HTMX GET request.
    """
    urlname = "styleguide_b5_paginated_table_view"
    table_class = ExampleFakeDataTable

    def get_queryset(self):
        return generate_example_pagination_data(100)

For this styleguide example, the queryset is just an in-memory list of dicts (generated below). In a real project you would typically return a Django QuerySet here instead.

Python
from corehq.apps.prototype.utils import fake_data
from corehq.util.quickcache import quickcache


@quickcache(['num_entries'])
def generate_example_pagination_data(num_entries):
    """
    This function just generates some fake data made to look somewhat like a CommCare Case.
    """
    rows = []
    for row in range(0, num_entries):
        rows.append({
            "name": f"{fake_data.get_first_name()} {fake_data.get_last_name()}",
            "color": fake_data.get_color(),
            "big_cat": fake_data.get_big_cat(),
            "dob": fake_data.get_past_date(),
            "app": fake_data.get_fake_app(),
            "status": fake_data.get_status(),
            "owner": fake_data.get_owner(),
            "date_opened": fake_data.get_past_date(months_away=[0, 1, 2, 3]),
        })
    return rows

Host View

Lastly, we have the host view, HtmxPaginationView. This view renders the full page shell (navigation, layout, scripts) and “hosts” the partial HTML returned by ExamplePaginatedTableView.

Python
from django.utils.decorators import method_decorator
from django.urls import reverse

from corehq.apps.hqwebapp.decorators import use_bootstrap5
from corehq.apps.hqwebapp.views import BasePageView


@method_decorator(use_bootstrap5, name='dispatch')
class HtmxPaginationView(BasePageView):
    """
    This view hosts the Django Tables `ExamplePaginatedTableView`.
    """
    urlname = "styleguide_b5_htmx_pagination_view"
    template_name = "styleguide/bootstrap5/examples/htmx_pagination.html"

    @property
    def page_url(self):
        return reverse(self.urlname)

Its template (shown below) sets up the JavaScript context and inherits from the appropriate CommCare HQ base template. A <div> issues an initial hx-get request to ExamplePaginatedTableView on page load (hx-trigger="load"). Subsequent requests (page links, page size, etc.) are driven by hx- attributes inside the table template generated by BaseHtmxTable.

Django
{% extends "hqwebapp/bootstrap5/base_navigation.html" %}
{% load hq_shared_tags %}
{% load django_tables2 %}
{% load i18n %}

{# This is the basic entry point for pages using HTMX and Alpine that do not need additional JavaScript: #}
{% js_entry "hqwebapp/js/htmx_and_alpine" %}

{% block content %}
  <div class="container p-5">
    <h1 class="py-3 m-0">
      {% trans "Simple Pagination: HTMX + Django Tables2" %}
    </h1>
    <p>
      {% blocktrans %}
        This button will trigger a refresh on the table once its HTMX request is complete:
      {% endblocktrans %}
      <br />
      <button
        class="btn btn-primary" type="button"
        hx-get="{% url 'styleguide_a_hanging_view' %}"
        {# the attribute below triggers an `hqRefresh` event on `ExampleFakeDataTable` after the button's request is complete #}
        hq-hx-refresh-after="#ExampleFakeDataTable"
        {# we use none for `hx-swap` because the URL from `hx-get` above returns nothing in this example #}
        hx-swap="none"
      >
        <i class="fa fa-refresh"></i> {% trans "Refresh Table After Request" %}
      </button>
    </p>
    <div
      hx-trigger="load"
      {# `hx-get` asynchronously loads the table from HtmxPaginationView on page load, triggered by `hx-trigger` above #}
      {# The `{% querystring %}` template tag below is from django-tables2. It retrieves the GET parameters from the URL to properly initialize the table when they are present. #}
      hx-get="{% url "styleguide_b5_paginated_table_view" %}{% querystring %}"
    >
      <div class="htmx-indicator">
        <i class="fa-solid fa-spinner fa-spin"></i> {% trans "Loading..." %}
      </div>
    </div>
  </div>
{% endblock %}

{% block modals %}
  {# you can either include this template or include an extension of this template to show HTMX errors to the user #}
  {% include "hqwebapp/htmx/error_modal.html" %}
  {{ block.super }}
{% endblock modals %}

The host view can also interact with the table from outside the original container that receives partial HTML from ExamplePaginatedTableView. In this example, the “Refresh Table After Request” button:

  • performs its own HTMX hx-get to a separate URL, and
  • uses hq-hx-refresh-after="#ExampleFakeDataTable" to trigger an hqRefresh event on the table after that request completes.

The table listens for this event and reloads itself via HTMX. In a real page, you could use the same pattern to refresh the table after submitting a filter form, applying bulk actions, or changing configuration elsewhere on the page.

Using Knockout Pagination

Legacy Knockout pattern — kept here for reference only.

Please do not write new code like this. When you next work in this area, consider replacing this example with an equivalent Alpine / HTMX pattern (if appropriate), following the Knockout → Alpine (+HTMX) migration guide , or removing it when no longer needed.

The best way to understand the different ways of using pagination is to see its use in HQ directly. The best sources for this are the Web Users and Mobile Workers pages.

For the Web Users page, the key points are:

An Example

Here is a quick example simulating the pagination that should otherwise be done asynchronously as in the Web Users example above. This is just so you have a visual reference of the pagination widget.

For small, simple UIs, you can leave the "Showing X to Y of Z entries" text and dropdown off by adding inlinePageListOnly: true to the pagination element's params. You can see an example of this on the HQ Dashboard page.
HTML
<div id="js-pagination-example">
  <ul
    class="list-group"
    data-bind="foreach: items"
  >
    <li
      class="list-group-item"
      data-bind="text: $data"
    ></li>
  </ul>
  <pagination
    data-apply-bindings="false"
    params="
      goToPage: goToPage,
      slug: 'style-guide',
      perPage: perPage,
      onLoad: onPaginationLoad,
      totalItems: totalItems
    "
  ></pagination>
</div>
JS
import $ from 'jquery';
import ko from 'knockout';
import _ from 'underscore';
import 'hqwebapp/js/components/pagination';

$(function () {
    let paginationExample = function () {
        let self = {};

        self.items = ko.observableArray();
        self.perPage = ko.observable();

        // Most of the time the widget will only deal with a page of items at a time
        // and goToPage will be an ajax call that will fetch some items and possibly a totalItems value
        self.allItems = _.map(_.range(23), function (i) { return "Item #" + (i + 1); });
        self.totalItems = ko.observable(self.allItems.length);
        self.goToPage = function (page) {
            self.items(self.allItems.slice(self.perPage() * (page - 1), self.perPage() * page));
        };

        // Initialize with first page of data
        self.onPaginationLoad = function () {
            self.goToPage(1);
        };

        return self;
    };

    $("#js-pagination-example").koApplyBindings(paginationExample());
});