Django + HTMX + Alpine.js: The Modern Full-Stack Stack untuk Python Developer

Bangun aplikasi Django ala SPA dengan 22KB JavaScript: tanpa React, tanpa build step. Panduan praktis HTMX 4.0 + Alpine.js untuk Python developer.

TL;DR: Django + HTMX + Alpine.js (dikenal juga sebagai DHAT atau AHA stack) adalah arsitektur full-stack paling pragmatis untuk Python developer di 2026. Dengan total payload client-side hanya ~22KB gzipped (HTMX 14KB + Alpine.js 8KB), kamu bisa membangun aplikasi dengan interaktivitas setara SPA tanpa perlu React, Vue, atau Svelte. HTMX 4.0 yang stabil sejak Agustus 2026 membawa native morphing swaps, Fetch API transport, dan streaming HTML — membuat kasus untuk server-rendered interactivity semakin kuat.


Kenapa React Bukan Jawaban untuk Semua Masalah Django

Selama satu dekade terakhir, narasi default untuk “aplikasi web modern” selalu sama: pisahkan frontend dan backend. React (atau Vue, atau Svelte) di satu sisi, Django REST Framework (DRF) di sisi lain. Dua server, dua codebase, dua tim skill set yang berbeda.

Pola itu masuk akal untuk aplikasi dengan kompleksitas tinggi — dashboard real-time, kanvas kolaboratif, atau aplikasi dengan drag-and-drop state management. Tapi untuk 80% proyek Django (CRUD, CMS, SaaS backend, portal berita, toko online), arsitektur SPA terpisah adalah over-engineering yang membayar pajak kompleksitas setiap hari:

  • Validasi ditulis dua kali: sekali di serializers Django, sekali di form React
  • State management boilerplate: Redux / Zustand / Jotai hanya untuk modal dan form
  • Build toolchain: Webpack / Vite, Babel, TypeScript config
  • Dua deployment: backend Django + static frontend
  • Dua tim atau dua skill set per developer

HTMX + Alpine.js menawarkan jalan keluar. Kamu tetap di Django, tetap pakai Django Templates, tetap punya server-authoritative state — dan dapat interaktivitas modern tanpa meninggalkan ekosistem Python.

Arsitektur: Dua Layer Interaktivitas

Kunci memahami DHAT stack adalah pembagian tanggung jawab yang tegas:

LayerToolsTanggung Jawab
ServerDjango, Django TemplatesBusiness logic, data, validasi, rendering HTML
HypermediaHTMXServer-driven interactions: form submit, pagination, search, lazy load
Client stateAlpine.jsClient-side UI state: toggle, modal, dropdown, inline editing

Aturan emasnya: HTMX untuk server round-trip, Alpine.js untuk local interaction. Kalau data harus ke server, pakai HTMX. Kalau cuma buka-tutup dropdown, pakai Alpine.

Kenapa HTMX 4.0 Jadi Game Changer

HTMX 4.0 (stabil Agustus 2026) membawa tiga perubahan besar yang membuat stack ini jauh lebih matang:

  1. Fetch API menggantikan XMLHttpRequest — performance lebih baik, error handling lebih bersih, dan support untuk streaming response
  2. Morphing swaps built-in — hx-swap="morph" tanpa perlu idiomorph extension. HTMX sekarang bisa melakukan DOM diffing cerdas: hanya mengganti elemen yang berubah, bukan mengganti seluruh target
  3. <hx-partial> — elemen baru untuk mengupdate multiple DOM targets dari satu response. Sebelumnya, satu response HTMX cuma bisa mengupdate satu target. Sekarang, satu endpoint bisa mengirim update ke beberapa bagian halaman sekaligus
<!-- Sebelum HTMX 4: satu response, satu target -->
<div hx-get="/stats" hx-target="#stats">
  Load Stats
</div>

<!-- HTMX 4: satu response, multiple targets -->
<hx-partial id="stats-container"></hx-partial>
<hx-partial id="notifications-badge"></hx-partial>

Di sisi server, Django cukup return response dengan <template hx-target="..."> wrapper:

def dashboard_stats(request):
    return render(request, "dashboard/_stats_partial.html", {
        "stats": get_stats(request.user),
        "notifications": get_unread_count(request.user),
    })

Template _stats_partial.html:

<template hx-target="#stats-container">
  {% include "dashboard/_stats.html" %}
</template>
<template hx-target="#notifications-badge">
  {% include "dashboard/_notifications.html" %}
</template>

Dual-Purpose Views: Pola Arsitektur Inti

Pola paling penting yang harus kamu kuasai adalah dual-purpose views: satu view yang bisa return full page ATAU partial fragment tergantung apakah request datang dari HTMX.

