Skip to main content

plain.htmx

Integrate HTMX with templates and views.

Overview

You can use plain.htmx to build HTMX-powered views that focus on server-side rendering without needing complicated URL structures or REST APIs.

The two main features are template fragments and view actions.

The HTMXView class is the starting point for the server-side HTMX behavior. To use these features on a view, inherit from this class (yes, this is designed to work with class-based views).

# app/views.py
from plain.htmx.views import HTMXView


class HomeView(HTMXView):
    template_name = "home.html"

In your base.html template (or wherever you need the HTMX scripts), you can use the {% htmx_js %} template tag:

<!-- base.html -->
{% load htmx %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>My Site</title>
    {% htmx_js %}
</head>
<body>
    {% block content %}{% endblock %}
</body>

Template fragments

An {% htmxfragment %} can render a specific part of your template in HTMX responses. When you use a fragment, all hx-get, hx-post, etc. elements inside that fragment will automatically send a request to the current URL, render only the updated content for the fragment, and swap out the fragment.

Here's an example:

<!-- home.html -->
{% extends "base.html" %}

{% load htmx %}

{% block content %}
<header>
  <h1>Page title</h1>
</header>

<main>
  {% htmxfragment "main" %}
  <p>The time is {% now "jS F Y H:i" %}</p>

  <button hx-get>Refresh</button>
  {% endhtmxfragment %}
</main>
{% endblock %}

Everything inside {% htmxfragment %} will automatically update when "Refresh" is clicked.

Fragments in loops

You can use {% htmxfragment %} inside a {% for %} loop by giving each iteration a unique fragment name using a dynamic expression:

{% for item in items %}
  {% htmxfragment "item-" ~ item.pk %}
  <p>{{ item.name }}</p>
  <button hx-post plain-hx-action="toggle">Toggle</button>
  {% endhtmxfragment %}
{% endfor %}

The ~ operator concatenates the string "item-" with the item's primary key, producing unique fragment names like item-1, item-2, etc. When a button inside a specific fragment is clicked, only that fragment re-renders — the server renders the full template but extracts just the matching fragment's content.

The view doesn't need any special handling for loop fragments — the standard HTMXView works as-is. Just make sure the view's context includes the full list of items so the loop can execute during the fragment render:

class ItemListView(HTMXView):
    template_name = "items.html"

    def get_template_context(self):
        context = super().get_template_context()
        context["items"] = Item.query.all()
        return context

    def htmx_post_toggle(self):
        # Handle the action — the fragment is re-rendered automatically.
        return None

Lazy template fragments

If you want to render a fragment lazily, you can add the lazy attribute to the {% htmxfragment %} tag.

{% htmxfragment "main" lazy=True %}
<!-- This content will be fetched with hx-get -->
{% endhtmxfragment %}

This pairs nicely with passing a callable function or method as a context variable, which will only get invoked when the fragment actually gets rendered on the lazy load.

def fetch_items():
    import time

    time.sleep(2)
    return ["foo", "bar", "baz"]


class HomeView(HTMXView):
    def get_template_context(self):
        context = super().get_template_context()
        context["items"] = fetch_items  # Missing () are on purpose!
        return context
{% htmxfragment "main" lazy=True %}
<ul>
  {% for item in items %}
    <li>{{ item }}</li>
  {% endfor %}
</ul>
{% endhtmxfragment %}

How template fragments work

When you use the {% htmxfragment %} tag, a standard div is output that looks like this:

<div plain-hx-fragment="main" hx-swap="outerHTML" hx-target="this" hx-indicator="this">
  {{ fragment_content }}
</div>

The plain-hx-fragment is a custom attribute, but the rest are standard HTMX attributes.

When Plain renders the response to an HTMX request, it will get the Plain-HX-Fragment header, find the fragment with that name in the template, and render that for the response.

Then the response content is automatically swapped in to replace the content of your {% htmxfragment %} tag.

Note that there is no URL specified on the hx-get attribute. By default, HTMX will send the request to the current URL for the page. When you're working with fragments, this is typically the behavior you want! (You're on a page and want to selectively re-render a part of that page.)

Fragment names must be unique on a page. For static fragments, use distinct string names. Inside loops, use a dynamic expression like "item-" ~ item.pk to generate unique names per iteration (see fragments in loops).

View actions

View actions let you define multiple "actions" on a class-based view. This is an alternative to defining specific API endpoints or form views to handle basic button interactions.

With view actions you can design a single view that renders a single template, and associate buttons in that template with class methods in the view.

As an example, let's say we have a PullRequest model and we want users to be able to open, close, or merge it with a button.

