Alpine, HTMX v4, Django stack

Written by:

Context

I was trying to create a simple TODO app using the above mentioned stack just to see how things are working. I did like the stack and it was really good and fast. There are no frontend build and a simple django app.

TLDR

I did not complete and make it work as I while creating a task using the combination of Alpine and HTMX, I could not able to make htmx use alpine to validate in the client side bfore submitting which caused the interoperability issue.

Approach

The making process was simple. Create a traditional django app with its core model of template, not REST api and then convert each server reload to replace with htmx and alpine so that it would work like SPA. Following is the process:

  • Create a Django app
  • Connect with Postgres DB
  • Include Alpine, HTMX and a UI kit like Tabler
  • Create A TODO
Django app

I used “uv” as package manager for django. Following are the commands to set it up.

Bash
uv init --no-package --python 3.13

uv add django

uv run django-admin startproject myapp .

uv run python manage.py startapp tasks
Bash

The project is named as “myapp” and then added an app called “tasks” which will contain all the “todo” related code.

Connecting to Postgres

I was going to use sqlite, but as I need to deploy it to a free service using render, I needed smthng to persist. Render’s free tier will remove the sqlite db file and all the data would vanish. So, I thought of using remote sqlite like Turso, but the recent version of django is not compatible with Turso. Lot of issues were coming up regarding the driver and its incompatibility. So, I moved to Postgres

settings.py
DATABASE_URL = os.environ.get(
    "DATABASE_URL",
    "postgres://postgres:postgres@localhost:5432/alpine-htmx-django"
)

DATABASES = {
    "default": dj_database_url.parse(DATABASE_URL, engine="django.db.backends.postgresql")
}
Python
Frontend

Created templates in Django:

  • Static folder for css, js and images
  • base layout for the html template and all the libraries(alpine, htmx, notyf, etc) and their css files if needed.
  • sidebar layout
  • Snippets/Partials for Header and Menu, so that these can be reused
settings.py
STATIC_URL = 'static/'
# Global static directory here:
STATICFILES_DIRS = [
    BASE_DIR / 'static',
]
# The absolute filesystem path where collectstatic will gather everything for production
STATIC_ROOT = BASE_DIR / 'staticfiles'
Python
templates/layouts/base.html
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Tasks App</title>

    <link rel="stylesheet" href="{% static 'css/notyf.css' %}" />
    <link rel="stylesheet" href="{% static 'css/tabler.css' %}" />

    <script src="{% static 'js/tabler.js' %}"></script>
    <script src="{% static 'js/htmx.js' %}"></script>
    <script src="{% static 'js/notyf.js' %}" defer></script>
    <script src="{% static 'js/alpine.js' %}" defer></script>
    <script defer>
        document.addEventListener('alpine:init', () => {
            // Because of 'defer' ordering, Notyf is 100% loaded and ready here
            const notyfInstance = new Notyf();

            Alpine.magic('toast', () => notyfInstance);
        });
    </script>
</head>
<body>
    <!-- This block allows child layouts to inject their unique wrappers -->
    {% block layout_wrapper %}{% endblock %}
</body>
</html>
Python
Tasks module

The tasks module contains all the tasks related code for creating task

  • tasks/
    • migrations/
      • all the db migrations for the tasks/todos
    • templates/
      • html files for creating task and task list
    • forms.py
      • Server side validation using form model
    • models.py
      • Task model that represents DB table
    • services.py
      • Creating and retrieving tasks
    • views.py
      • View layer for rendering the related templates

This all worked well as the server side saving works all fine. But, the client side validation and then create task did not happen.

tasks/templates/create_task.html
{% extends 'layouts/sidebar_layout.html' %} {% block content %}
<h2>Create Task</h2>

<div class="card">
    <div class="card-header">
        <div>
            <h2 class="card-title">New Task</h2>
            <p class="card-subtitle">Form to create task.</p>
        </div>
    </div>
    <div class="card-body" x-data="taskForm()">
        <form
            class="space-y"
            hx-post="{% url 'create_task' %}"
            hx-select=".sidebar-content"
            hx-target=".sidebar-content"
            hx-swap="outerHTML"
            hx-confirm="validate"
            @htmx:confirm="validateForm($event)" @htmx:after-request="resetForm()">
            {% csrf_token %}
            {% if error %}
                <div class="alert alert-danger">{{ error }}</div>
            {% endif %}
            <div>
                <label class="form-label" for="basic-title">
                    Title
                </label>

                <input
                    type="text"
                    placeholder="Title"
                    class="form-control"
                    id="basic-title"
                    name="title"
                    value="{{form.title.value | default:'' }}"
                    x-model="title"
                />

                {% if form.title.errors %}
                    <div class="error-text" style="color: red;">{{ form.title.errors.0 }}</div>
                {% endif %}
            </div>
            <div>
                <label class="form-label" for="basic-description">
                    Description
                </label>

                <textarea
                    placeholder="Description"
                    rows="6"
                    class="form-control"
                    id="basic-description"
                    name="description"
                    x-model="description"
                >{{form.description.value | default:'' }}</textarea>

                {% if form.description.errors %}
                    <div class="error-text" style="color: red;">{{ form.description.errors.0 }}</div>
                {% endif %}
            </div>
            <div>
                <button type="submit" class="btn btn-primary w-100">
                    Create
                </button>
            </div>
        </form>
    </div>
</div>

<script>
  function taskForm() {
    return {
      title: '',
      description: '',
      errors: {
        title: '',
        description: ''
      },
      isTitleValid() {
        if (!this.title) {
          this.$toast.error('Title cannot be empty')
          return false
        }
        if (this.title.length < 4) {
          this.$toast.error('Title must be atleast 3 characters')
          return false
        }
        return true
      },
      isDescValid() {
        if (!this.title) {
          this.$toast.error('Description cannot be empty')
          return false
        }
        return true
      },
      isFormValid() {
        if (!this.isTitleValid() || !this.isDescValid()) {
          return false
        }
        return true
      },
      resetForm() {
          this.title = '';
          this.description = '';
      }
    }
  }
</script>
{% endblock %}
Python

From line #12 to #19 is creating issue. When I submit the form, it somehow gets to the server side. I tried various approach. In some, the client side validation happens but it does not prevent submission if the validation fails and in one approach, it does not even validate. Although the data does not get saved due to the server side validation, but the SPA part does not work properly.

I will probably take a look at a later time. If this works fine, I will probably complete this.

Deployment

Build the app:

Bash
uv sync --frozen --no-dev && uv cache prune --ci && uv run python manage.py collectstatic --noinput && uv run python manage.py migrate
Bash

Run in production

Bash
uv run gunicorn myapp.asgi:application -k uvicorn.workers.UvicornWorker
Bash

Python supports both asynchronous(non-blocking, uvicorn(asgi)) and synchronous(blocking, gunicorn(wsgi)) code. Simple CRUD app is good for synchronous/wsgi engine, but for high traffic async/asgi is really good. I wanted to see how to use both the aspects of python. As per this code, gunicorn handles the python process whereas uvicorn handles all the requests. This makes our app flexible to add feature at the later stage if needed.

Github Repo

Leave a Reply

Latest Articles

Discover more from My Learnings

Subscribe now to keep reading and get access to the full archive.

Continue reading