Skip to content

Instantly share code, notes, and snippets.

@RNCTX
Last active October 30, 2025 01:58
Show Gist options
  • Select an option

  • Save RNCTX/760b0a678731b6d43b48127faa831df9 to your computer and use it in GitHub Desktop.

Select an option

Save RNCTX/760b0a678731b6d43b48127faa831df9 to your computer and use it in GitHub Desktop.
Alpine.js + HTMX + Django server-side interactivity starter pack

Alpine.js + HTMX + Django server-side interactivity starter pack

Reasoning for this

There are lots of guides and courses out there on HTMX and Alpine.js separately, but I didn't find many with the particulars of using them together for persistence of instant client-side interactions, so this is an example of such a thing. I've taken a bootstrap 5 theme from one of the marketplace sites, and made one thing in it data driven and supportive of instant user gratification, persisted to the database as user preferences in Django.

Consider the following:

A static_menu.html

Consider the following menu that lets a user choose between light and dark mode:

<div class="app-theme-panel-content">
    <div class="small fw-bold text-inverse mb-1">Display Mode</div>
        <div class="card mb-3">
        <!-- BEGIN card-body -->
            <div class="card-body p-2">
                <div class="row gx-2">
                    <div class="col-6">
                        <a href="javascript:;" data-toggle="theme-mode-selector" data-theme-mode="dark" class="app-theme-mode-link active">
                            <div class="img">
                                <img src="{% static 'assets/img/mode/dark.jpg' %}" class="object-fit-cover" height="76" width="76" alt="Dark Mode">
                            </div>
                            <div class="text">Dark</div>
                        </a>
                    </div>
                    <div class="col-6">
                        <a href="javascript:;" data-toggle="theme-mode-selector" data-theme-mode="light" class="app-theme-mode-link">
                            <div class="img">
                                <img src="{% static 'assets/img/mode/light.jpg' %}" class="object-fit-cover" height="76" width="76" alt="Light Mode">
                            </div>
                            <div class="text">Light</div>
                        </a>
                    </div>
                </div>
            </div>
        <!-- END card-body -->
        <!-- BEGIN card-arrow -->
            <div class="card-arrow">
                <div class="card-arrow-top-left"></div>
                <div class="card-arrow-top-right"></div>
                <div class="card-arrow-bottom-left"></div>
                <div class="card-arrow-bottom-right"></div>
            </div>
        <!-- END card-arrow -->
    </div>
</div>

This HTML uses bootstrap's data-bs attributes to toggle the available values for things like the theme color, the background image, link and notification colors, whether the menus are on the left or right of the page, etc.

That's all fine and good, but outside of the browser's cache the settings don't persist. So let's persist the settings to the user's profile in the Django database.

Step 1: the user model's fields for the settings:

class AdminUser(AbstractUser):
    username = None
    email = models.EmailField(_('email address'), unique=True)
    first_name = models.CharField(_('first name'), max_length=50)
    last_name = models.CharField(_('last name'), max_length=50)
    theme = models.CharField(_('theme'), max_length=50, choices=THEME_CHOICES, default='dark')
    menu_direction = models.CharField(_('menu direction'), max_length=50, choices=MENU_DIRECTION_CHOICES, default='ltr')
    color = models.CharField(_('color'), max_length=50, choices=THEME_COLOR_CHOICES, default='theme-primary')

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['first_name', 'last_name']

    objects = AdminUserManager()

    def __str__(self):
        return self.email

This should be fairly self explanatory to anyone who has created a Django user model before, we have the base requirements for a custom user model with email for the username and three extra fields to hold our theme settings for the user:

  • theme (light or dark mode)
  • menu_direction (left to right or right to left)
  • color (link and notification color)

The defaults we want for the theme can be the database defaults, so new users pick them up to start with (also solves the problem of checking for blanks and nulls, they won't ever be).

Step 2: the Alpine.js bindings

Bootstrap accomplishes clickable toggling of things like dark mode and other such theme changes by data-bs-toggle properties bound to the <html> and <body> elements of the page. When the user makes a selection the toggle function switches a class or data-bs-xxxxxx attribute on the element in question, which applies the change without a page reload, like so:

<html lang="en" data-bs-theme="dark">
 <!-- head element stuff  ... -->
<body class="pace-done app-init theme-red">

We need to modify the <html> element like so, using Alpine's x-data and bind methods, for the two bootstrap toggles that go into the top <html> element:

<html lang="en" x-cloak x-data="{theme: '{{ request.user.theme }}', 
    menuDirection: '{{ request.user.menu_direction }}'}" 
    :data-bs-theme="theme" :dir="menuDirection">
  1. x-data is a syntax basically like a JSON object, just a key / value pair, comma separated if there's more than one.
  2. on initial page load, we're pulling the value of the user's dark mode setting {{ request.user.theme }} from the database value for the user
  3. we're doing the same thing for the user's menu left / right preference, by rendering {{ request.user.menu_direction }} from the database value for the user
  4. the : operators are Alpine bind statements, which match the key in the x-data statement. In effect, this says to Alpine render the value of x-data['theme'] as the value for this HTML element property

