
Building Progressive Step Forms with Django: HTMX vs Unpoly
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!

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