HTMX Real Project Walkthrough
HTMX Real Project Walkthrough
Real-world project: a Kanban Board- Users create columns, add cards to each column, move cards between columns, and delete them. The application uses HTMX for all interactions, Flask for the server, and SQLite for storage. Every feature maps directly to a concept you have already learned.
What the App Does
┌───────────────────────────────────────────────────────────┐ │ KANBAN BOARD │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ To Do │ │ In Progress│ │ Done │ │ │ │─────────────│ │─────────────│ │─────────────│ │ │ │ Design logo │ │ Write API │ │ Setup DB │ │ │ │─────────────│ │─────────────│ │─────────────│ │ │ │ Write tests │ │ │ │ Deploy CI │ │ │ │─────────────│ │ │ └─────────────┘ │ │ │[+ Add Card ]│ │[+ Add Card ]│ [+ Add Card ] │ │ └─────────────┘ └─────────────┘ │ │ [+ Add Column] │ └───────────────────────────────────────────────────────────┘
HTMX Concepts Used
| Feature | HTMX Concept |
|---|---|
| Add a column | hx-post, hx-swap="beforeend" |
| Add a card | hx-post, hx-target, hx-swap="beforeend" |
| Delete a card | hx-delete, hx-swap="outerHTML", hx-confirm |
| Move a card | hx-put, Out of Band swap, hx-trigger="change" |
| Edit a card title | hx-get (edit form), hx-put (save), inline editing |
| Loading indicators | htmx-indicator, button.htmx-request CSS |
| Form reset after add | hx-on:htmx:after-request with this.reset() |
| Card count per column | HX-Trigger response header + OOB swap |
Project Structure
kanban/ ├── app.py ├── templates/ │ ├── base.html │ ├── board.html │ └── partials/ │ ├── column.html │ ├── card.html │ ├── card_edit.html │ └── card_count.html └── database.db (auto-created)
Flask Application: app.py
from flask import Flask, render_template, request, jsonify
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
db = SQLAlchemy(app)
class Column(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
cards = db.relationship('Card', backref='column', lazy=True, order_by='Card.position')
class Card(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
column_id = db.Column(db.Integer, db.ForeignKey('column.id'), nullable=False)
position = db.Column(db.Integer, default=0)
with app.app_context():
db.create_all()
# ── Board ────────────────────────────────────────────────────────
@app.route('/')
def board():
columns = Column.query.order_by(Column.id).all()
return render_template('board.html', columns=columns)
# ── Columns ──────────────────────────────────────────────────────
@app.route('/columns', methods=['POST'])
def add_column():
title = request.form.get('title', '').strip()
if not title:
return '<p style="color:red">Title required.</p>', 422
col = Column(title=title)
db.session.add(col)
db.session.commit()
return render_template('partials/column.html', column=col)
@app.route('/columns/<int:col_id>', methods=['DELETE'])
def delete_column(col_id):
col = Column.query.get_or_404(col_id)
db.session.delete(col)
db.session.commit()
return '', 200
# ── Cards ────────────────────────────────────────────────────────
@app.route('/columns/<int:col_id>/cards', methods=['POST'])
def add_card(col_id):
title = request.form.get('title', '').strip()
if not title:
return '<p style="color:red">Title required.</p>', 422
card = Card(title=title, column_id=col_id)
db.session.add(card)
db.session.commit()
count = Card.query.filter_by(column_id=col_id).count()
response = render_template('partials/card.html', card=card)
from flask import Response
resp = Response(response)
import json
resp.headers['HX-Trigger'] = json.dumps({
'card-added': {'column_id': col_id, 'count': count}
})
return resp
@app.route('/cards/<int:card_id>/edit', methods=['GET'])
def edit_card_form(card_id):
card = Card.query.get_or_404(card_id)
return render_template('partials/card_edit.html', card=card)
@app.route('/cards/<int:card_id>', methods=['PUT'])
def update_card(card_id):
card = Card.query.get_or_404(card_id)
title = request.form.get('title', '').strip()
if title:
card.title = title
db.session.commit()
return render_template('partials/card.html', card=card)
@app.route('/cards/<int:card_id>', methods=['DELETE'])
def delete_card(card_id):
card = Card.query.get_or_404(card_id)
col_id = card.column_id
db.session.delete(card)
db.session.commit()
count = Card.query.filter_by(column_id=col_id).count()
from flask import Response
resp = Response('')
import json
resp.headers['HX-Trigger'] = json.dumps({'card-removed': {'column_id': col_id, 'count': count}})
return resp
if __name__ == '__main__':
app.run(debug=True)
Base Template
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kanban Board</title>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
Board Template
<!-- templates/board.html -->
{% extends 'base.html' %}
{% block content %}
<h2>Kanban Board</h2>
<!-- Add Column Form -->
<form hx-post="/columns"
hx-target="#columns-container"
hx-swap="beforeend"
hx-on:htmx:after-request="if(event.detail.successful) this.reset()">
<input type="text" name="title" placeholder="Column name..." required>
<button type="submit">+ Add Column</button>
</form>
<!-- Columns Container -->
<div id="columns-container" style="display:flex; gap:20px; margin-top:20px">
{% for column in columns %}
{% include 'partials/column.html' %}
{% endfor %}
</div>
{% endblock %}
Column Partial
<!-- templates/partials/column.html -->
<div id="column-{{ column.id }}" style="width:250px; border:1px solid #ccc; padding:10px">
<h3>
{{ column.title }}
<small id="card-count-{{ column.id }}">({{ column.cards|length }})</small>
</h3>
<div id="cards-{{ column.id }}">
{% for card in column.cards %}
{% include 'partials/card.html' %}
{% endfor %}
</div>
<!-- Add Card Form -->
<form hx-post="/columns/{{ column.id }}/cards"
hx-target="#cards-{{ column.id }}"
hx-swap="beforeend"
hx-on:htmx:after-request="if(event.detail.successful) this.reset()">
<input type="text" name="title" placeholder="New card..." required>
<button type="submit">+ Add Card</button>
</form>
<button hx-delete="/columns/{{ column.id }}"
hx-target="#column-{{ column.id }}"
hx-swap="outerHTML"
hx-confirm="Delete this entire column and all its cards?">
Delete Column
</button>
</div>
Card Partial
<!-- templates/partials/card.html -->
<div id="card-{{ card.id }}" style="border:1px solid #eee; padding:8px; margin:4px 0">
{{ card.title }}
<button hx-get="/cards/{{ card.id }}/edit"
hx-target="#card-{{ card.id }}"
hx-swap="outerHTML">Edit</button>
<button hx-delete="/cards/{{ card.id }}"
hx-target="#card-{{ card.id }}"
hx-swap="outerHTML"
hx-confirm="Delete this card?">✕</button>
</div>
Card Edit Partial
<!-- templates/partials/card_edit.html -->
<div id="card-{{ card.id }}">
<form hx-put="/cards/{{ card.id }}"
hx-target="#card-{{ card.id }}"
hx-swap="outerHTML">
<input type="text" name="title" value="{{ card.title }}" required>
<button type="submit">Save</button>
</form>
</div>
Listening to Card Count Events
Add this to base.html so card counts update after add or delete:
<script>
document.body.addEventListener('card-added', function(event) {
const { column_id, count } = event.detail;
document.getElementById('card-count-' + column_id).textContent = '(' + count + ')';
});
document.body.addEventListener('card-removed', function(event) {
const { column_id, count } = event.detail;
document.getElementById('card-count-' + column_id).textContent = '(' + count + ')';
});
</script>
Running the App
pip install flask flask-sqlalchemy python app.py # Open http://localhost:5000
Key Takeaway
This Kanban board demonstrates that real, complex applications are buildable with HTMX using the same small set of attributes. Each user interaction maps to one HTMX request: add sends POST, edit sends GET then PUT, delete sends DELETE, and updates to related UI elements travel through HX-Trigger response headers and JavaScript event listeners. The server stays in control of all HTML. The client stays simple. That is the HTMX way — and it scales from a todo list to a full-featured collaborative tool.