# views.py
from django.shortcuts import render, redirect
from django.views.decorators.http import require_http_methods
from .models import Task
from .forms import TaskForm

@require_http_methods(["GET", "POST"])
def task_list(request):
    tasks = Task.objects.filter(user=request.user).select_related("project")
    
    if request.method == "POST":
        form = TaskForm(request.POST)
        if form.is_valid():
            form.instance.user = request.user
            form.save()
            if request.htmx:
                # HTMX: return partial — list item baru sudah termasuk
                return render(request, "tasks/_task_list.html", {"tasks": tasks})
            return redirect("task-list")
    else:
        form = TaskForm()
    
    if request.htmx:
        return render(request, "tasks/_task_list.html", {"tasks": tasks})
    
    return render(request, "tasks/task_list.html", {
        "tasks": tasks,
        "form": form,
    })

Perhatikan polanya: view yang sama, validasi yang sama, business logic yang sama. Zero duplication. Yang berbeda cuma template yang dirender — full page atau partial.

Agar request.htmx bisa dipakai, pasang django-htmx:

# settings.py
INSTALLED_APPS = [
    ...
    "django_htmx",
    ...
]

MIDDLEWARE = [
    ...
    "django_htmx.middleware.HtmxMiddleware",
    ...
]

Partial Template Organization

Semakin banyak partial, semakin penting organisasi. Dua pendekatan yang bisa dipakai:

1. Naming convention: Prefix file partial dengan underscore

templates/tasks/
├── task_list.html          # Full page
├── _task_list.html         # Partial — daftar task
├── _task_row.html          # Partial — satu baris task
├── _search_results.html    # Partial — hasil pencarian
└── _task_form.html         # Partial — form task

2. django-template-partials (sekarang built-in di Django 6.0): Inline partial dalam template yang sama

{% extends "base.html" %}

{% partialdef task-list %}
  {% for task in tasks %}
    {% include "tasks/_task_row.html" %}
  {% empty %}
    <p class="text-muted">Belum ada task.</p>
  {% endfor %}
{% endpartialdef %}

{% block content %}
  <h1>Task List</h1>
  <div id="task-list">
    {% partial task-list %}
  </div>
{% endblock %}

View HTMX cukup return {% partial task-list %} — Django hanya merender partial itu, bukan seluruh halaman.

Form Submission dengan HTMX + Django Validation

Form adalah use case paling umum HTMX. Berikut pola lengkapnya:

{% load django_htmx %}
<form hx-post="{% url 'task-create' %}"
      hx-target="#task-list"
      hx-swap="outerHTML"
      hx-on::after-request="if(event.detail.successful) this.reset()">
  {% csrf_token %}
  <div class="form-group">
    <input type="text" name="title"
           class="input {% if form.title.errors %}is-invalid{% endif %}"
           placeholder="Nama task..."
           required />
    {% if form.title.errors %}
      <div class="invalid-feedback">{{ form.title.errors.0 }}</div>
    {% endif %}
  </div>
  <button type="submit" class="btn btn-primary">Tambah Task</button>
</form>

Yang perlu diperhatikan:

  • hx-target="#task-list" — response dari server akan menggantikan elemen dengan id task-list
  • hx-swap="outerHTML" — ganti seluruh elemen target, bukan isinya
  • hx-on::after-request — reset form setelah submit sukses, tanpa reload halaman
  • CSRF tetap wajib — HTMX mengirim header X-CSRFToken otomatis kalau ada atribut hx-headers atau cookie

Kalau validasi gagal, server return partial dengan error yang sudah dirender — HTMX swap ke target yang sama, dan user melihat errornya tanpa page reload.

Real-Time Search dengan HTMX Debounce

Search dengan feedback instant tanpa JavaScript custom:

<input type="search" name="q" placeholder="Cari task..."
       hx-get="{% url 'task-search' %}"
       hx-trigger="input changed delay:300ms, search"
       hx-target="#search-results"
       hx-indicator="#search-spinner"
       hx-push-url="true" />
<div id="search-spinner" class="htmx-indicator">
  Mencari...
</div>
<div id="search-results">
  {% include "tasks/_search_results.html" %}
</div>
# views.py
from django.views.decorators.http import require_http_methods

@require_http_methods(["GET"])
def task_search(request):
    query = request.GET.get("q", "").strip()
    tasks = Task.objects.filter(user=request.user)
    if query:
        tasks = tasks.filter(title__icontains=query)
    return render(request, "tasks/_search_results.html", {
        "tasks": tasks[:50],
        "query": query,
    })

