Building Progressive Step Forms with Django: HTMX vs Unpoly

Building Progressive Step Forms with Django: HTMX vs Unpoly

A side-by-side comparison of building dynamic multi-step forms with minimal JavaScript

In the modern web development landscape, we often find ourselves seeking the perfect balance between rich user interfaces and maintainable server-side logic. If you're building with Django, you might be exploring ways to enhance your apps with interactive features without diving headfirst into the complexity of JavaScript-heavy frontend frameworks.

Two libraries have emerged as popular choices for Django developers looking to create dynamic, modern interfaces while maintaining the simplicity of server-rendered HTML: HTMX and Unpoly. In this article, I'll compare how these libraries handle one of the most common UI patterns: multi-step forms.

What are HTMX and Unpoly?

Before diving into the code, let's briefly introduce our contenders:

HTMX allows you to access modern browser features directly from HTML, rather than using JavaScript. Its core philosophy is extending HTML to provide advanced features through attributes like hx-get, hx-post, and hx-swap.

Unpoly is a framework that enhances navigation and forms in server-rendered HTML applications. It uses attributes like up-follow, up-submit, and up-target to create smooth, app-like experiences without writing JavaScript.

Both libraries aim to simplify the creation of dynamic interfaces while keeping most of your application logic on the server side.

The Challenge: Building a Multi-Step Registration Form

For our comparison, we'll implement a three-step registration form: 1. Personal information (name, email) 2. Address details 3. Account creation (username, password)

Let's explore how each library approaches this challenge.

Project Setup

Our Django project structure will look like this:

project/
├── manage.py
├── myproject/
└── registration/
    ├── models.py
    ├── forms.py
    ├── views.py
    ├── urls.py
    └── templates/
        └── registration/
            ├── base.html
            ├── step_1.html
            ├── step_2.html
            ├── step_3.html
            └── success.html

For both implementations, we'll share the same models and forms:

# registration/models.py
from django.db import models

class UserRegistration(models.Model):
    # Step 1 fields
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)
    email = models.EmailField(unique=True)

    # Step 2 fields
    address = models.CharField(max_length=200)
    city = models.CharField(max_length=100)
    state = models.CharField(max_length=100)
    zip_code = models.CharField(max_length=20)

    # Step 3 fields
    username = models.CharField(max_length=100, unique=True)
    password = models.CharField(max_length=100)  # Would use proper hashing in production
# registration/forms.py
from django import forms
from .models import UserRegistration

class Step1Form(forms.ModelForm):
    class Meta:
        model = UserRegistration
        fields = ['first_name', 'last_name', 'email']

class Step2Form(forms.ModelForm):
    class Meta:
        model = UserRegistration
        fields = ['address', 'city', 'state', 'zip_code']

class Step3Form(forms.ModelForm):
    confirm_password = forms.CharField(widget=forms.PasswordInput())

    class Meta:
        model = UserRegistration
        fields = ['username', 'password']
        widgets = {
            'password': forms.PasswordInput(),
        }

    def clean(self):
        cleaned_data = super().clean()
        password = cleaned_data.get('password')
        confirm_password = cleaned_data.get('confirm_password')

        if password and confirm_password and password != confirm_password:
            self.add_error('confirm_password', "Passwords don't match")

        return cleaned_data

Implementation with HTMX

Installing HTMX

First, we need to include HTMX in our base template:

<!-- In the <head> section of base.html -->
<script src="https://unpkg.com/htmx.org@1.9.6"></script>

Template for Step 1

{% extends "registration/base.html" %}

{% block content %}
<form hx-post="{% url 'registration:step_1' %}" 
      hx-target="#form-container"
      hx-swap="innerHTML">
    {% csrf_token %}
    <div class="mb-3">
        <label for="{{ form.first_name.id_for_label }}" class="form-label">First Name</label>
        {{ form.first_name }}
        {% if form.first_name.errors %}
        <div class="text-danger">{{ form.first_name.errors }}</div>
        {% endif %}
    </div>
    <!-- Other form fields -->
    <div class="d-grid">
        <button type="submit" class="btn btn-primary">Next</button>
    </div>