Where did I get this from? Pretty easy, since I'm starting with a working template, I just need to observe what happens in the browser dev tools console when toggling the bootstrap toggles. As it turns out there's a dir html property for left to right or right to left on the menu preference, and a data-bs-theme property that changes when you click dark mode or light mode.

As for the link / notification color, I can see at a glance it's in the <body> element's classes, theme-red like so:

<body class="pace-done app-init theme-red">

Therefore, this is what we need for the Alpine binding and x-data on the <body> element:

<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
    id="body" x-data="{color: '{{ request.user.color }}'}" 
    :class="color">

You should notice that we have the CSRF token here. When you do a lot with HTMX in a particular application you will inevitably wind up submitting some random post requests, like we're doing in this guide, that aren't a Django form. As such, an easy way to make sure that the CSRF token is always available is to just put it in the page's <body> element, like the above.

You'll also notice that I put an HTML ID on the body element. HTMX has built-in short-and-sweet syntax for accessing an element by ID, so putting IDs on elements you're working with is the shortest path to making sure you target the right ones.

Step 3: HTMX + Alpine on the clickables

Our clickable elements from the original template above look like this after seasoning with HTMX and Alpine:

{% startpartial color_mode %}
<!-- BEGIN card-body -->
<div id="color-mode-container" class="card-body p-2">
    <div class="row gx-2">
        <div class="col-6 d-flex align-items-center justify-content-center border-light">
            <input id="theme_dark" name="theme" value="dark" type="hidden">
            <a class="hover-clickable app-theme-mode-link-dark
                {% if request.user.theme == 'dark' %} active{% endif %}" @click="theme = 'dark'"
                hx-post="{% url 'settings:save_display_settings' %}"
                hx-include="#theme_dark"
                hx-target="#color-mode-container"
                hx-target-error="#body"
                hx-swap="outerHTML">
                <div class="img"><img src="{% static 'assets/img/mode/dark.jpg' %}"
                        class="object-fit-cover border border-light m-2" height="76" width="152" alt="Dark Mode">
                <div class="text">Dark</div>
                </div>
            </a>
        </div>
        <div class="col-6 d-flex align-items-center justify-content-center border-light">
            <input id="theme_light" name="theme" value="light" type="hidden">
            <a class="hover-clickable app-theme-mode-link-light
                {% if request.user.theme == 'light' %} active{% endif %}" @click="theme = 'light'"
                hx-post="{% url 'settings:save_display_settings' %}"
                hx-include="#theme_light"
                hx-target="#color-mode-container"
                hx-target-error="#body"
                hx-swap="outerHTML">
                <div class="img"><img src="{% static 'assets/img/mode/light.jpg' %}"
                        class="object-fit-cover border border-dark m-2" height="76" width="152" alt="Light Mode">
                <div class="text">Light</div>
                </div>
            </a>
        </div>
    </div>
</div>
<!-- END card-body -->
{% endpartial %}

<h4><i class="fa fa-arrows-alt-h fa-fw text-theme"></i> Direction Mode</h4>
<p>Select the order of the content and menus in the theme</p>
<div class="card mb-3">
    {% partial direction_mode %}
    <!-- BEGIN card-arrow -->
    <div class="card-arrow">
        <div class="card-arrow-top-left"></div>
        <div class="card-arrow-top-right"></div>
        <div class="card-arrow-bottom-left"></div>
        <div class="card-arrow-bottom-right"></div>
    </div>
    <!-- END card-arrow -->
</div>

First off, I'd like to point out here that the HTMX and Alpine'ified HTML is ONLY 9 LINES LONGER than the original. That's just badass, but anyway, we're using django-template-partials here which is explained further down below in the python view section of this writeup. Also, notice we've taken the href away from the <a links here and replaced them with Alpine x-on methods (shorthand for x-on is @ if you prefer).

In short, the one on the dark mode toggle says to Alpine when this element is clicked, find the x-data element named 'theme' and replace its 'theme' value with dark. This makes the change happen immediately in the browser. Since I'm taking away the href I have a CSS class called "hover-clickable" that looks like this, to give the user the stock clickable indicator (additionally, a couple of font-awesome icons as ::before elements to indicate which one is selected to the user):

.hover-clickable {
  cursor: pointer;
}

