@sedatdemirAra

HTMX 2 ile SPA'sız modern web uygulaması

HTMX 2 ile SPA'sız modern web uygulaması

Bir dashboard projesi için React, bundler, state management kütüphanesi, API katmanı, client-side routing kurulumu yapıyorsun. Sonra bakıyorsun: kullanıcı bir tablo satırını tıklayıp detay görecek, bir form gönderecek, bir listeyi filtreleyecek. Bu kadar. Ama karşında 200 MB'lık node_modules dizini, HMR yapılandırması, hydration hataları var. İşte HTMX tam olarak bu noktada devreye giriyor: "Bu uygulama gerçekten bir SPA mı olmalı?" sorusunu ciddiye alan projeler için.

HTMX ne yapıyor, ne yapmıyor

HTMX, HTML elementlerine HTTP istekleri yapma yeteneği kazandırıyor. Bir <button> tıklandığında sunucuya AJAX isteği atıp, dönen HTML parçasını sayfanın belirli bir yerine yerleştiriyor. Bunu yaparken tek satır JavaScript yazmana gerek yok.

HTMX 2.0 (Ocak 2024'te yayınlandı) birkaç önemli değişiklik getirdi: hx-on attribute'ü ile inline event handling, htmx.config.selfRequestsOnly güvenlik varsayılanı (sadece aynı origin'e istek atılıyor), IE desteğinin düşürülmesi ve WebSocket/SSE uzantılarının core'dan ayrılması. Dosya boyutu gzip sonrası yaklaşık 14 KB.

HTMX'in yapmadığı şeyler de var. Client-side state yönetimi yok. Offline çalışma yok. Karmaşık animasyon orkestrasyonu yok. Bunları istiyorsan yanlış araçtasın.

Ne zaman HTMX yeter

Bu soruya net bir cevap vermek gerekiyor: Uygulamanın birincil işi veri gösterme ve form işleme ise HTMX yeter. Bir admin paneli, bir blog CMS'i, bir e-ticaret backoffice, bir raporlama arayüzü, bir CRUD uygulaması. Bu projelerde SPA mimarisi genellikle gereksiz karmaşıklık ekliyor.

HTMX'in yetersiz kaldığı yerler de belirgin: gerçek zamanlı collaborative editing (Google Docs benzeri), offline-first uygulamalar, drag-and-drop yoğun arayüzler, Figma tarzı canvas tabanlı uygulamalar. Bu senaryolarda client-side state kaçınılmaz.

Arada kalan gri bölge var tabii. Bir e-ticaret sitesinin ürün listeleme sayfası HTMX ile gayet iyi çalışır, ama sepet yönetimi biraz daha düşünce ister. Bu durumda HTMX ile Alpine.js gibi minimal bir kütüphaneyi birleştirmek işe yarıyor.

Go ile HTMX backend

Go, HTMX ile doğal bir uyum gösteriyor. html/template paketi sunucu tarafında HTML üretmeyi kolaylaştırıyor, HTTP standart kütüphanesi yeterince güçlü. Bir todo uygulaması üzerinden görelim:

package main

import (
	"html/template"
	"net/http"
	"strconv"
	"sync"
)

type Todo struct {
	ID   int
	Text string
	Done bool
}

var (
	todos  []Todo
	nextID = 1
	mu     sync.Mutex
)

var todoItemTmpl = template.Must(template.New("item").Parse(`
<li id="todo-{{.ID}}" class="todo-item">
	<span>{{.Text}}</span>
	<button hx-delete="/todos/{{.ID}}"
		hx-target="#todo-{{.ID}}"
		hx-swap="outerHTML">Sil</button>
</li>
`))

var pageTmpl = template.Must(template.New("page").Parse(`
<!DOCTYPE html>
<html>
<head>
	<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
	<h1>Yapılacaklar</h1>
	<form hx-post="/todos" hx-target="#todo-list" hx-swap="beforeend" hx-on::after-request="this.reset()">
		<input name="text" required placeholder="Yeni görev...">
		<button type="submit">Ekle</button>
	</form>
	<ul id="todo-list">
		{{range .}}
		<li id="todo-{{.ID}}" class="todo-item">
			<span>{{.Text}}</span>
			<button hx-delete="/todos/{{.ID}}"
				hx-target="#todo-{{.ID}}"
				hx-swap="outerHTML">Sil</button>
		</li>
		{{end}}
	</ul>
</body>
</html>
`))

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		mu.Lock()
		defer mu.Unlock()
		pageTmpl.Execute(w, todos)
	})

	http.HandleFunc("/todos", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.Error(w, "Method not allowed", 405)
			return
		}
		text := r.FormValue("text")
		if text == "" {
			http.Error(w, "Text required", 400)
			return
		}
		mu.Lock()
		todo := Todo{ID: nextID, Text: text}
		nextID++
		todos = append(todos, todo)
		mu.Unlock()
		todoItemTmpl.Execute(w, todo)
	})

	http.HandleFunc("/todos/", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodDelete {
			http.Error(w, "Method not allowed", 405)
			return
		}
		idStr := r.URL.Path[len("/todos/"):]
		id, _ := strconv.Atoi(idStr)
		mu.Lock()
		for i, t := range todos {
			if t.ID == id {
				todos = append(todos[:i], todos[i+1:]...)
				break
			}
		}
		mu.Unlock()
		w.WriteHeader(200)
	})

	http.ListenAndServe(":8080", nil)
}

