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.
uv init --no-package --python 3.13
uv add django
uv run django-admin startproject myapp .
uv run python manage.py startapp tasksBashThe 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
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")
}PythonFrontend
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
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{% 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>
PythonTasks 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
- migrations/
This all worked well as the server side saving works all fine. But, the client side validation and then create task did not happen.
{% 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 %}
PythonFrom 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:
uv sync --frozen --no-dev && uv cache prune --ci && uv run python manage.py collectstatic --noinput && uv run python manage.py migrateBashRun in production
uv run gunicorn myapp.asgi:application -k uvicorn.workers.UvicornWorkerBashPython 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.
Leave a Reply