.app-theme-mode-link-dark.active .img:before {
    content: "\f00c";
    position: absolute;
    inset-inline-start: 0;
    inset-inline-end: 0;
    top: 0;
    bottom: 0;
    font-size: .875rem;
    color: #fff;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 10;
    font-family: Font Awesome\ 6 Free, Font Awesome\ 6 Pro, FontAwesome !important;
    font-weight: 900;
    font-style: normal;
    font-variant: normal;
    text-rendering: auto;
}

.app-theme-mode-link-light.active .img:before {
    content: "\f00c";
    position: absolute;
    inset-inline-start: 0;
    inset-inline-end: 0;
    top: 0;
    bottom: 0;
    font-size: .875rem;
    color: #000;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 10;
    font-family: Font Awesome\ 6 Free, Font Awesome\ 6 Pro, FontAwesome !important;
    font-weight: 900;
    font-style: normal;
    font-variant: normal;
    text-rendering: auto;
}

Why take out the href? Because I don't want the URL to change. We're allowing the user to submit multiple post requests from a single page so I want the browser to leave the URL and history alone, more on that later.

HTMX comes into play to post the user's preference change back to the server side for storage in their preference fields. Within the same element after toggling their preference in the page, HTMX is:

  1. posting the change to the back end (save_display_setting matches a POST view)
  2. ...from a hidden form field which we call with hx-include by HTML element ID (the input field's value matches the value we want to set)
  3. if the server returns an error code response (becasue of a validation failure), we're specifying the target element for HTML to rewrite in the browser, in this case we would want the 500 error to take over the whole page, not just the element that was clicked to submit the post

Step 4: the get and post views

These are honestly dead simple. In addition to the HTML only being 9 lines longer, you only need about 35-40 lines of python view code.

def display_settings_view(request):
    context = {}
    return render(request, 'display_settings.html', context)

def save_display_settings_view(request):
    if request.method == 'POST':
        if request.POST:
            if request.user and request.user.is_authenticated:
                user = request.user
        
                if 'theme' in request.POST:
                    theme = request.POST['theme']
                    user.theme = theme
                elif 'menu_direction' in request.POST:
                    menu_direction = request.POST['menu_direction']
                    user.menu_direction = menu_direction
                elif 'color' in request.POST:
                    color = request.POST['color']
                    user.color = color

                try:
                    user.full_clean()
                except ValidationError:
                    return render(request, 'page_500_error.html')
                else:
                    user.save()
            else:
                return render(request, 'page_404_error.html')
        if 'theme' in request.POST:
            return render(request, 'display_settings.html#color_mode')
        elif 'menu_direction' in request.POST:
            return render(request, 'display_settings.html#direction_mode')
        elif 'color' in request.POST:
            return render(request, 'display_settings.html#theme_color')
    return HttpResponseNotAllowed(permitted_methods=['POST'])

To load the settings page with display_settings_view? Pretty much nothing is required except request.user which is passed by default, so it's just rendering the template. The one for the post request save_display_settings_view isn't much more complex. We're checking for...

  1. a valid HTTP method (post only)
  2. if there's a user in the request object (we can only save settings to a user we know)
  3. if the user is logged in (it's a profile page, derp)
  4. if the user has submitted one of our possible theme settings (full_clean() will make sure the values match with possible choices on the model fields)
  5. if so, save the user object with the one field value changed, whichever one it is, presuming full_clean() passes
  6. if everything works, return the partial template relevant to what the user clicked, using django-template-partials here to target the correct partial template block for HTMX to replace, and only sending that content back to the browser, not the full page. django-template-partials allows you to break out the parts of the page being dynamically changed. I wrote a previous guide similar to this one on usage of that library, but the basic idea is it allows you to define {% block %} type template sections both in python and in your templates, within the scope of a single file on the template side. This is necessary for HTMX, becuase it needs a target to re-render on the client side, and on the server side the python view needs to return the partial template to-be-rendered, rather than the whole page.

On the python / view side, you do this by simply calling your template file with #partial_name included on the end of it. On the template side, you tell HTMX which element ID to replace, that's pretty much it.

As mentioned previously, by only working with template fragments on both the front end and the back end, there's no reason a user should have to fill out an entire form before submitting to the back end. The whole point of this is instant gratification for the user. Whatever they click is given to them immediately, and the data persisted in an (automatic) subsequent step. This is also why the href parts of links were removed, as mentioned previously. The browser should leave this page alone in terms of link clicking, Alpine and HTMX are in control of all of it.

To-Do

It would be more efficient to update the specific field the user has changed, rather than doing a full user object save in the post view, but in my case there are only ~7 fields in my user model at present, so that's a minor concern, didn't bother. Another consideration in that is Django doesn't provide any automagic methods to check clean() on an update request for a single field, to my knoweldge, so you'd have to write clean logic into the post view or write a custom clean() method on the user model. Didn't really wanna do that either, so...

Here's what this looks like:

ezgif-871615d

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment