Jump to content

Django + HTMX SaaS Frontend Part 2: Building Real Features

From JOHNWICK

In Part 1, we explored the foundations of building a SaaS application with Django and HTMX. Now it’s time to get our hands dirty building actual features that users will interact with daily. We’ll create a task management system with real-time updates, inline editing, and dynamic filtering — all without writing a single line of JavaScript. What We’re Building

We’re going to implement a complete task management feature with:

  • Dynamic task creation without page reloads
  • Inline editing with instant updates
  • Real-time filtering and search
  • Status updates with visual feedback
  • Delete functionality with confirmation

Setting Up Our Models First, let’s create a robust Task model that will power our feature:

# tasks/models.py
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone

class Task(models.Model):

    STATUS_CHOICES = [
        ('todo', 'To Do'),
        ('in_progress', 'In Progress'),
        ('done', 'Done'),
    ]
    
    PRIORITY_CHOICES = [
        ('low', 'Low'),
        ('medium', 'Medium'),
        ('high', 'High'),
    ]
    
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='todo')
    priority = models.CharField(max_length=20, choices=PRIORITY_CHOICES, default='medium')
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tasks')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    due_date = models.DateField(null=True, blank=True)
    
    class Meta:
        ordering = ['-created_at']
    
    def __str__(self):
        return self.title

Creating the Task List View Now let’s build our main view that handles listing and filtering tasks:

# tasks/views.py
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from .models import Task
@login_required
def task_list(request):
    tasks = Task.objects.filter(user=request.user)
    
    # Handle filtering
    status_filter = request.GET.get('status')
    priority_filter = request.GET.get('priority')
    search_query = request.GET.get('search')
    
    if status_filter:
        tasks = tasks.filter(status=status_filter)
    if priority_filter:
        tasks = tasks.filter(priority=priority_filter)
    if search_query:
        tasks = tasks.filter(title__icontains=search_query)
    
    context = {
        'tasks': tasks,
        'status_filter': status_filter,
        'priority_filter': priority_filter,
        'search_query': search_query,
    }
    
    # Return partial template for HTMX requests
    if request.htmx:
        return render(request, 'tasks/partials/task_list.html', context)
    
    return render(request, 'tasks/task_list.html', context)

The Main Template Here’s our main task list template with HTMX attributes:

<!-- tasks/templates/tasks/task_list.html -->
{% extends 'base.html' %}
{% block content %}
<div class="container mx-auto px-4 py-8">
    <div class="flex justify-between items-center mb-6">
        <h1 class="text-3xl font-bold">My Tasks</h1>
        <button 
            hx-get="{% url 'task_create_form' %}"
            hx-target="#modal"
            class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
            + New Task
        </button>
    </div>
    
    <!-- Filters -->
    <div class="bg-white p-4 rounded shadow mb-6">
        <form class="flex gap-4">
            <input 
                type="text" 
                name="search"
                placeholder="Search tasks..."
                value="{{ search_query|default:'' }}"
                hx-get="{% url 'task_list' %}"
                hx-trigger="keyup changed delay:300ms"
                hx-target="#task-container"
                class="flex-1 px-4 py-2 border rounded">
            
            <select 
                name="status"
                hx-get="{% url 'task_list' %}"
                hx-trigger="change"
                hx-target="#task-container"
                hx-include="[name='search'], [name='priority']"
                class="px-4 py-2 border rounded">
                <option value="">All Statuses</option>
                <option value="todo" {% if status_filter == 'todo' %}selected{% endif %}>To Do</option>
                <option value="in_progress" {% if status_filter == 'in_progress' %}selected{% endif %}>In Progress</option>
                <option value="done" {% if status_filter == 'done' %}selected{% endif %}>Done</option>
            </select>
            
            <select 
                name="priority"
                hx-get="{% url 'task_list' %}"
                hx-trigger="change"
                hx-target="#task-container"
                hx-include="[name='search'], [name='status']"
                class="px-4 py-2 border rounded">
                <option value="">All Priorities</option>
                <option value="low" {% if priority_filter == 'low' %}selected{% endif %}>Low</option>
                <option value="medium" {% if priority_filter == 'medium' %}selected{% endif %}>Medium</option>
                <option value="high" {% if priority_filter == 'high' %}selected{% endif %}>High</option>
            </select>
        </form>
    </div>
    
    <!-- Task List Container -->
    <div id="task-container">
        {% include 'tasks/partials/task_list.html' %}
    </div>
</div>
<!-- Modal Container -->
<div id="modal"></div>
{% endblock %}

Creating the Task List Partial This partial template renders just the task list and can be swapped in dynamically:

<!-- tasks/templates/tasks/partials/task_list.html -->
<div class="space-y-4">
    {% for task in tasks %}
    <div id="task-{{ task.id }}" class="bg-white p-4 rounded shadow hover:shadow-lg transition">
        <div class="flex justify-between items-start">
            <div class="flex-1">
                <h3 class="text-xl font-semibold">{{ task.title }}</h3>
                {% if task.description %}
                <p class="text-gray-600 mt-2">{{ task.description }}</p>
                {% endif %}
                <div class="flex gap-4 mt-3">
                    <span class="px-2 py-1 text-xs rounded {% if task.status == 'done' %}bg-green-100 text-green-800{% elif task.status == 'in_progress' %}bg-blue-100 text-blue-800{% else %}bg-gray-100 text-gray-800{% endif %}">
                        {{ task.get_status_display }}
                    </span>
                    <span class="px-2 py-1 text-xs rounded {% if task.priority == 'high' %}bg-red-100 text-red-800{% elif task.priority == 'medium' %}bg-yellow-100 text-yellow-800{% else %}bg-gray-100 text-gray-800{% endif %}">
                        {{ task.get_priority_display }}
                    </span>
                    {% if task.due_date %}
                    <span class="text-xs text-gray-500">Due: {{ task.due_date|date:"M d, Y" }}</span>
                    {% endif %}
                </div>
            </div>
            <div class="flex gap-2">
                <button 
                    hx-get="{% url 'task_edit_form' task.id %}"
                    hx-target="#modal"
                    class="text-blue-600 hover:text-blue-800">
                    Edit
                </button>
                <button 
                    hx-delete="{% url 'task_delete' task.id %}"
                    hx-target="#task-{{ task.id }}"
                    hx-swap="outerHTML"
                    hx-confirm="Are you sure you want to delete this task?"
                    class="text-red-600 hover:text-red-800">
                    Delete
                </button>
            </div>
        </div>
    </div>
    {% empty %}
    <div class="text-center py-12 text-gray-500">
        <p class="text-xl">No tasks found</p>
        <p class="mt-2">Create your first task to get started!</p>
    </div>
    {% endfor %}
</div>

Creating and Editing Tasks Let’s implement the create and edit functionality with modal forms:

# tasks/views.py (continued)
from django.http import HttpResponse
from .forms import TaskForm
@login_required
@require_http_methods(["GET"])
def task_create_form(request):
    form = TaskForm()
    return render(request, 'tasks/partials/task_form.html', {'form': form, 'action': 'create'})
@login_required
@require_http_methods(["POST"])
def task_create(request):
    form = TaskForm(request.POST)
    if form.is_valid():
        task = form.save(commit=False)
        task.user = request.user
        task.save()
        
        # Return the updated task list
        tasks = Task.objects.filter(user=request.user)
        return render(request, 'tasks/partials/task_list.html', {'tasks': tasks})
    
    return render(request, 'tasks/partials/task_form.html', {'form': form, 'action': 'create'})
@login_required
@require_http_methods(["GET"])
def task_edit_form(request, pk):
    task = get_object_or_404(Task, pk=pk, user=request.user)
    form = TaskForm(instance=task)
    return render(request, 'tasks/partials/task_form.html', {
        'form': form, 
        'task': task, 
        'action': 'edit'
    })
@login_required
@require_http_methods(["POST"])
def task_update(request, pk):
    task = get_object_or_404(Task, pk=pk, user=request.user)
    form = TaskForm(request.POST, instance=task)
    if form.is_valid():
        form.save()
        tasks = Task.objects.filter(user=request.user)
        return render(request, 'tasks/partials/task_list.html', {'tasks': tasks})
    
    return render(request, 'tasks/partials/task_form.html', {
        'form': form, 
        'task': task, 
        'action': 'edit'
    })
@login_required
@require_http_methods(["DELETE"])
def task_delete(request, pk):
    task = get_object_or_404(Task, pk=pk, user=request.user)
    task.delete()
    return HttpResponse("")
The Task Form
Create a Django form for our tasks:
# tasks/forms.py
from django import forms
from .models import Task
class TaskForm(forms.ModelForm):
    class Meta:
        model = Task
        fields = ['title', 'description', 'status', 'priority', 'due_date']
        widgets = {
            'title': forms.TextInput(attrs={
                'class': 'w-full px-4 py-2 border rounded focus:ring-2 focus:ring-blue-500',
                'placeholder': 'Task title'
            }),
            'description': forms.Textarea(attrs={
                'class': 'w-full px-4 py-2 border rounded focus:ring-2 focus:ring-blue-500',
                'placeholder': 'Task description',
                'rows': 3
            }),
            'status': forms.Select(attrs={
                'class': 'w-full px-4 py-2 border rounded focus:ring-2 focus:ring-blue-500'
            }),
            'priority': forms.Select(attrs={
                'class': 'w-full px-4 py-2 border rounded focus:ring-2 focus:ring-blue-500'
            }),
            'due_date': forms.DateInput(attrs={
                'class': 'w-full px-4 py-2 border rounded focus:ring-2 focus:ring-blue-500',
                'type': 'date'
            }),
        }

