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

FeatureHTMX Concept
Add a columnhx-post, hx-swap="beforeend"
Add a cardhx-post, hx-target, hx-swap="beforeend"
Delete a cardhx-delete, hx-swap="outerHTML", hx-confirm
Move a cardhx-put, Out of Band swap, hx-trigger="change"
Edit a card titlehx-get (edit form), hx-put (save), inline editing
Loading indicatorshtmx-indicator, button.htmx-request CSS
Form reset after addhx-on:htmx:after-request with this.reset()
Card count per columnHX-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.

Leave a Comment

Your email address will not be published. Required fields are marked *