</form>
{% endblock %}

The HTMX attributes here tell the browser to: - Submit the form via POST to the step_1 URL (hx-post) - Replace the inner HTML of the #form-container element with the response (hx-target and hx-swap)

Template for Step 2

{% extends "registration/base.html" %}

{% block content %}
<form hx-post="{% url 'registration:step_2' %}" 
      hx-target="#form-container"
      hx-swap="innerHTML">
    {% csrf_token %}
    <!-- Form fields -->
    <div class="d-flex justify-content-between">
        <button type="button" class="btn btn-secondary" 
                hx-get="{% url 'registration:step_1' %}" 
                hx-target="#form-container"
                hx-swap="innerHTML">Back</button>
        <button type="submit" class="btn btn-primary">Next</button>
    </div>
</form>
{% endblock %}

Notice the back button uses hx-get to load the previous form step without a full page reload.

The HTMX View Logic

# registration/views.py
class Step1View(View):
    def get(self, request):
        # Start fresh registration
        if 'registration_id' in request.session:
            del request.session['registration_id']

        form = Step1Form()
        return render(request, 'registration/step_1.html', {'form': form, 'step': 1})

    def post(self, request):
        form = Step1Form(request.POST)
        if form.is_valid():
            # Create new registration or update existing
            registration_id = request.session.get('registration_id')
            if registration_id:
                registration = UserRegistration.objects.get(id=registration_id)
                registration.first_name = form.cleaned_data['first_name']
                registration.last_name = form.cleaned_data['last_name']
                registration.email = form.cleaned_data['email']
                registration.save()
            else:
                registration = form.save()
                request.session['registration_id'] = registration.id

            # Redirect to step 2
            return redirect('registration:step_2')

        return render(request, 'registration/step_1.html', {'form': form, 'step': 1})

HTMX will handle the response and update only the targeted element in the DOM, creating a smooth transition between steps.

Implementation with Unpoly

Installing Unpoly

First, include Unpoly in your base template:

<!-- In the <head> section of base.html -->
<script src="https://unpkg.com/unpoly@2.7.1/dist/unpoly.min.js"></script>
<link href="https://unpkg.com/unpoly@2.7.1/dist/unpoly.min.css" rel="stylesheet">

Template for Step 1

{% extends "registration/base.html" %}

{% block content %}
<form up-submit="{% url 'registration:step_1' %}" 
      up-target="#form-container"
      up-transition="cross-fade">
    {% csrf_token %}
    <div class="mb-3">
        <label for="{{ form.first_name.id_for_label }}" class="form-label">First Name</label>
        {{ form.first_name }}
        {% if form.first_name.errors %}
        <div class="text-danger">{{ form.first_name.errors }}</div>
        {% endif %}
    </div>
    <!-- Other form fields -->
    <div class="d-grid">
        <button type="submit" class="btn btn-primary">Next</button>
    </div>
</form>
{% endblock %}

The Unpoly attributes tell the browser to: - Submit the form to the step_1 URL (up-submit) - Replace the #form-container with the response (up-target) - Use a cross-fade transition effect (up-transition)

Template for Step 2

{% extends "registration/base.html" %}

{% block content %}
<form up-submit="{% url 'registration:step_2' %}" 
      up-target="#form-container"
      up-transition="cross-fade">
    {% csrf_token %}
    <!-- Form fields -->
    <div class="d-flex justify-content-between">
        <a class="btn btn-secondary" 
           up-follow="{% url 'registration:step_1' %}"
           up-target="#form-container" 
           up-transition="cross-fade">Back</a>
        <button type="submit" class="btn btn-primary">Next</button>
    </div>
</form>
{% endblock %}

For navigation, Unpoly uses up-follow instead of a button with hx-get.