In your template, use the plain-hx-action attribute to name the action:

{% extends "base.html" %}

{% load htmx %}

{% block content %}
<header>
  <h1>{{ pullrequest }}</h1>
</header>

<main>
  {% htmxfragment "pullrequest" %}
  <p>State: {{ pullrequest.state }}</p>

  {% if pullrequest.state == "open" %}
    <!-- If it's open, they can close or merge it -->
    <button hx-post plain-hx-action="close">Close</button>
    <button hx-post plain-hx-action="merge">Merge</button>
  {% else if pullrequest.state == "closed" %}
    <!-- If it's closed, it can be re-opened -->
    <button hx-post plain-hx-action="open">Open</button>
  {% endif %}

  {% endhtmxfragment %}
</main>
{% endblock %}

Then in the view class, define methods for each HTTP method + plain-hx-action:

from plain.htmx.views import HTMXView
from plain.views import DetailView


class PullRequestDetailView(HTMXView, DetailView):
    def get_queryset(self):
        # The queryset will apply to all actions on the view, so "permission" logic can be shared
        return super().get_queryset().filter(users=self.user)

    # Action handling methods follow this format:
    # htmx_{method}_{action}
    #
    # Return `None` to re-render the current template (or active fragment).
    # Return a `Response` only when you need to do something else —
    # a redirect, a 204, a custom payload.
    def htmx_post_open(self):
        if self.object.state != "closed":
            raise ValueError("Only a closed pull request can be opened")

        self.object.state = "closed"
        self.object.update()

    def htmx_post_close(self):
        if self.object.state != "open":
            raise ValueError("Only an open pull request can be closed")

        self.object.state = "open"
        self.object.update()

    def htmx_post_merge(self):
        if self.object.state != "open":
            raise ValueError("Only an open pull request can be merged")

        self.object.state = "merged"
        self.object.update()

This can be a matter of preference, but typically you may end up building out an entire form, API, or set of URLs to handle these behaviors. If your application is only going to handle these actions via HTMX, then a single View may be a simpler way to do it.

You can also handle HTMX requests without a specific action by just implementing the HTTP method:

from plain.http import Response


class PullRequestDetailView(HTMXView, DetailView):
    def get_queryset(self):
        return super().get_queryset().filter(users=self.user)

    # You can also leave off the "plain-hx-action" attribute and just handle the HTTP method
    def htmx_delete(self):
        self.object.delete()

        # Tell HTMX to do a client-side redirect when it receives the response
        response = Response(status_code=204)
        response.headers["HX-Redirect"] = "/"
        return response

Dedicated templates

A nice convention is to name template "partials" dedicated to HTMX as {template_name}_htmx.html, and {% include %} them wherever they're rendered.

For cases where loop items need their own URL (e.g., each item has a detail page), you can define dedicated URLs to handle the HTMX behaviors for individual items. You can sometimes think of these as "pages within a page". (For simpler cases, fragments in loops may be sufficient.)

So if you have a template that renders a collection of items, you can do the initial render using a {% include %}:

<!-- pullrequests/pullrequest_list.html -->
{% extends "base.html" %}

{% block content %}

{% for pullrequest in pullrequests %}
<div>
  {% include "pullrequests/pullrequest_detail_htmx.html" %}
</div>
{% endfor %}

{% endblock %}

And then subsequent HTMX requests/actions on individual items can be handled by a separate URL/View:

<!-- pullrequests/pullrequest_detail_htmx.html -->
<div hx-url="{{ url('pullrequests:detail', uuid=pullrequest.uuid) }}" hx-swap="outerHTML" hx-target="this">
  <!-- Send all HTMX requests to a URL for single pull requests (works inside of a loop, or on a single detail page) -->
  <h2>{{ pullrequest.title }}</h2>
  <button hx-get>Refresh</button>
  <button hx-post plain-hx-action="update">Update</button>
</div>

If you need a URL to render an individual item, you can simply include the same template fragment in most cases:

<!-- pullrequests/pullrequest_detail.html -->
{% extends "base.html" %}

{% block content %}

{% include "pullrequests/pullrequest_detail_htmx.html" %}

{% endblock %}
# urls.py and views.py
# urls.py
default_namespace = "pullrequests"

urlpatterns = [
    path("<uuid:uuid>/", views.PullRequestDetailView, name="detail"),
]


# views.py
class PullRequestDetailView(HTMXView, DetailView):
    def htmx_post_update(self):
        self.object.update()

FAQs

How do I add a Tailwind CSS variant for loading states?

