HTMX and Alpine

Low JavaScript, high interactivity.

Overview

HTMX and Alpine are two lightweight JavaScript libraries that work well together to add interactivity to a page without a lot of custom JavaScript. Both are driven primarily by HTML attributes, which keeps most behavior close to the markup it affects.

HTMX is particularly well-suited to a Django environment like ours because it expects asynchronous responses to be HTML (instead of JSON). That means our views can return partial templates as responses, and HTMX will swap them into the page. We can lean on Django for rendering and business logic, instead of re-implementing that logic in the browser.

Getting Started

To quickly get started on a new page, you can:

  • Use the js_entry point hqwebapp/js/htmx_and_alpine (no additional configuration required), and
  • Include hqwebapp/htmx/error_modal.html in the modals block of your page.
Django
{% extends "hqwebapp/bootstrap5/base_navigation.html" %}
{% load hq_shared_tags %}

{% js_entry "hqwebapp/js/htmx_and_alpine" %}

{% block content %}
  Do HTMX and Alpine things here!
{% endblock %}

{% block modals %}
  {% include "hqwebapp/htmx/error_modal.html" %}
  {{ block.super }}
{% endblock modals %}

HTMX and Alpine are currently only available on pages using Bootstrap 5. This is intentional: we want to encourage pages to migrate to Bootstrap 5 first, and then add HTMX and Alpine on top.

North Star Goals

These are our long-term default patterns when building or updating pages with HTMX and Alpine. They are guidelines, not strict rules, especially when working in legacy areas of the codebase:

  • Reduce JS complexity and avoid duplicating server-side logic in the browser
  • Rely on Django templates for most HTML and layout
  • Take advantage of Django’s templating features—template inheritance, inclusion tags, filters, and control flow—instead of re-creating loops and conditionals in JavaScript.
  • Use Alpine for light, local interactivity and UI-only state
  • Use HTMX for server-coordinated interactions (forms, tables, filtering, pagination)

HqHtmxActionMixin to Simplify Usage

In practice, many HTMX pages need to send several different actions to the same URL to modify state and/or return an updated partial. Without any structure, each view ends up manually inspecting request headers and branching on different actions, or we create many small views with separate URLs that are hard to keep track of over time.

The HqHtmxActionMixin lets you treat a single view as a collection of named HTMX actions. Each action is a small method on the view, instead of a separate endpoint or a big if...elif block. Mentally, you can think of each @hq_hx_action method as a “mini endpoint” hanging off the view, all sharing the same URL and page context. It lets you:

  • Declare small handler methods (like load_form, submit_form, or mark_item_done)
  • Call them from HTML using hq-hx-action="load_form" and similar attributes
  • Enforce HTTP method checks and basic security via the @hq_hx_action decorator (e.g. POST only requests)
  • Use helper methods for partial responses, no-op responses, and redirects (and more).

How it works (high level)

When an HTMX request includes an hq-hx-action attribute on the triggering element, HQ's frontend code also sends a HQ-HX-Action header with the same value. HqHtmxActionMixin reads that header and routes the request to a matching method on the view:

  1. Include HqHtmxActionMixin as a base class in your TemplateView
  2. Where you use hx-get or hx-post in your code, include the hq-hx-action="some_action" attribute.
  3. In your view, you define a method with the same name (def some_action(...)) and decorate it with @hq_hx_action() (optionally passing an HTTP method like 'post').
  4. HqHtmxActionMixin.dispatch():
    • Looks up some_action on the view
    • Checks that it's decorated with @hq_hx_action
    • Verifies the HTTP method if one was specified
    • Calls the handler and returns its response

If no HQ-HX-Action header is present, the mixin simply falls back to the normal dispatch behavior (i.e., your view works like a regular TemplateView for non-HTMX requests).

Examples

Below are two starter examples working with both HTMX and Alpine:

  • A simple form demo-ing asynchronously loading, validating, and displaying a success message (HTMX) and hiding/showing a field based on the value of another field (Alpine).
  • A more complex view using a todo-list to demo multiple different actions and partials with HTMX, and triggering an inline-editing form with Alpine.

A Simple Form with HTMX and Alpine

In this example, HTMX is used to asynchronously load a form and submit it back to the same view. The form is posted to an hq_hx_action handler on that view, which validates the data and either:

  • returns the form again with validation errors, or
  • shows a success message and resets the form.

Alpine provides a small layer of interactivity on top: it hides the “Value” field when the “Match Type” is set to “is empty”. The Alpine model is defined in the form layout itself, since that logic is specific to this form.