Dikkat edilecek nokta: sunucu JSON değil, HTML döndürüyor. POST /todos endpoint'i yeni eklenen todo'nun HTML'ini döndürüyor, HTMX bunu #todo-list elementinin sonuna ekliyor. DELETE /todos/3 ise boş bir 200 yanıtı döndürüyor, hx-swap="outerHTML" ile hedef element DOM'dan kalkıyor.

Burada JavaScript sıfır. Form gönderimi, silme işlemi, DOM güncellemesi hep HTML attribute'leri üzerinden yönetiliyor.

Hono ile HTMX backend

Hono, TypeScript ile yazılmış ve Cloudflare Workers, Deno, Bun, Node.js gibi birçok runtime'da çalışan bir web framework. HTMX ile birlikte kullanmak isteyenler için JSX desteği bir avantaj: template string'leri yerine JSX ile HTML üretebilirsiniz.

import { Hono } from "hono";
import { html } from "hono/html";

const app = new Hono();

type Contact = { id: number; name: string; email: string };
let contacts: Contact[] = [
  { id: 1, name: "Ali Veli", email: "ali@test.com" },
  { id: 2, name: "Ayşe Fatma", email: "ayse@test.com" },
];
let nextId = 3;

const contactRow = (c: Contact) => html`
  <tr id="contact-${c.id}">
    <td>${c.name}</td>
    <td>${c.email}</td>
    <td>
      <button
        hx-delete="/contacts/${c.id}"
        hx-target="#contact-${c.id}"
        hx-swap="outerHTML swap:500ms"
        hx-confirm="Silmek istediğinize emin misiniz?"
      >
        Sil
      </button>
    </td>
  </tr>
`;

const page = (contactList: Contact[]) => html`
  <!doctype html>
  <html>
    <head>
      <script src="https://unpkg.com/htmx.org@2.0.4"></script>
    </head>
    <body>
      <h1>Kişiler</h1>

      <input
        type="search"
        name="search"
        placeholder="Ara..."
        hx-get="/contacts/search"
        hx-trigger="input changed delay:300ms"
        hx-target="#contact-table"
      />

      <table>
        <thead>
          <tr>
            <th>İsim</th>
            <th>Email</th>
            <th></th>
          </tr>
        </thead>
        <tbody id="contact-table">
          ${contactList.map(contactRow)}
        </tbody>
      </table>
    </body>
  </html>
`;

app.get("/", (c) => c.html(page(contacts)));

app.get("/contacts/search", (c) => {
  const q = (c.req.query("search") || "").toLowerCase();
  const filtered = contacts.filter(
    (ct) =>
      ct.name.toLowerCase().includes(q) ||
      ct.email.toLowerCase().includes(q)
  );
  const rows = filtered.map(contactRow).join("");
  return c.html(rows);
});

app.delete("/contacts/:id", (c) => {
  const id = parseInt(c.req.param("id"));
  contacts = contacts.filter((ct) => ct.id !== id);
  return c.body("", 200);
});

export default app;

Bu örnekte arama kutusuna yazıldığında 300ms debounce ile sunucuya istek gidiyor, filtrelenmiş tablo satırları HTML olarak dönüyor ve #contact-table tbody'si güncelleniyor. Hono'nun html tagged template literal'ı burada template engine görevi görüyor.

Bu kodu Cloudflare Workers + D1 ile deploy ettiğinizde edge'de çalışan, veritabanı sorgulayan, HTML dönen bir uygulama elde ediyorsunuz. Client bundle boyutu: 14 KB (HTMX'in kendisi).

HTMX'in temel attribute'leri

HTMX 2.0'daki en çok kullanılan attribute'ler ve ne yaptıkları:

hx-get, hx-post, hx-put, hx-delete, hx-patch belirtilen URL'e ilgili HTTP metodu ile istek atar. hx-target dönen HTML'in yerleştirileceği CSS seçicisini belirler, varsayılan olarak isteği yapan elementin kendisidir. hx-swap yerleştirme stratejisini kontrol eder: innerHTML (varsayılan), outerHTML, beforeend, afterbegin, delete, none gibi seçenekler var.

hx-trigger attribute'ü isteklerin ne zaman tetikleneceğini belirler. Varsayılan olarak form'larda submit, input'larda change, diğer elementlerde click event'idir. Ama özelleştirilebilir:

<!-- Her 5 saniyede bir polling -->
<div hx-get="/notifications" hx-trigger="every 5s" hx-target="#notif-count">
</div>

<!-- Sayfa yüklendiğinde (lazy loading) -->
<div hx-get="/dashboard/stats" hx-trigger="load" hx-swap="innerHTML">
  Yükleniyor...
</div>

<!-- Element viewport'a girdiğinde -->
<div hx-get="/comments" hx-trigger="revealed">
</div>