The standard behavior for {% htmxfragment %} is to set hx-indicator="this" on the rendered element. This tells HTMX to add the htmx-request class to the fragment element when it is loading.

Here's a simple variant you can add to your tailwind.config.js to easily style the loading state:

const plugin = require('tailwindcss/plugin')

module.exports = {
  plugins: [
    // Add variants for htmx-request class for loading states
    plugin(({addVariant}) => addVariant('htmx-request', ['&.htmx-request', '.htmx-request &']))
  ],
}

You can then prefix any class with htmx-request: to decide what it looks like while HTMX requests are being sent:

<!-- The "htmx-request" class will be added to the <form> by default -->
<form hx-post="{{ url }}">
    <!-- Showing an element -->
    <div class="hidden htmx-request:block">
        Loading
    </div>

    <!-- Changing a button's class -->
    <button class="text-white bg-black htmx-request:opacity-50 htmx-request:cursor-wait" type="submit">Submit</button>
</form>

How are CSRF tokens handled?

CSRF tokens are configured automatically with the HTMX JS API. You don't have to put hx-headers on the <body> tag.

How do I show error states?

This package includes an HTMX extension for adding error classes for failed requests:

  • htmx-error-response for htmx:responseError
  • htmx-error-response-{{ status_code }} for htmx:responseError
  • htmx-error-send for htmx:sendError

To enable them, use hx-ext="plain-errors".

You can add the ones you want as Tailwind variants and use them to show error messages:

const plugin = require('tailwindcss/plugin')

module.exports = {
  plugins: [
    // Add variants for htmx-request class for loading states
    plugin(({addVariant}) => addVariant('htmx-error-response-429', ['&.htmx-error-response-429', '.htmx-error-response-429 &']))
  ],
}

How do I configure HTMX for CSP?

If you're using Content Security Policy, you can disable the indicator styles that HTMX adds inline:

<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>

Installation

Install the plain.htmx package from PyPI:

uv add plain.htmx

Add plain.htmx to your installed packages:

# app/settings.py
INSTALLED_PACKAGES = [
    # ...
    "plain.htmx",
]

Add the HTMX JavaScript to your base template:

<!-- base.html -->
{% load htmx %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>My Site</title>
    {% htmx_js %}
</head>
<body>
    {% block content %}{% endblock %}
</body>

Create a view that inherits from HTMXView:

# app/views.py
from plain.htmx.views import HTMXView


class HomeView(HTMXView):
    template_name = "home.html"

Create a template with an HTMX fragment:

<!-- home.html -->
{% extends "base.html" %}
{% load htmx %}

{% block content %}
{% htmxfragment "content" %}
<p>The time is {% now "jS F Y H:i" %}</p>
<button hx-get>Refresh</button>
{% endhtmxfragment %}
{% endblock %}

Metadata

Release files for plain.htmx 0.22.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for plain.htmx 0.22.0
File Size Uploaded
plain_htmx-0.22.0.tar.gz 274.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for plain.htmx 0.22.0
File Interpreter ABI Platform
plain_htmx-0.22.0-py3-none-any.whl Python 3 none any Details

Total release size: 571.3 kB

Release files / plain_htmx-0.22.0.tar.gz

Download URL plain_htmx-0.22.0.tar.gz
Size 274.7 kB
Tags Source
SHA-256 checksum
How to use checksums
6c2d786548924dd0122d302fdd09b6d3b58dcd17e8e25c0bfb2960a77baace33
BLAKE2b-256 checksum
How to use checksums
0fe20428b85fd7f25040ebe980498db2865eb274367cb8544e8c1378264605a2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / plain_htmx-0.22.0-py3-none-any.whl

Download URL plain_htmx-0.22.0-py3-none-any.whl
Size 296.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fc2ee228ae4d8ca4d6f804c5e5c34ea140977b8654cb43b40b3ba991c414adda
BLAKE2b-256 checksum
How to use checksums
efdd82524146de4dc480dd0427002290f028741bcf68153236b2707fb9ad03b1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.22.0 This release

2 release files

0.21.0

2 release files

0.20.3

2 release files

0.20.2

2 release files

0.20.0

2 release files

0.19.2

2 release files

0.19.0

2 release files

0.18.3

2 release files

0.18.2

2 release files

0.18.0

2 release files

0.17.1

2 release files

0.16.2

2 release files

0.16.0

2 release files

0.15.1

2 release files

0.13.0

2 release files

0.12.0

2 release files

0.11.1

2 release files

0.11.0

2 release files

0.10.4

2 release files

0.10.0

2 release files

0.9.2

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

0.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page