Detail penting:

  • hx-trigger="input changed delay:300ms, search" — debounce 300ms, tapi juga trigger di event “search” (enter key atau klik tombol search di browser)
  • hx-indicator="#search-spinner" — elemen dengan class htmx-indicator otomatis tampil selama request berlangsung
  • hx-push-url="true" — URL berubah, jadi user bisa bookmark hasil pencarian atau pakai tombol back

Alpine.js: Client-Side State yang HTMX Tidak Bisa Sentuh

HTMX unggul untuk interaksi yang butuh server. Tapi untuk state yang murni client-side — dropdown terbuka atau tertutup, tab aktif, checkbox “select all” — Alpine.js adalah jawabannya.

Mengirim Data Server ke Alpine: Aturan #1

Jangan pernah pakai string interpolation untuk ngirim data dari Django ke Alpine. Ini adalah footgun keamanan dan reliability:

{/* ❌ JANGAN: string interpolation */}
<div x-data="{ name: '{{ task.name|escapejs }}' }">...</div>

Escapejs tetap bisa gagal kalau data mengandung karakter khusus. Lebih penting lagi, pola ini rawan XSS kalau ada celah di escapejs atau lupa di-escape.

✅ Yang benar: json_script + JSON.parse()

{{ projects|json_script:"projects-data" }}

<div x-data="{
    projects: JSON.parse(document.getElementById('projects-data').textContent),
    selectedProjects: [],
    isOpen: false,

    selectAll() {
      this.selectedProjects = this.isAllSelected ? [] : this.projects.map(p => p.id);
    },

    toggleProject(id) {
      const idx = this.selectedProjects.indexOf(id);
      idx === -1
        ? this.selectedProjects.push(id)
        : this.selectedProjects.splice(idx, 1);
    },

    get isAllSelected() {
      return this.projects.length > 0
        && this.selectedProjects.length === this.projects.length;
    },

    get count() {
      return this.selectedProjects.length;
    }
  }">

  <button @click="isOpen = !isOpen" class="btn">
    Batch Actions ({{ '{{ count }}' }} selected)
  </button>

  <template x-if="isOpen">
    <div class="batch-actions-panel">
      <button @click="selectedProjects = []">Clear</button>
      <button @click="$dispatch('bulk-archive', { ids: selectedProjects })">
        Archive ({{ '{{ count }}' }})
      </button>
    </div>
  </template>

  <table>
    <thead>
      <tr>
        <th><input type="checkbox" @click="selectAll()"
                   :checked="isAllSelected"></th>
        <th>Nama Project</th>
      </tr>
    </thead>
    <tbody>
      <template x-for="project in projects" :key="project.id">
        <tr>
          <td><input type="checkbox"
                     :checked="selectedProjects.includes(project.id)"
                     @click="toggleProject(project.id)"></td>
          <td x-text="project.name"></td>
        </tr>
      </template>
    </tbody>
  </table>
</div>

Kenapa json_script lebih aman? Django’s json_script template filter menggunakan json.dumps() yang menghasilkan JSON valid — handling string, angka, boolean, null, nested objects, dan array dengan benar. Data dilewatkan sebagai <script type="application/json"> yang tidak dirender secara visual dan tidak bisa dieksekusi sebagai JavaScript. JSON.parse() di sisi client mengubahnya kembali ke objek JavaScript yang aman.

Inline Editing: Alpine + HTMX Bekerja Sama

Pola klasik: klik teks → berubah jadi input → submit via HTMX → kembali jadi teks.

{% load static %}

<div x-data="{ editing: false, title: '{{ task.title|escapejs }}' }">
  <template x-if="!editing">
    <div class="inline-display">
      <span x-text="title" class="task-title"></span>
      <button @click="editing = true" class="btn-icon">
        ✏️
      </button>
    </div>
  </template>

  <template x-if="editing">
    <form hx-post="{% url 'task-update' task.id %}"
          hx-target="closest div"
          hx-swap="outerHTML"
          hx-on::after-request="if(event.detail.successful) editing = false">
      {% csrf_token %}
      <input type="text" name="title"
             :value="title"
             x-init="$el.focus()"
             class="input inline-input" />
      <button type="submit" class="btn btn-sm btn-primary">Save</button>
      <button type="button" @click="editing = false" class="btn btn-sm">
        Cancel
      </button>
    </form>
  </template>
</div>

Alpine manage state editing (buka/tutup form). HTMX handle submit ke server. Kalau sukses, server return HTML terbaru dan Alpine editing balik ke false.

Common Pitfalls yang Harus Dihindari

1. HTMX 4.0 Implicit Inheritance

Di HTMX 2.x, hx-headers dan hx-* attribute pada parent otomatis diwariskan ke child elements. HTMX 4.0 mengubah ini — inheritance harus eksplisit.