Tips:

  • To view this demo, make sure you are logged in.
  • Leave the “Value” field blank (with a match type that requires a value) to trigger Django form validation via HTMX.
  • Set the “Match Type” field to “is empty” to hide the “Value” field (Alpine).
  • Submit the form with all fields filled out to trigger the success message (HTMX).

The HtmxAlpineFormDemoView uses HqHtmxActionMixin so that it can define two HTMX actions on the same URL using hq-hx-action:

  • load_form — loads the initial empty form when the page first loads.
  • submit_form — handles the POSTed form data, validates it, and returns either the form with errors or a success message with a fresh form.
Python
from django.urls import reverse
from django.utils.decorators import method_decorator

from corehq.apps.domain.decorators import login_required
from corehq.apps.hqwebapp.decorators import use_bootstrap5
from corehq.apps.hqwebapp.views import BasePageView
from corehq.apps.styleguide.examples.bootstrap5.htmx_alpine_form_demo import FilterDemoForm
from corehq.util.htmx_action import HqHtmxActionMixin, hq_hx_action


@method_decorator(login_required, name='dispatch')
@method_decorator(use_bootstrap5, name='dispatch')
class HtmxAlpineFormDemoView(HqHtmxActionMixin, BasePageView):
    urlname = 'sg_htmx_alpine_form_demo'
    template_name = 'styleguide/htmx_alpine_crispy/main.html'
    form_template = 'styleguide/htmx_alpine_crispy/partial_form.html'

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

    def form_context(self, existing_form=None, show_success=False):
        return {
            'filter_form': existing_form or FilterDemoForm(),
            'show_success': show_success,
        }

    @hq_hx_action('get')
    def load_form(self, request, *args, **kwargs):
        """
        HTMX action: load and render the initial form.
        """
        return self.render_htmx_partial_response(
            request,
            self.form_template,
            self.form_context(),
        )

    @hq_hx_action('post')
    def submit_form(self, request, *args, **kwargs):
        """
        HTMX action: handle form submission, validate, and
        re-render the form partial (with errors or success).
        """
        filter_form = FilterDemoForm(request.POST)
        show_success = False
        if filter_form.is_valid():
            # Do something with the form data here.
            show_success = True
            # Reset the form after successful submission.
            filter_form = None

        return self.render_htmx_partial_response(
            request,
            self.form_template,
            self.form_context(
                existing_form=filter_form,
                show_success=show_success,
            ),
        )

