Modal forms with Django+HTMX

This article describes the pattern I use to implement modal forms (i.e., forms in a modal dialog box) with Django and HTMX.

Modal forms with Django+HTMX

The advantages of the solution presented in this article are:

  1. It doesn’t depend on Hyperscript (unlike HTMX’s example).
  2. It requires only a few lines of JavaScript.
  3. It is reusable (no need to repeat the JavaScript code).
  4. It supports server-side form validation.
  5. It allows refreshing the main page on success.
  6. It works with any CSS framework.
  7. It can support progressive enhancement when JavaScript is unavailable (but I won’t show it in this article).


To read this article, you must be familiar with Django, know the basics of HTMX and a bit about Bootstrap. I won’t explain how to create a Django project or integrate HTMX in your application; I’m assuming that you already know how to do this.

If this article goes too quickly, I recommend that you watch this YouTube video where I take the time to explain every step of the process.

You can find the complete source code for this project on GitHub. I uploaded two versions: one using Bootstrap 4 and the other using Bootstrap 5. You’ll find each version in the corresponding branch. The code snippets in this article use Bootstrap 5.

Step 1: create a placeholder

First, we need a placeholder that will serve as our hx-target when displaying a modal dialog.

Place the following lines at the end of <body>:

<div id="modal" class="modal fade">
  <div id="dialog" class="modal-dialog" hx-target="this"></div>

As you can see, this code is directly taken from Bootstrap’s documentation, except that the dialog is empty.

Three things to notice:

  1. The modal’s id is modal. We’ll use this id later in the JavaScript code to control the visibility of the dialog.
  2. The dialog’s id is dialog. We’ll use this id for hx-target to set the dialog’s content (see next step).
  3. We set hx-target to this so that every HTMX request originating from the dialog (typically a form POST) remains in the dialog.

Step 2: create the button that opens the dialog

We’ll create a simple button that leverages HTMX attributes:

<button hx-get="{% url 'add_movie' %}" hx-target="#dialog">
  Add a movie

As promised, we used #dialog for hx-target, which means that the response’s HTML will be injected in the dialog.

For the hx-get attribute, we used a URL named add_movie; we’ll create this view in the next step.

Step 3: create the view that renders the form

For now, we’ll only see the GET part in the view:

def add_movie(request):
    form = MovieForm()
    return render(request, 'movie_form.html', {
        'form': form,

Here is a simple version of movie_form.html:

<form hx-post="{{ request.path }}" class="modal-content">
  {% csrf_token %}
  <div class="modal-header">
    <h5 class="modal-title">Edit Movie</h5>
  <div class="modal-body">
    {{ form.as_p }}
  <div class="modal-footer">
    <button type="button" data-bs-dismiss="modal">Cancel</button>
    <button type="submit">Save</button>

There is nothing fancy in this template, except maybe the value of hx-post. request.path is the URL of the current request, which means that the POST request will use the same view as the GET request. For now, our view only handles the GET method correctly; we’ll implement the POST method in a moment.

If you look at the code on GitHub, you’ll see that I use a slightly more complicated template to layout the form according to Bootstrap’s conventions.

Step 4: show the modal

Thanks to the attribute hx-target="#dialog", the form returned by the add_movie view is injected inside the dialog box. At this point, the dialog box is still hidden; we need to write a small piece of JavaScript to show it.

const modal = new bootstrap.Modal(document.getElementById("modal"))

htmx.on("htmx:afterSwap", (e) => {
  // Response targeting #dialog => show the modal
  if ( == "dialog") {

Obviously, this code is specific to Bootstrap 5, so you’ll need to adapt it to your CSS framework.

What’s important here is that we listen to the htmx:afterSwap event. As the documentation says: “this event is triggered after new content has been swapped into the DOM.”

When this event occurs, we show the dialog. Of course, we only do that if #dialog is the target, so it only affects dialog-related swaps.

Step 5: handle the form POST in the view

Let’s edit the add_movie view to support the POST method:

def add_movie(request):
    if request.method == "POST":
        form = MovieForm(request.POST)
        if form.is_valid():
            return HttpResponse(status=204)
        form = MovieForm()
    return render(request, 'movie_form.html', {
        'form': form,

As you can see, we use the classic pattern for function-based view with one twist: instead of returning a 302 to redirect the browser to a new page, we return an empty response with the appropriate status code (204 No Content).

Returning an empty response will be a signal for our JavaScript code to hide the dialog. We’ll see that in the next step.

If the form is invalid, we render the template with the form errors. Since we added hx-target="this" to the dialog’s <div>, the response will replace the dialog’s content. This is perfect because it will show the forms error in the dialog box.

Step 6: hide the dialog box

Since we returned an empty response, you might think that HTMX will flush the dialog’s content, but that’s not the case: HTMX doesn’t swap the content for non-200 responses, and we used 204.

Right now, nothing changes on the screen after a successful POST, so we need to hide the dialog. We’ll do that in the htmx:beforeSwap event:

htmx.on("htmx:beforeSwap", (e) => {
  // Empty response targeting #dialog => hide the modal
  if ( == "dialog" && !e.detail.xhr.response) {
    e.detail.shouldSwap = false

As you can see, we first check that the swap targets the dialog box and that the response is empty. If that’s the case, the we hide the dialog box.

Notice that I didn’t check the status code of 204 because I want this code to support any empty response. This is a safeguard in case you prefer to use 200 instead of 204.

Similarly, the event handler sets shouldSwap to false to prevent HTMX from clearing the content when the dialog fades away. Again, this is a safeguard because the default is false for non-200 responses.

Step 7: empty the dialog

Let’s summarise: the dialog box opens and hides as expected and even shows form errors. Great! In fact, everything works fine except a tiny little detail.

Imagine this scenario:

  1. User opens the dialog
  2. User submits the form with an error
  3. Error gets rendered on the screen
  4. User clicks “Cancel”
  5. Dialog fades away

At this point, the dialog is hidden but still contains the form with the errors. If the user opens the dialog again, she might see the errors for a fraction of a second.

To avoid this, we must flush the content of the dialog after the fade transition.

With Bootstrap 5, we can do this from the event.

htmx.on("", () => {
  document.getElementById("dialog").innerHTML = ""

Step 8: refresh page content

Now the dialog box works perfectly, but we still have to find a way to refresh the page and reflect the changes introduced by the POST. We returned an empty response, so how are we supposed to update the page?
We’ll raise an event that triggers a refresh.

Let’s modify the add_movie view to include the HX-Trigger header to the response, like so:

return HttpResponse(status=204, headers={'HX-Trigger': 'movieListChanged'})

This header instructs HTMX to raise an event. In this case, I decided to name the event movieListChanged.

Now, we need to listen to this event. HTMX lets us do that with the attribute hx-trigger="movieListChanged from:body".

In my example, I also wanted to load the table on page load, so the actual code looks like this:

<tbody hx-trigger="load, movieListChanged from:body" hx-get="{% url 'movie_list' %}" hx-target="this">

As you might expect, the movie_list view returns a bunch of rows tailored for this particular table.

That’s all! Now, the table updates itself when the form is submitted.


There you have it: a reusable pattern to handle Django Form in modal dialogs.

I’ve been using it for a while now, and I can confirm that it is a reliable and scalable solution.

Should you have any questions about this article, please open an issue in the GitHub project.

2022/03/02: The next article shows how to display a notification with this pattern.