{# ❌ HTMX 4: hx-headers di parent tidak diwariskan otomatis #}
<div hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
  <button hx-get="/api/data" hx-target="#result">
    Load Data  {# Ini tidak akan mengirim headers #}
  </button>
</div>

{# ✅ Perbaikan: pakai :inherited suffix #}
<div hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
  <button hx-get="/api/data" hx-target="#result" hx-headers=":inherited">
    Load Data
  </button>
</div>

Atau set implicitInheritance: true di konfigurasi HTMX global.

2. Template Sprawl

Partial bertambah cepat. Dalam seminggu kamu bisa punya 20+ file partial. Atasi dengan dua strategi:

  • Naming convention ketat: _nama_view.html untuk partial, nama_view.html untuk full page
  • django-template-partials: Simpan partial inline di template utama dengan {% partialdef %}, jangan di file terpisah, untuk partial yang cuma dipakai satu tempat

3. Lupa hx-push-url

HTMX partial swap TIDAK mengupdate URL browser secara default. Kalau navigasi harus bookmarkable:

<a hx-get="{% url 'task-list' %}?page=2"
   hx-target="#task-list"
   hx-push-url="true">
  Halaman 2
</a>

4. Konflik Request

Dua input yang masing-masing trigger HTMX request bisa saling berebut. Gunakan hx-sync:

<form hx-sync="this:replace">
  <input name="search" hx-get="/search" hx-trigger="keyup changed delay:300ms">
  <select name="filter" hx-get="/search" hx-trigger="change">
    ...
  </select>
</form>

hx-sync="this:replace" — request baru membatalkan request sebelumnya di form yang sama.

5. Alpine Async Init Trap

{# ❌ Ini SET data ke Promise, bukan hasil fetch #}
<div x-data="{ data: fetch('/api/data').then(r => r.json()) }">
{# ✅ Pakai async init() #}
<div x-data="{
    data: [],
    async init() {
      this.data = await (await fetch('/api/data')).json();
    }
  }">

6. Testing Partial Membutuhkan Fragment-Aware Assertions

# tests.py
from django.test import TestCase, Client

class TaskHTMXTests(TestCase):
    def test_task_search_returns_partial(self):
        client = Client()
        # Kirim request dengan header HX-Request
        response = client.get(
            "/tasks/search/?q=test",
            HTTP_HX_REQUEST="true",
        )
        self.assertEqual(response.status_code, 200)
        # ✅ Cek bahwa response adalah partial, bukan full page
        self.assertContains(response, "<li")  # task item
        self.assertNotContains(response, "<html")  # bukan full page

Kapan Stack Ini Cocok — dan Kapan Tidak

Cocok untuk:

  • CRUD apps — dashboard admin, CMS, portal berita
  • SaaS backends — customer portal, billing UI, team settings
  • Form-heavy apps — aplikasi survey, aplikasi pendaftaran
  • Read-heavy dashboards — monitoring, analytics, reporting
  • Prototyping — dari ide ke production dalam hari, bukan minggu

Tidak cocok untuk:

  • Complex client-side state — kanvas kolaboratif (Figma-style), real-time multiplayer game
  • Drag-and-drop reordering — butuh DOM manipulation yang lebih canggih dari HTMX morphing
  • Optimistic updates with offline queuing — aplikasi yang harus jalan saat offline
  • Mobile-first SPA dengan complex routing — React/Next.js masih lebih cocok

Batasnya tegas: kalau 90% interaksi user butuh server, pakai DHAT. Kalau 90% interaksi bisa jalan tanpa server, pakai React/Svelte.

Kesimpulan

Django + HTMX + Alpine.js adalah arsitektur yang mengembalikan server-authoritative state ke tempatnya — di server — tanpa mengorbankan pengalaman pengguna modern. Dengan HTMX 4.0 yang matang, Alpine.js untuk client-side gaps, dan Django sebagai otak aplikasi, kamu bisa membangun aplikasi yang:

  • Punya interaktivitas setara SPA
  • Tanpa dual codebase (backend + frontend)
  • Dengan payload 22KB (vs React ~120KB gzipped + router + state management)
  • Dengan validasi di SATU tempat (Django forms)
  • Yang bisa di-deploy sebagai satu aplikasi, bukan dua

Untuk 80% proyek Django, ini adalah arsitektur paling produktif yang bisa kamu pilih di 2026.

Mulai dari sini:

pip install django-htmx
# settings.py
INSTALLED_APPS += ["django_htmx"]
MIDDLEWARE += ["django_htmx.middleware.HtmxMiddleware"]
{% load django_htmx %}
<script src="https://unpkg.com/htmx.org@4.0.0"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>

Siap coding. 🚀


Punya pengalaman migrasi dari React/DRF ke DHAT? Atau nemu pola yang lebih elegant? Share di komentar!

tulisan-terkait

↑