Astro กับ HTMX: Hypermedia-driven Interactivity

#astro13 เม.ย. 2569

Astro กับ HTMX: Hypermedia-driven Interactivity

HTMX เป็น library ที่ให้ HTML เข้าถึง AJAX, CSS Transitions, WebSockets, และ Server Sent Events โดยตรงผ่าน attributes ทำให้สามารถสร้าง dynamic web applications โดยไม่ต้องเขียน JavaScript มาก บทความนี้จะแนะนำการใช้ HTMX กับ Astro SSR

ทำไมต้องใช้ HTMX?

  • HTML-first: เพิ่ม interactivity ผ่าน HTML attributes
  • No JavaScript Required: ลด JavaScript ที่ต้องเขียน
  • Server-side Rendering: server ส่ง HTML fragments กลับมา
  • Progressive Enhancement: ทำงานได้แม้ JavaScript ปิด
  • Small Size: ~14KB minified+gzipped

การติดตั้ง

---
// src/layouts/BaseLayout.astro
---

<html lang="th">
  <head>
    <script
      src="https://unpkg.com/htmx.org@1.9.12"
      integrity="sha384-..."
      crossorigin="anonymous"
      is:inline
    ></script>
  </head>
  <body>
    <slot />
  </body>
</html>

หรือผ่าน npm:

npm install htmx.org
<script>
  import 'htmx.org';
</script>

HTMX Attributes พื้นฐาน

---
// src/pages/demo.astro
---

<html lang="th">
  <body>
    <!-- hx-get: ส่ง GET request และแทนที่ content -->
    <button
      hx-get="/api/time"
      hx-target="#time-display"
      hx-swap="innerHTML"
    >
      ดูเวลาปัจจุบัน
    </button>
    <div id="time-display">คลิกปุ่มเพื่อดูเวลา</div>

    <!-- hx-post: ส่ง POST request -->
    <form
      hx-post="/api/subscribe"
      hx-target="#subscribe-result"
      hx-swap="outerHTML"
    >
      <input type="email" name="email" placeholder="อีเมลของคุณ" required />
      <button type="submit">สมัครรับข่าวสาร</button>
    </form>
    <div id="subscribe-result"></div>

    <!-- hx-trigger: กำหนด event ที่ trigger request -->
    <input
      type="search"
      name="q"
      hx-get="/api/search"
      hx-trigger="keyup changed delay:300ms"
      hx-target="#search-results"
      placeholder="ค้นหา..."
    />
    <div id="search-results"></div>
  </body>
</html>

API Routes ที่ส่ง HTML Fragments

// src/pages/api/time.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = () => {
  const now = new Date().toLocaleString('th-TH', {
    dateStyle: 'full',
    timeStyle: 'medium',
  });

  return new Response(`<span class="text-blue-600 font-bold">${now}</span>`, {
    headers: { 'Content-Type': 'text/html' },
  });
};
// src/pages/api/search.ts
import type { APIRoute } from 'astro';
import { searchPosts } from '../../lib/search';

export const GET: APIRoute = async ({ url }) => {
  const query = url.searchParams.get('q') ?? '';

  if (!query.trim()) {
    return new Response('<p class="text-gray-500">พิมพ์เพื่อค้นหา...</p>', {
      headers: { 'Content-Type': 'text/html' },
    });
  }

  const results = await searchPosts(query);

  if (results.length === 0) {
    return new Response(`<p class="text-gray-500">ไม่พบผลลัพธ์สำหรับ "${query}"</p>`, {
      headers: { 'Content-Type': 'text/html' },
    });
  }

  const html = `
    <ul class="divide-y">
      ${results
        .map(
          (post) => `
        <li class="py-3">
          <a href="/blog/${post.slug}" class="hover:text-blue-600">
            <h3 class="font-medium">${post.title}</h3>
            <p class="text-sm text-gray-500">${post.excerpt}</p>
          </a>
        </li>
      `
        )
        .join('')}
    </ul>
  `;

  return new Response(html, {
    headers: { 'Content-Type': 'text/html' },
  });
};