The Unpoly View Logic

# registration/views.py
class Step1View(View):
    def get(self, request):
        # Start fresh registration
        if 'registration_id' in request.session:
            del request.session['registration_id']

        form = Step1Form()
        return render(request, 'registration/step_1.html', {'form': form, 'step': 1})

    def post(self, request):
        form = Step1Form(request.POST)
        if form.is_valid():
            # Create new registration or update existing
            registration_id = request.session.get('registration_id')
            if registration_id:
                registration = UserRegistration.objects.get(id=registration_id)
                registration.first_name = form.cleaned_data['first_name']
                registration.last_name = form.cleaned_data['last_name']
                registration.email = form.cleaned_data['email']
                registration.save()
            else:
                registration = form.save()
                request.session['registration_id'] = registration.id

            # Respond appropriately based on the request type
            if request.headers.get('X-Up-Version'):
                # This is an Unpoly request, return just the fragment
                return render(request, 'registration/step_2.html', {
                    'form': Step2Form(instance=registration),
                    'step': 2
                })
            else:
                # Regular request, do a full redirect
                return redirect('registration:step_2')

        return render(request, 'registration/step_1.html', {'form': form, 'step': 1})

The main difference here is how we detect Unpoly requests using the X-Up-Version header and respond with the appropriate content.

Key Differences Between HTMX and Unpoly

After implementing the same form with both libraries, several differences stand out:

1. Attribute Names and Syntax

Feature HTMX Unpoly
Form submission hx-post="/url/" up-submit="/url/"
Target element hx-target="#element" up-target="#element"
Navigation hx-get="/url/" up-follow="/url/"
Transitions hx-swap="outerHTML swap:1s" up-transition="cross-fade"

2. Transitions and Animations

Unpoly comes with a built-in transition system that makes animated changes between states easier to implement. HTMX can achieve similar effects but requires more custom CSS work.

3. Request Detection

HTMX requests are identified via the HX-Request header, while Unpoly uses X-Up-Version. This slight difference affects how you detect and respond to dynamic requests in your views.

4. Navigation Model

Unpoly has a more comprehensive navigation model with history management and fragment caching. HTMX focuses more on the simple HTML extension paradigm.

Which One Should You Choose?

Both libraries deliver on their promise of enhancing Django applications with dynamic features while keeping server-side logic intact. Your choice might depend on:

Choose HTMX if: - You prefer a smaller library footprint (~14KB vs ~35KB) - You want a simpler mental model that extends HTML - You need WebSocket or Server-Sent Events support - Your project is very minimalist

Choose Unpoly if: - You want more built-in UI components (modals, popups) - You prefer the built-in transition system - You need advanced fragment caching - You value the more comprehensive navigation and history features

Performance Considerations

Both libraries are lightweight compared to full JavaScript frameworks, but there are subtle differences:

  • HTMX is smaller in file size
  • Unpoly includes more built-in features, which may increase initial load time
  • Both are highly efficient for partial page updates
  • Both have excellent browser compatibility

Conclusion

HTMX and Unpoly represent a refreshing approach to web development that bridges the gap between traditional server-rendered applications and modern interactive UIs. They allow Django developers to create dynamic, responsive applications without sacrificing the simplicity and structure of server-side logic.

For multi-step forms specifically, both libraries provide elegant solutions that enhance user experience while keeping most of the logic server-side. The choice between them ultimately comes down to your specific project needs and personal preferences regarding API design and additional features.

Whichever you choose, both libraries work exceptionally well with Django and provide a smooth, interactive experience for your users without the complexity of a full JavaScript framework.

Code Repository

For the complete code examples and a working demo of both implementations, check out my GitHub repository: django-multistep-form-comparison.


What's your experience with HTMX or Unpoly? Have you used either in your Django projects? Let me know in the comments below!

Author

Django Developer

Django Developer and DevOps Expert specializing in web applications and cloud infrastructure.