HtmxAlpineFormDemoView references two templates:

  • main.html — the full page template. It:
    • extends a core base template,
    • uses the hqwebapp/js/htmx_and_alpine entry point (no extra JS needed for this demo), and
    • sets up an HTMX hx-trigger="load" request to request.path_info (the same URL serving the view), which calls the load_form action.
    Django
    {% extends "hqwebapp/bootstrap5/base_navigation.html" %}
    {% load hq_shared_tags %}
    {% load i18n %}
    
    {# Use the `hqwebapp/js/htmx_and_alpine` entry point to enable HTMX + Alpine without adding any page-specific JavaScript. #}
    {% js_entry "hqwebapp/js/htmx_and_alpine" %}
    
    {% block content %}
      <div
        class="container p-5"
        hx-trigger="load"
        hx-get="{{ request.path_info }}"
        hq-hx-action="load_form"
      >
        {# Shown automatically while HTMX is waiting for the response. #}
        <div class="htmx-indicator">
          <i class="fa-solid fa-spinner fa-spin"></i> {% trans "Loading..." %}
        </div>
      </div>
    {% endblock content %}
    
    {% block modals %}
      {# Include a shared error modal for HTMX errors. You can override or extend this template in your own app if needed. #}
      {% include "hqwebapp/htmx/error_modal.html" %}
      {{ block.super }}
    {% endblock modals %}
    
  • partial_form.html — the partial template returned by the two hq-hx-action handlers. It renders the crispy form and, when appropriate, a success message.
    Django
    {% load i18n %}
    {% load crispy_forms_tags %}
    
    <div id="add-filter-form-container">
      {% if show_success %}
        <div
          class="alert alert-success alert-dismissible fade show"
          role="alert"
        >
          {% blocktrans %}
            Added filter!
          {% endblocktrans %}
          <button
            type="button"
            class="btn-close"
            data-bs-dismiss="alert"
            aria-label="{% trans "Close" %}"
          ></button>
        </div>
      {% endif %}
    
      <form
        hx-post="{{ request.path_info }}"
        hq-hx-action="submit_form"
        hx-target="#add-filter-form-container"
        hx-swap="outerHTML"
        hx-disabled-elt="find button"
      >
        {# Crispy Forms renders the fields and Alpine attributes defined in FilterDemoForm. #}
        {% crispy filter_form %}
      </form>
    </div>
    

Lastly, the Django form FilterDemoForm uses crispy-forms to define the layout. This layout also sets up the Alpine data model and x-* attributes that drive the hide/show "Value" field behavior.

Python
import json

from crispy_forms import bootstrap as twbscrispy
from crispy_forms import layout as crispy
from crispy_forms.helper import FormHelper
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy


class MatchType:
    EXACT = 'exact'
    IS_NOT = 'is_not'
    CONTAINS = 'contains'
    IS_EMPTY = 'is_empty'

    OPTIONS = (
        (EXACT, gettext_lazy('is exactly')),
        (IS_NOT, gettext_lazy('is not')),
        (CONTAINS, gettext_lazy('contains')),
        (IS_EMPTY, gettext_lazy('is empty')),
    )

    MATCHES_WITH_VALUES = (
        EXACT,
        IS_NOT,
        CONTAINS,
    )


class FilterDemoForm(forms.Form):
    slug = forms.ChoiceField(
        label=gettext_lazy('Column'),
        choices=(
            ('name', gettext_lazy('Name')),
            ('color', gettext_lazy('Color')),
            ('desc', gettext_lazy('Description')),
        ),
        required=False,
    )
    match = forms.ChoiceField(
        label=gettext_lazy('Match Type'),
        choices=MatchType.OPTIONS,
        required=False,
        help_text=gettext_lazy("Hint: select 'is empty' to watch the Value field below disappear"),
    )
    value = forms.CharField(label=gettext_lazy('Value'), strip=False, required=False)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.helper = FormHelper()

        # We defer defining the <form> tag to the template, since we want
        # full control over the HTMX attributes (hx-post, hq-hx-action, etc.)
        # near the HTML they affect.
        self.helper.form_tag = False

        # Define the Alpine data model used by the layout below.
        # Keeping this close to the layout makes the form behavior easier to follow.
        alpine_data_model = {
            'match': self.fields['match'].initial,
            'valueMatches': MatchType.MATCHES_WITH_VALUES,
        }

        # Layout: Alpine is used to toggle visibility of the "value" field
        # based on the selected match type.
        self.helper.layout = crispy.Layout(
            crispy.Div(
                'slug',
                crispy.Field(
                    'match',
                    # Initialize the Alpine "match" variable when the field is rendered...
                    x_init='match = $el.value',
                    # ...and keep it in sync with this input via two-way binding.
                    x_model='match',
                ),
                crispy.Div(
                    'value',
                    # Only show the "value" field when the current match
                    # requires a value (defined in valueMatches).
                    x_show='valueMatches.includes(match)',
                ),
                twbscrispy.StrictButton(
                    _('Add Filter'),
                    type='submit',
                    css_class='btn btn-primary',
                ),
                # Bind the Alpine data model defined above to this wrapper <div>.
                x_data=json.dumps(alpine_data_model),
            ),
        )

    def clean(self):
        cleaned_data = super().clean()
        match = cleaned_data.get('match')
        value = cleaned_data.get('value')
        if match in MatchType.MATCHES_WITH_VALUES and not value:
            self.add_error('value', _('Please specify a value.'))
        return cleaned_data

A Single View with Multiple HTMX Actions (To-Do List)

The form demo showed a view with two HTMX actions (load_form, submit_form) on the same URL. The To-Do List demo takes the same idea one step further: a single view with several HTMX actions for different interactions (create, edit, mark done), plus some inline editing behavior powered by Alpine.

In the To-Do List demo, TodoListDemoView mixes in HqHtmxActionMixin. Three methods are decorated with @hq_hx_action('post'): create_new_item, edit_item, and mark_item_done.

Python
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _

from corehq.apps.domain.decorators import login_required
from corehq.apps.hqwebapp.decorators import use_bootstrap5
from corehq.apps.hqwebapp.views import BasePageView
from corehq.apps.prototype.models.cache_store import CacheStore
from corehq.util.htmx_action import HqHtmxActionMixin, hq_hx_action


@method_decorator(login_required, name='dispatch')
@method_decorator(use_bootstrap5, name='dispatch')
class TodoListDemoView(HqHtmxActionMixin, BasePageView):
    """
    Demonstrates using HqHtmxActionMixin with a view to provide
    HTMX responses for multiple actions via the `hq-hx-action` attribute.
    """

    urlname = 'sg_htmx_todo_list_example'
    template_name = 'styleguide/htmx_todo/main.html'

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

    @property
    def page_context(self):
        # Initial page context: all items (done + not done)
        return {
            'items': self.get_items(),
        }

    def get_items(self):
        return TodoListStore(self.request).get()

    def save_items(self, items):
        TodoListStore(self.request).set(items)

    def update_item(self, item_id, name=None, is_done=None):
        """
        Update a single item by ID, optionally changing its name and/or done flag.
        Returns the updated item.
        """
        items = self.get_items()
        for item in items:
            if item['id'] == item_id:
                item['name'] = name if name is not None else item['name']
                item['is_done'] = is_done if is_done is not None else item['is_done']
                self.save_items(items)
                return item

    def render_item_response(self, request, item):
        """
        Return the appropriate partial template for a single item,
        based on whether it's done or not.
        """
        template = (
            'styleguide/htmx_todo/item_done_oob_swap.html' if item['is_done']
            else 'styleguide/htmx_todo/item.html'
        )
        context = {'item': item}
        return self.render_htmx_partial_response(request, template, context)

    # We can now reference `hq-hx-action="create_new_item"`
    # alongside an `hx-post` to this view URL.
    @hq_hx_action('post')
    def create_new_item(self, request, *args, **kwargs):
        items = self.get_items()
        new_item = {
            'id': len(items) + 1,
            'name': _('New Item'),
            'is_done': False,
        }
        items.insert(0, new_item)
        self.save_items(items)
        return self.render_item_response(request, new_item)

    @hq_hx_action('post')
    def edit_item(self, request, *args, **kwargs):
        item = self.update_item(
            int(request.POST['itemId']),
            name=request.POST['newValue'],
        )
        return self.render_item_response(request, item)

    @hq_hx_action('post')
    def mark_item_done(self, request, *args, **kwargs):
        item = self.update_item(
            int(request.POST['itemId']),
            is_done=True,
        )
        return self.render_item_response(request, item)


class TodoListStore(CacheStore):
    """
    CacheStore is a helpful prototyping tool when you need to store
    data on the server side for prototyping HTMX views.

    Caution: Please don't use this for real features.
    """

    slug = 'styleguide-todo-list'
    initial_value = [
        {
            'id': 1,
            'name': 'get coat hangers',
            'is_done': False,
        },
        {
            'id': 2,
            'name': 'water plants',
            'is_done': False,
        },
        {
            'id': 3,
            'name': 'Review PRs',
            'is_done': False,
        },
    ]

These methods act like small, named HTMX endpoints hanging off the same URL. The main template wires them up with HTMX:

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

{# Use this entry point if you don't need to add any extra JavaScript to an HTMX + Alpine page. #}
{% js_entry "hqwebapp/js/htmx_and_alpine" %}

{% block content %}
  <div class="container py-4">
    <h1>
      {% trans "To-Do" %}
      <button
        class="btn btn-primary btn-sm"
        {# request.path_info is just the same URL for this view #}
        hx-post="{{ request.path_info }}"
        {# hq-hx-action triggers the create_new_item method in TodoListDemoView #}
        hq-hx-action="create_new_item"
        {# Insert the returned HTML at the beginning of the #todo-list <ul> #}
        hx-target="#todo-list"
        hx-swap="afterbegin"
      >
        <i class="fa-solid fa-plus"></i> {% trans "Add Item" %}
      </button>
    </h1>

    <ul
      class="list-group"
      id="todo-list"
    >
      {% for item in items %}
        {% if not item.is_done %}
          {% include "styleguide/htmx_todo/item.html" %}
        {% endif %}
      {% endfor %}
    </ul>

    <h2 class="pt-5">
      {% trans "Done" %}
    </h2>
    <ul
      class="list-group"
      id="done-items"
    >
      {% for item in items %}
        {% if item.is_done %}
          {% include "styleguide/htmx_todo/item_done.html" %}
        {% endif %}
      {% endfor %}
    </ul>

    <div class="py-5">
      <button
        class="btn btn-outline-danger"
        type="button"
        {# This posts to a non-existent @hq_hx_action method, intentionally triggering the HTMX error modal. #}
        hx-post="{{ request.path_info }}"
        hq-hx-action="does_not_exist"
      >
        {% trans "Trigger Error" %}
      </button>
    </div>
  </div>
{% endblock content %}

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

Each list item template then combines HTMX calls to those actions with a tiny Alpine model for inline editing:

Django
{% load i18n %}
<li
  id="item-{{ item.id }}"
  class="list-group-item"
  {# the x-data below is from Alpine, and sets up the model for inline editing #}
  x-data='{
    isEditing: false,
    isSubmitting: false,
    itemValue: "{{ item.name }}",
  }'
>
  <div class="d-flex align-items-center fs-4 p-1">
    <input
      class="form-check-input me-3"
      type="checkbox"
      {# request.path_info is just the same URL for this view #}
      {# an alternative would be {% url "sg_htmx_todo_list_example" %} #}
      hx-post="{{ request.path_info }}"
      {# hq-hx-action triggers the mark_item_done method in TodoListDemoView #}
      hq-hx-action="mark_item_done"
      {# hx-vals is a handy way to send extra JSON-formatted data with the request #}
      hx-vals='{
        "itemId": {{ item.id }}
      }'
      {# the next two lines say replace the entire content of the target in hx-target #}
      hx-swap="outerHTML"
      hx-target="#item-{{ item.id }}"
      {# :disabled is shorthand for Alpine's x-bind:disabled #}
      :disabled="isEditing || isSubmitting"
    >

    {# the element below defines what the text looks like when not in edit mode #}
    <div
      class="pt-1"
      {# here we react to the value of isEditing from the alpine model set up in the parent <li> element #}
      x-show="!isEditing"
      {# @dblclick is shorthand for Alpine's x-on:dblclick #}
      @dblclick="isEditing = !isSubmitting"
    >
      <span>{{ item.name }}</span>
      <button
        class="btn btn-link btn-sm inline-edit-action"
        type="button"
        @click="isEditing = true"
        :disabled="isSubmitting"
      >
        <i class="fa fa-pencil"></i>
        <span class="visually-hidden">
          {% trans "Edit Value" %}
        </span>
      </button>
    </div>

    {# the element below defines what the inline edit looks like in edit mode #}
    <div x-show="isEditing">
      <form
        hx-post="{{ request.path_info }}"
        hq-hx-action="edit_item"
        hx-vals='{
          "itemId": {{ item.id }}
        }'
        {# hx-disabled-elt ensures that all buttons related to this form are disabled on post #}
        hx-disabled-elt="find button"
        hx-swap="outerHTML"
        hx-target="#item-{{ item.id }}"
      >
        <div class="input-group">
          <input
            type="text"
            class="form-control"
            name="newValue"
            {# x-model creates a two-way binding for the value of itemValue in the alpine model with the value of the input #}
            x-model="itemValue"
          >
          <button
            class="btn btn-primary"
            type="submit"
            @click="isSubmitting = true"
          >
            <i class="fa fa-check"></i>
          </button>
          <button
            class="btn btn-outline-danger"
            type="button"
            :disabled="isSubmitting"
            @click="isEditing = false"
          >
            <i class="fa fa-close"></i>
          </button>
        </div>
      </form>
    </div>
  </div>
</li>

When an item is marked as done, render_item_response() returns a different partial that uses hx-swap-oob to move the item into the “Done” list:

Django
{# hx-swap-oob inserts the result of this element in the #done-items <ul> instead of the #todo-list <ul> #}
<ul hx-swap-oob="afterbegin:#done-items">
  {% include "styleguide/htmx_todo/item_done.html" %}
</ul>

This pattern (one view + several @hq_hx_action methods) works well for medium-complexity pages that have a few related interactions but don’t yet need to be split into multiple views.

Organization for Complex Pages

For very complex pages, a single view with many HTMX actions can still become hard to reason about. In those cases, it can be helpful to split the page into multiple section views:

  • A single host view renders the overall page layout, navigation, common context, and JavaScript entry point.
  • Several section views each manage one part of the page. Each section view has its own URL and its own HTMX actions.

The host view passes section view URLs into the template via context, and the template uses those URLs as hx-get/hx-post targets for different parts of the page.

Example: Bulk Edit Cases (Host View + Section Views)

A concrete production example of this pattern is the bulk data editing UI in corehq.apps.data_cleaning. The BulkEditCasesSessionView acts as the host view for the page, and several section views handle individual areas of the UI.

In BulkEditCasesSessionView.page_context, the host view exposes the URLs of its section views.

Each of these URLs points to a section view responsible for one part of the page:

Each section view follows the same patterns described earlier: TemplateView + HqHtmxActionMixin, with one or more @hq_hx_action methods to handle interactions for that section.

The end result is a page that feels like a single cohesive UI, but where each section has its own view class, URL, and HTMX actions. This keeps each piece manageable and testable, while still creating the feeling of a cohesive "full-page app" with the host view tying everything together. You get a feeling of using React, but Django is actually at the core!

Multi-Step Flows with next_action

Sometimes you need a short multi-step flow made of one or more forms: pick something, confirm what to do with it, then either finish or go back. You could model this as a single "wizard" form with a step field, but often it's clearer to give each step its own form and handler.

With HTMX and HqHtmxActionMixin, you can keep this entirely server-driven by using a simple next_action pattern:

  • One view class, with multiple @hq_hx_action methods.
  • A shared partial that posts to the same URL, but uses hq-hx-action="".
  • Each handler validates its form and either:
    • returns a “next step” form with a new next_action, or
    • returns a final success / summary state.

Example: Simple Two-Step “Choose & Confirm” Flow

This demo shows a tiny two-step flow:

  1. Choose a favorite fruit.
  2. Confirm the choice or go back and change it.

The view SimpleNextActionDemoView uses three HTMX actions:

  • load_first_step — load the initial form on page load.
  • validate_choice — validate the chosen fruit and move to the confirm step.
  • confirm_or_change — either finish or go back to step 1.
Python
from django.urls import reverse
from django.utils.decorators import method_decorator

from corehq.apps.domain.decorators import login_required
from corehq.apps.hqwebapp.decorators import use_bootstrap5
from corehq.apps.hqwebapp.views import BasePageView
from corehq.apps.styleguide.examples.bootstrap5.htmx_next_action_simple_forms import (
    ChooseFruitForm,
    ConfirmFruitChoiceForm,
)
from corehq.util.htmx_action import HqHtmxActionMixin, hq_hx_action


@method_decorator(login_required, name='dispatch')
@method_decorator(use_bootstrap5, name='dispatch')
class SimpleNextActionDemoView(HqHtmxActionMixin, BasePageView):
    """
    A minimal example of a multi-step flow using the `next_action` pattern.

    Step 1: Choose a fruit.
    Step 2: Confirm the choice or go back to change it.
    """

    urlname = 'sg_htmx_next_action_demo'
    template_name = 'styleguide/htmx_next_action_simple/main.html'
    form_template = 'styleguide/htmx_next_action_simple/next_step_with_message.html'
    container_id = 'simple-next-action'

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

    @property
    def page_context(self):
        # The initial full-page render (non-HTMX) doesn’t need the form;
        # the container will fetch it via HTMX on load.
        return {
            'container_id': self.container_id,
        }

    def _step_context(self, form=None, next_action=None, message=None):
        """
        Shared context for all steps.
        """
        return {
            'form': form or ChooseFruitForm(),
            'container_id': self.container_id,
            'next_action': next_action or 'validate_choice',
            'message': message,
        }

    @hq_hx_action('get')
    def load_first_step(self, request, *args, **kwargs):
        """
        HTMX action: load the initial "choose fruit" form.
        """
        return self.render_htmx_partial_response(
            request,
            self.form_template,
            self._step_context(),
        )

    @hq_hx_action('post')
    def validate_choice(self, request, *args, **kwargs):
        """
        HTMX action: validate the chosen fruit.

        - If invalid: re-render the same step with errors.
        - If valid: move to the "confirm or change" step.
        """
        form = ChooseFruitForm(request.POST)
        if not form.is_valid():
            return self.render_htmx_partial_response(
                request,
                self.form_template,
                self._step_context(form=form, next_action='validate_choice'),
            )

        fruit = form.cleaned_data['fruit']
        confirm_form = ConfirmFruitChoiceForm(initial={'fruit': fruit})
        return self.render_htmx_partial_response(
            request,
            self.form_template,
            self._step_context(
                form=confirm_form,
                next_action='confirm_or_change',
            ),
        )

    @hq_hx_action('post')
    def confirm_or_change(self, request, *args, **kwargs):
        """
        HTMX action: either finish the flow or go back to step 1.
        """
        form = ConfirmFruitChoiceForm(request.POST)
        if not form.is_valid():
            # Stay on confirm step and show errors
            return self.render_htmx_partial_response(
                request,
                self.form_template,
                self._step_context(form=form, next_action='confirm_or_change'),
            )

        fruit = form.cleaned_data['fruit']
        next_step = form.cleaned_data['next_step']

        if next_step == 'change':
            # Go back to step 1, optionally pre-filling the previous choice
            choose_form = ChooseFruitForm(initial={'fruit': fruit})
            return self.render_htmx_partial_response(
                request,
                self.form_template,
                self._step_context(
                    form=choose_form,
                    next_action='validate_choice',
                ),
            )

        # next_step == "confirm": show a simple success message and reset to step 1
        message = f'Saved your choice: {fruit}'
        return self.render_htmx_partial_response(
            request,
            self.form_template,
            self._step_context(
                form=ChooseFruitForm(),
                next_action='validate_choice',
                message=message,
            ),
        )

The main template sets up the HTMX + Alpine entry point and an empty container that loads the first step via HTMX:

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

{# Use the standard HTMX + Alpine entry point; no extra JS is required. #}
{% js_entry "hqwebapp/js/htmx_and_alpine" %}

{% block content %}
  <div class="container py-4">
    <h1 class="mb-3">
      Favorite Fruit (Next Action Demo)
    </h1>
    <p class="text-muted">
      This demo shows a small multi-step flow using a single view and the <code>next_action</code> pattern.
    </p>

    <div
      id="{{ container_id }}"
      hx-trigger="load"
      hx-get="{{ request.path_info }}"
      hq-hx-action="load_first_step"
    >
      <div class="htmx-indicator">
        <i class="fa-solid fa-spinner fa-spin"></i>
        Loading...
      </div>
    </div>
  </div>
{% endblock content %}

{% block modals %}
  {% include "hqwebapp/htmx/error_modal.html" %}
  {{ block.super }}
{% endblock modals %}

The partial template is intentionally generic, and based on an even simpler version reused elsewhere in HQ (See it here). This template doesn't know about "fruits" or a specific step...it just renders whatever form and next_action the view provides. It does add a message alert in case one of the steps decides to display a message.

Django
{% load i18n %}
{% load crispy_forms_tags %}

<div id="{{ container_id }}" hx-swap="outerHTML">
  {% if message %}
    <div
      class="alert alert-success alert-dismissible fade show mb-3"
      role="alert"
    >
      {{ message }}
      <button
        type="button"
        class="btn-close"
        data-bs-dismiss="alert"
        aria-label="{% trans "Close" %}"
      ></button>
    </div>
  {% endif %}

  <form
    hx-post="{{ request.path_info }}"
    hx-target="#{{ container_id }}"
    hx-disabled-elt="find button"
    hq-hx-action="{{ next_action }}"
  >
    {% crispy form %}
  </form>
</div>

The two forms keep all validation and labels in Django:

Python
from crispy_forms import bootstrap as twbscrispy
from crispy_forms import layout as crispy
from django import forms

from corehq.apps.hqwebapp import crispy as hqcrispy


class ChooseFruitForm(forms.Form):
    fruit = forms.ChoiceField(
        label='Favorite fruit',
        choices=(
            ('apple', 'Apple'),
            ('banana', 'Banana'),
            ('orange', 'Orange'),
        ),
        required=False,
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = hqcrispy.HQFormHelper()
        self.helper.form_tag = False
        self.helper.layout = crispy.Layout(
            crispy.Field(
                'fruit',
            ),
            hqcrispy.FormActions(
                twbscrispy.StrictButton(
                    'Next',
                    type='submit',
                    css_class='btn btn-primary',
                ),
                css_class='mb-0',
            ),
        )

    def clean_fruit(self):
        fruit = self.cleaned_data.get('fruit')
        if not fruit:
            raise forms.ValidationError('Please choose a fruit to continue.')
        return fruit


class ConfirmFruitChoiceForm(forms.Form):
    fruit = forms.CharField(
        widget=forms.HiddenInput,
        required=False,
    )
    next_step = forms.ChoiceField(
        label='What would you like to do?',
        required=False,
        widget=forms.RadioSelect,
        choices=(
            ('confirm', 'Yes, save this choice.'),
            ('change', 'No, go back and change it.'),
        ),
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = hqcrispy.HQFormHelper()
        self.helper.form_tag = False
        self.helper.layout = crispy.Layout(
            'fruit',
            crispy.Field(
                'next_step',
            ),
            hqcrispy.FormActions(
                twbscrispy.StrictButton(
                    'Next',
                    type='submit',
                    css_class='btn btn-primary',
                ),
                css_class='mb-0',
            ),
        )

    def clean_fruit(self):
        fruit = self.cleaned_data.get('fruit')
        if not fruit:
            raise forms.ValidationError('Missing fruit choice. Please go back and choose again.')
        return fruit

    def clean_next_step(self):
        next_step = self.cleaned_data.get('next_step')
        if not next_step:
            raise forms.ValidationError('Please choose an option to continue.')
        return next_step

The end result is a small multi-step flow that:

  • stays entirely server-driven,
  • reuses one HTMX container and one partial template, and
  • keeps each step's logic in its own form + action method.

The only thing the template needs to know is which action name to call next.

Debugging During Development

When developing an HTMX-powered page, it’s often useful to see how the UI behaves when requests are slow or the server is flaky. Browser dev tools throttling only delays the request from the browser to your local server—it does not simulate a slow or unreliable server response. That distinction matters a lot for HTMX, especially when you’re debugging spinners, timeouts, and retry behavior.

To make this easier on views that use HqHtmxActionMixin, you can also mix in HqHtmxDebugMixin. This mixin lets you:

  • Simulate slow responses by setting simulate_slow_response = True (and adjusting slow_response_time in seconds).
  • Simulate intermittent 504 “gateway timeout” errors by setting simulate_flaky_gateway = True.

A typical usage looks like:

Python
from corehq.apps.hqwebapp.views import BasePageView
from corehq.util.htmx_action import HqHtmxActionMixin
from corehq.util.htmx_debug import HqHtmxDebugMixin


class TodoListDemoView(HqHtmxDebugMixin, HqHtmxActionMixin, BasePageView):
    simulate_slow_response = True
    slow_response_time = 3
    simulate_flaky_gateway = False
    ...  # The rest of the view implementation would go here

Important: mixin order matters. Make sure HqHtmxDebugMixin appears before HqHtmxActionMixin so its dispatch() runs first.

You can check out HqHtmxDebugMixin in the codebase for additional documentation and implementation details.

Loading Indicators

By default, when an element triggers an HTMX request, HTMX will automatically add the htmx-request CSS class to that element for the duration of the request. HQ defines some shared styles for this and related states in _htmx.scss, which support the common patterns outlined later in this section.

If you want to create custom loading indicators for non-standard elements (for example, a spinner elsewhere on the page rather than on the trigger itself), you can use the hx-indicator attribute (see docs here).

Buttons

It's often a good idea to pair button-triggered requests with hx-disabled-elt="this" (see docs for hx-disabled-elt), as in the example below, so that the triggering <button> is disabled while the request is in flight.

Django
<button
  class="btn btn-primary" type="button"
  hx-get="{% url 'styleguide_a_hanging_view' %}"
  hx-disabled-elt="this"
  hx-swap="none"  {# example view returns nothing to display #}
>
  Click Me
</button>

Checkboxes

You can also add hx-disabled-elt="this" to checkboxes to reveal the checkbox spinner when a request is in flight.

Django
<div class="form-check">
  <input
    id="id-flex-check-default"
    class="form-check-input"
    type="checkbox"
    value=""
    hx-get="{% url 'styleguide_a_hanging_view' %}"
    hx-disabled-elt="this"
    hx-swap="none"  {# example view returns nothing to display #}
  />
  <label for="id-flex-check-default" class="form-check-label">
    Try to check me
  </label>
</div>

Forms

In the example below, the <form> element is the HTMX trigger, so hx-disabled-elt="find button" is used to disable all of the form’s <button> children while the request is in flight. Because the form is the submitting element, HTMX automatically applies the htmx-request class to it for the duration of the request. Our styles then ensure that any <button type="submit"> inside a submitting form shows a loading indicator during the HTMX request.

Django
<form
  hx-get="{% url 'styleguide_a_hanging_view' %}"
  hx-disabled-elt="find button"
  hx-swap="none"  {# example view returns nothing to display #}
>
  <div class="mb-3">
    <label for="id-value" class="form-label">Value</label>
    <input id="id-value" class="textinput form-control" type="text" name="value" />
  </div>
  <button class="btn btn-primary" type="submit">
    Submit Me!
  </button>
</form>

Table Pagination with HTMX and Django Tables

For paginated tables, HTMX pairs nicely with django-tables2: Django renders each page of results as a partial, and HTMX swaps that partial into the page as the user clicks through pages or changes page size.

We have a separate styleguide example that walks through this pattern end-to-end (table definition, paginated view, and “host” HTMX view), plus a live demo:

If you're building or migrating any paginated tables, start there for concrete patterns you can copy into your own views.