async function searchPosts(query: string) {
  return [];
}

Infinite Scroll

---
// src/pages/blog/index.astro
import { getPosts } from '../../lib/posts';

const posts = await getPosts({ limit: 10, page: 1 });
---

<html lang="th">
  <body>
    <h1>บทความ</h1>
    
    <div id="posts-container">
      {posts.map((post) => (
        <article class="post-card">
          <h2><a href={`/blog/${post.slug}`}>{post.title}</a></h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
      
      <!-- Trigger สำหรับ infinite scroll -->
      <div
        hx-get="/api/posts?page=2"
        hx-trigger="revealed"
        hx-target="#posts-container"
        hx-swap="beforeend"
        hx-indicator="#loading"
      >
        <div id="loading" class="htmx-indicator text-center py-4">
          กำลังโหลด...
        </div>
      </div>
    </div>
  </body>
</html>
// src/pages/api/posts.ts
import type { APIRoute } from 'astro';
import { getPosts } from '../../lib/posts';

export const GET: APIRoute = async ({ url }) => {
  const page = parseInt(url.searchParams.get('page') ?? '1');
  const posts = await getPosts({ limit: 10, page });
  const nextPage = page + 1;
  const hasMore = posts.length === 10;

  const html = `
    ${posts
      .map(
        (post) => `
      <article class="post-card">
        <h2><a href="/blog/${post.slug}">${post.title}</a></h2>
        <p>${post.excerpt}</p>
      </article>
    `
      )
      .join('')}
    ${hasMore
      ? `<div
          hx-get="/api/posts?page=${nextPage}"
          hx-trigger="revealed"
          hx-target="#posts-container"
          hx-swap="beforeend"
        >
          <div class="htmx-indicator text-center py-4">กำลังโหลด...</div>
        </div>`
      : '<p class="text-center py-4 text-gray-500">โหลดครบแล้ว</p>'
    }
  `;

  return new Response(html, {
    headers: { 'Content-Type': 'text/html' },
  });
};

Like Button

<button
  hx-post={`/api/posts/${post.id}/like`}
  hx-target="this"
  hx-swap="outerHTML"
  class="like-btn"
>
  ❤️ {post.likeCount}
</button>
// src/pages/api/posts/[id]/like.ts
import type { APIRoute } from 'astro';
import { toggleLike } from '../../../../lib/posts';

export const POST: APIRoute = async ({ params }) => {
  const { id } = params;
  const { likeCount, liked } = await toggleLike(id!);

  const html = `
    <button
      hx-post="/api/posts/${id}/like"
      hx-target="this"
      hx-swap="outerHTML"
      class="like-btn ${liked ? 'liked' : ''}"
    >
      ${liked ? '❤️' : '🤍'} ${likeCount}
    </button>
  `;

  return new Response(html, {
    headers: { 'Content-Type': 'text/html' },
  });
};

async function toggleLike(id: string) {
  return { likeCount: 0, liked: false };
}

Server-Sent Events (SSE)

// src/pages/api/notifications.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = () => {
  const stream = new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();

      const sendEvent = (data: object) => {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
        );
      };

      // ส่ง notification ทุก 5 วินาที
      const interval = setInterval(() => {
        sendEvent({
          message: 'มีการอัปเดตใหม่!',
          timestamp: new Date().toISOString(),
        });
      }, 5000);

      // Cleanup
      return () => clearInterval(interval);
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  });
};

ใช้งานใน HTML:

<div
  hx-ext="sse"
  sse-connect="/api/notifications"
  sse-swap="message"
  hx-target="#notifications"
>
  <div id="notifications"></div>
</div>

สรุป

HTMX กับ Astro SSR เป็นการผสมผสานที่น่าสนใจมากสำหรับ hypermedia-driven applications ด้วยแนวคิด server-side rendering ที่ส่ง HTML fragments กลับมา ทำให้ลด JavaScript ที่ต้องเขียนได้อย่างมาก เหมาะสำหรับ applications ที่ต้องการ simplicity และ performance โดยไม่ต้องการ complex client-side state management