hx-indicator attribute'ü ile istek süresince bir loading göstergesi gösterilebilir. HTMX istek başladığında htmx-request CSS class'ını ekliyor, bitince kaldırıyor.

HTMX 2.0 ile gelen hx-on

HTMX 2.0'ın dikkat çekici özelliklerinden biri hx-on attribute'ü. Eskiden HTMX event'lerini dinlemek için JavaScript ile document.addEventListener kullanmak gerekiyordu. Artık inline olarak yazılabiliyor:

<form hx-post="/todos"
      hx-target="#todo-list"
      hx-swap="beforeend"
      hx-on::after-request="this.reset()">
  <input name="text" required>
  <button type="submit">Ekle</button>
</form>

hx-on::after-request form gönderildikten sonra formu sıfırlıyor. hx-on::before-request, hx-on::after-settle gibi lifecycle event'lerinin hepsi bu şekilde kullanılabiliyor.

SPA framework'leri ile karşılaştırma

Bir React veya Next.js uygulaması ile HTMX uygulamasını karşılaştırmak biraz elmalarla armutları karşılaştırmak gibi olabilir ama pratik açıdan bakıldığında fark edilen şeyler var.

Bir HTMX uygulamasında build adımı yok. Bundler yok. node_modules yok (backend tarafı Go veya benzeri bir dil ise). Hot module replacement'a ihtiyaç yok çünkü sunucu tarafı template'leri düzenliyorsunuz ve tarayıcıyı yeniliyorsunuz.

Buna karşılık, SPA'ların gerçekten iyi olduğu yerler var. Optimistic UI güncellemeleri React'ta useOptimistic ile doğal bir şekilde çözülür. HTMX'te bunu yapabilirsiniz ama el ile CSS class'ları ve hx-on event'leri ile uğraşırsınız. Karmaşık form state'i, çok adımlı wizard'lar, client-side validation senaryolarında SPA framework'leri daha ergonomik.

Partial template render pattern

HTMX ile çalışırken en sık karşılaşılan mimari soru şu: aynı template'i hem tam sayfa yüklemesinde hem de HTMX isteğinde kullanmak. Çözüm basit: HX-Request header'ını kontrol etmek.

func handleTodos(w http.ResponseWriter, r *http.Request) {
	todos := getTodosFromDB()

	if r.Header.Get("HX-Request") == "true" {
		// Sadece todo listesini döndür
		todoListTmpl.Execute(w, todos)
		return
	}
	// Tam sayfayı döndür (layout + todo listesi)
	fullPageTmpl.Execute(w, todos)
}

Bu pattern sayesinde ilk sayfa yüklemesinde tam HTML dönersiniz (SEO dostu), sonraki etkileşimlerde sadece değişen parçayı dönersiniz. Server-side rendering ile partial update'lerin birleşimi.

HTMX ile birlikte kullanılan araçlar

HTMX tek başına yeterli olmadığı durumlarda genellikle Alpine.js ile birlikte kullanılıyor. Alpine.js, client-side'da dropdown toggle, modal açma kapama, tab switching gibi küçük etkileşimleri hallediyor. HTMX sunucu iletişimini, Alpine.js client-side state'i yönetiyor.

Template engine tarafında Go için html/template veya templ (type-safe Go template kütüphanesi), Python için Jinja2, Hono için built-in html helper'ı yaygın tercihler.

CSS tarafında Tailwind CSS doğal bir eşleşme çünkü template dosyalarında class'ları direkt yazıyorsunuz. Bir component kütüphanesi import etmenize gerek yok.

Dikkat edilecek noktalar

HTMX'e geçerken birkaç tuzak var. İlki: sunucunun her istekte HTML döndürmesi, REST API'nizin başka client'lar (mobil uygulama, üçüncü parti entegrasyon) tarafından kullanılamayacağı anlamına gelir. İki ayrı API katmanı yazmak istemiyorsanız bunu baştan planlayın.

İkincisi: HTMX istekleri arasında client-side state kaybolur. Bir filtreleme formunda kullanıcı birkaç dropdown seçtiyse ve tablo güncelleniyorsa, dropdown değerlerinin korunması template tarafında halledilmeli. Sunucu, döndürdüğü HTML'de seçili değerleri selected attribute'ü ile işaretlemeli.

Üçüncüsü: test etme stratejisi değişiyor. React uygulamalarında Jest veya Vitest ile component testleri yazarsınız. HTMX uygulamalarında backend handler testleri (dönen HTML'in doğruluğu) ve Playwright/Cypress ile end-to-end testler ön plana çıkıyor.

Projeniz bir CRUD uygulaması, bir admin paneli veya içerik odaklı bir site ise HTMX ile başlayın. React ekosisteminin getirdiği karmaşıklığa ihtiyacınız olmadığını göreceksiniz. Eğer proje zamanla gerçek zamanlı işbirliği, offline mod veya karmaşık client-side state gerektirmeye başlarsa, o zaman SPA'ya geçiş yaparsınız. Ama birçok projede o gün hiç gelmeyecek.