The Modal Form Template

<!-- tasks/templates/tasks/partials/task_form.html -->
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
    <div class="bg-white rounded-lg p-8 max-w-2xl w-full mx-4">
        <h2 class="text-2xl font-bold mb-6">
            {% if action == 'create' %}Create New Task{% else %}Edit Task{% endif %}
        </h2>
        
        <form 
            {% if action == 'create' %}
            hx-post="{% url 'task_create' %}"
            {% else %}
            hx-post="{% url 'task_update' task.id %}"
            {% endif %}
            hx-target="#task-container"
            class="space-y-4">
            {% csrf_token %}
            
            {% for field in form %}
            <div>
                <label class="block text-sm font-medium text-gray-700 mb-2">
                    {{ field.label }}
                </label>
                {{ field }}
                {% if field.errors %}
                <p class="text-red-500 text-sm mt-1">{{ field.errors.0 }}</p>
                {% endif %}
            </div>
            {% endfor %}
            
            <div class="flex justify-end gap-4 mt-6">
                <button 
                    type="button"
                    onclick="document.getElementById('modal').innerHTML=''"
                    class="px-4 py-2 border rounded hover:bg-gray-50">
                    Cancel
                </button>
                <button 
                    type="submit"
                    class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
                    {% if action == 'create' %}Create Task{% else %}Update Task{% endif %}
                </button>
            </div>
        </form>
    </div>
</div>

URL Configuration Wire everything together with clean URLs:

# tasks/urls.py
from django.urls import path
from . import views
urlpatterns = [
    path('', views.task_list, name='task_list'),
    path('create/form/', views.task_create_form, name='task_create_form'),
    path('create/', views.task_create, name='task_create'),
    path('<int:pk>/edit/form/', views.task_edit_form, name='task_edit_form'),
    path('<int:pk>/update/', views.task_update, name='task_update'),
    path('<int:pk>/delete/', views.task_delete, name='task_delete'),
]

Advanced Feature: Quick Status Updates Let’s add inline status updates without opening a modal:

# tasks/views.py (add this)
@login_required
@require_http_methods(["POST"])
def task_quick_status(request, pk):
    task = get_object_or_404(Task, pk=pk, user=request.user)
    new_status = request.POST.get('status')
    if new_status in dict(Task.STATUS_CHOICES):
        task.status = new_status
        task.save()
    
    return render(request, 'tasks/partials/task_row.html', {'task': task})

Add a new partial for a single task row:

<!-- tasks/templates/tasks/partials/task_row.html -->
<div id="task-{{ task.id }}" class="bg-white p-4 rounded shadow hover:shadow-lg transition">
    <!-- Same content as in task_list.html but with quick status update -->
    <div class="flex justify-between items-start">
        <div class="flex-1">
            <h3 class="text-xl font-semibold">{{ task.title }}</h3>
            {% if task.description %}
            <p class="text-gray-600 mt-2">{{ task.description }}</p>
            {% endif %}
            <div class="flex gap-4 mt-3">
                <select 
                    name="status"
                    hx-post="{% url 'task_quick_status' task.id %}"
                    hx-target="#task-{{ task.id }}"
                    hx-swap="outerHTML"
                    class="px-2 py-1 text-xs rounded border">
                    <option value="todo" {% if task.status == 'todo' %}selected{% endif %}>To Do</option>
                    <option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>In Progress</option>
                    <option value="done" {% if task.status == 'done' %}selected{% endif %}>Done</option>
                </select>
            </div>
        </div>
    </div>
</div>

Key Takeaways

Building real features with Django and HTMX demonstrates several powerful patterns: Server-Side Rendering Wins: All our logic stays in Python where we have access to our ORM, authentication, and business logic. No need to duplicate validation or authorization in JavaScript.

Progressive Enhancement: Our application works with basic HTML forms and gets enhanced with HTMX. Turn off JavaScript and the core functionality remains intact. Minimal Frontend Complexity: We built dynamic filtering, inline editing, modals, and real-time updates without managing client-side state or dealing with JavaScript frameworks.

Developer Productivity: Changes are made in one place — the server. We write Python code and Django templates, languages we already know well.

What’s Next? In Part 3, we’ll explore adding real-time notifications, implementing optimistic UI updates, and integrating Django Channels for WebSocket support with HTMX. We’ll also dive into performance optimization techniques and caching strategies. The complete code for this tutorial is available on GitHub. Try building these features yourself and experiment with adding pagination, sorting, and bulk operations using the same patterns we’ve established here.


Read the full article here: https://medium.com/@yogeshkrishnanseeniraj/django-htmx-saas-frontend-part-2-building-real-features-d2836f30cd89