Hay una narrativa instalada en el frontend que dice que si no usas React, Vue o Angular, estás haciendo las cosas mal. Llevo años escuchándola y durante mucho tiempo la asumí como verdad. Luego apareció HTMX y me hizo reconsiderar todo.

HTMX es una librería de menos de 15KB que extiende HTML con atributos personalizados. Con esos atributos puedes hacer peticiones HTTP, actualizar partes del DOM, manejar eventos y animar transiciones, todo sin escribir una sola línea de JavaScript. La idea es simple: HTML debería poder hacer más de lo que hace por defecto.

Lo que más me llama la atención no es la librería en sí, sino el enfoque. Si tienes un backend en Spring Boot, Django, Rails, Laravel o Express que ya devuelve HTML, HTMX encaja de forma casi quirúrgica. No necesitas montar una API REST, no necesitas un frontend separado, no tienes que aprender nada nuevo excepto unos pocos atributos.

¿Qué problema resuelve HTMX exactamente?

El problema clásico: tienes una página web con un formulario. El usuario lo envía, quieres mostrar un mensaje de éxito sin recargar la página. Con JavaScript puro tienes que interceptar el submit, hacer un fetch, gestionar los estados de carga, actualizar el DOM manualmente. Tres docenas de líneas para algo conceptualmente trivial.

Con HTMX añades tres atributos al formulario y el backend devuelve el fragmento HTML que quieres mostrar. Eso es todo. El navegador hace el resto. Esto no es magia ni un truco, es una filosofía diferente: en lugar de que el servidor devuelva JSON y el cliente lo convierta en HTML, el servidor devuelve HTML directamente.

Esta aproximación tiene un nombre: Hypermedia As The Engine Of Application State (HATEOAS). Es tan antigua como la web misma. HTMX simplemente la hace práctica en 2026.

Instalación: cero dependencias, cero configuración

Añadir HTMX a cualquier proyecto es literalmente una línea en el HTML. Desde CDN no necesitas npm, bundler ni ningún paso de build:

<!-- Desde CDN, la forma más rápida -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>

<!-- O instalado como módulo npm -->
<!-- npm install htmx.org -->

No hay configuración inicial, no hay imports, no hay providers, no hay nada. Simplemente incluye el script y todos tus elementos HTML ganan acceso a los atributos de HTMX.

Para este tutorial uso un backend en Node.js con Express que devuelve fragmentos HTML. El mismo patrón funciona igual con cualquier otro backend: Spring Boot con Thymeleaf, Django con templates, Laravel con Blade. Lo importante es que el servidor devuelva HTML, no JSON.

Los cuatro atributos fundamentales de HTMX

Toda la potencia de HTMX viene de cuatro atributos principales. Una vez que los entiendes, puedes construir interfaces bastante complejas.

hx-get, hx-post, hx-put, hx-delete definen qué método HTTP usar y a qué URL hacer la petición. El elemento que los tiene los dispara cuando ocurre el evento por defecto: click para botones, submit para formularios, change para inputs.

hx-target define qué elemento del DOM se actualiza con la respuesta del servidor. Acepta selectores CSS. Si no lo defines, HTMX actualiza el propio elemento que hizo la petición. Puedes usar closest .clase o find .clase para targets relativos.

hx-swap define cómo se inserta la respuesta en el target. Las opciones principales son innerHTML (reemplaza el contenido), outerHTML (reemplaza el elemento completo), beforeend (añade al final) y afterend (inserta después).

hx-trigger define qué evento dispara la petición. Por defecto es click o submit según el elemento, pero puedes usar cualquier evento del navegador, o triggers especiales como load, revealed (cuando entra en el viewport) o every 2s para polling.

Ejemplo práctico: lista de tareas con Express

Vamos a construir una lista de tareas funcional con Express en el backend. La aplicación tendrá creación, eliminación y toggle de estado, todo sin JavaScript de frontend:

// server.js
const express = require('express');
const app = express();
app.use(express.urlencoded({ extended: true }));

let tareas = [
  { id: 1, texto: 'Aprender HTMX', completada: false },
  { id: 2, texto: 'Simplificar el frontend', completada: false },
];
let nextId = 3;

function tareaHTML(t) {
  return `
    <li id="tarea-${t.id}" class="${t.completada ? 'completada' : ''}"
        style="${t.completada ? 'text-decoration:line-through' : ''}">
      <input type="checkbox"
        hx-put="/tareas/${t.id}/toggle"
        hx-target="#tarea-${t.id}"
        hx-swap="outerHTML"
        ${t.completada ? 'checked' : ''}>
      <span>${t.texto}</span>
      <button
        hx-delete="/tareas/${t.id}"
        hx-target="#tarea-${t.id}"
        hx-swap="outerHTML">Eliminar</button>
    </li>
  `;
}

app.get('/', (req, res) => {
  res.send(`
    <!DOCTYPE html>
    <html lang="es">
    <head><meta charset="UTF-8">
      <script src="https://unpkg.com/htmx.org@2.0.4"></script>
    </head>
    <body>
      <h1>Lista de tareas</h1>
      <form hx-post="/tareas" hx-target="#lista"
            hx-swap="beforeend"
            hx-on::after-request="this.reset()">
        <input type="text" name="texto" placeholder="Nueva tarea" required>
        <button type="submit">Añadir</button>
      </form>
      <ul id="lista">
        ${tareas.map(tareaHTML).join('')}
      </ul>
    </body></html>
  `);
});

app.post('/tareas', (req, res) => {
  const nueva = { id: nextId++, texto: req.body.texto, completada: false };
  tareas.push(nueva);
  res.send(tareaHTML(nueva));
});

app.put('/tareas/:id/toggle', (req, res) => {
  const tarea = tareas.find(t => t.id === Number(req.params.id));
  if (!tarea) return res.status(404).send('');
  tarea.completada = !tarea.completada;
  res.send(tareaHTML(tarea));
});

app.delete('/tareas/:id', (req, res) => {
  tareas = tareas.filter(t => t.id !== Number(req.params.id));
  res.send(''); // elemento vacío = HTMX lo elimina del DOM
});

app.listen(3000);

Fíjate en lo que acaba de pasar: una aplicación CRUD completamente funcional, sin un solo archivo JavaScript de frontend. El HTML que devuelve el servidor ya incluye los atributos de HTMX que gobiernan el comportamiento de cada elemento.

Indicadores de carga y feedback visual automático

Cuando hay una petición en curso, HTMX añade automáticamente la clase htmx-request al elemento que la inició. Puedes usarla directamente en tu CSS para dar feedback visual sin ningún código adicional:

/* El botón se desactiva visualmente mientras carga */
button.htmx-request {
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}

/* Spinner que aparece solo durante peticiones */
.htmx-indicator {
  display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  display: inline-block;
}

Para un indicador de carga en un lugar específico de la página, usa el atributo hx-indicator. HTMX mostrará ese elemento automáticamente mientras dure la petición:

<button hx-post="/operacion-lenta"
        hx-target="#resultado"
        hx-indicator="#spinner">
  Ejecutar proceso
</button>
<span id="spinner" class="htmx-indicator">
  ⏳ Procesando...
</span>
<div id="resultado"></div>

Búsqueda en tiempo real con debounce incluido

La búsqueda que filtra mientras escribes es uno de esos requisitos que con JavaScript puro necesita debounce, fetch, manejo de errores y actualización del DOM. Con HTMX el HTML lo dice todo:

<input type="search"
       name="q"
       placeholder="Buscar productos..."
       hx-get="/buscar"
       hx-target="#resultados"
       hx-trigger="input changed delay:300ms"
       hx-swap="innerHTML">

<div id="resultados"></div>

El trigger input changed delay:300ms hace tres cosas: escucha el evento input, solo dispara si el valor realmente cambió (changed), y espera 300ms después del último keystroke antes de hacer la petición. Eso es debouncing automático sin una función debounce manual.

// Backend Express
app.get('/buscar', (req, res) => {
  const query = (req.query.q || '').toLowerCase().trim();
  
  if (!query) {
    return res.send('<p>Escribe algo para buscar</p>');
  }
  
  const resultados = productos.filter(p =>
    p.nombre.toLowerCase().includes(query) ||
    p.descripcion.toLowerCase().includes(query)
  );
  
  if (resultados.length === 0) {
    return res.send(`<p>Sin resultados para "${query}"</p>`);
  }
  
  res.send(resultados.map(p => `
    <div class="resultado">
      <strong>${p.nombre}</strong>
      <span>${p.precio}€</span>
    </div>
  `).join(''));
});

Paginación infinita con hx-trigger revealed

El scroll infinito es otro clásico que con JavaScript suele necesitar Intersection Observer y gestión de estado. Con HTMX lo resuelves con un elemento centinela al final de la lista que se activa al entrar en el viewport:

<div id="articulos">
  <!-- artículos de la página 1 incluidos en el SSR inicial -->
  
  <!-- Centinela: dispara al entrar en el viewport -->
  <div hx-get="/articulos?pagina=2"
       hx-trigger="revealed"
       hx-target="this"
       hx-swap="outerHTML">
    <span class="htmx-indicator">Cargando más...</span>
  </div>
</div>

El servidor devuelve los artículos de la página 2 y, si hay más páginas, incluye al final un nuevo centinela que apunta a la página 3. La paginación infinita se encadena sola sin ningún estado en el cliente:

app.get('/articulos', (req, res) => {
  const pagina = Number(req.query.pagina) || 1;
  const porPagina = 10;
  const inicio = (pagina - 1) * porPagina;
  const items = articulos.slice(inicio, inicio + porPagina);
  const hayMas = inicio + porPagina < articulos.length;
  
  let html = items.map(a => `
    <article><h3>${a.titulo}</h3><p>${a.resumen}</p></article>
  `).join('');
  
  if (hayMas) {
    html += `
      <div hx-get="/articulos?pagina=${pagina + 1}"
           hx-trigger="revealed"
           hx-target="this"
           hx-swap="outerHTML">
        <span class="htmx-indicator">Cargando más...</span>
      </div>
    `;
  }
  
  res.send(html);
});

Gestión del historial del navegador con hx-push-url

Cuando HTMX actualiza contenido de forma dinámica, el botón «atrás» del navegador puede quedar roto si no gestionas el historial. El atributo hx-push-url resuelve esto añadiendo una entrada al historial cada vez que se hace una petición:

<a hx-get="/productos/javascript"
   hx-target="#contenido"
   hx-push-url="true">
  Ver libros de JavaScript
</a>

<!-- O con hx-boost para convertir todos los links de la página -->
<body hx-boost="true">

Con hx-push-url="true", HTMX actualiza la URL del navegador al hacer la petición. Cuando el usuario pulsa «atrás», HTMX restaura el contenido automáticamente. El servidor debe detectar si la petición viene de HTMX (cabecera HX-Request: true) para devolver solo el fragmento o la página completa según corresponda.

Cuándo usar HTMX y cuándo no

HTMX es una herramienta, no una religión. Brilla en aplicaciones donde el backend ya genera HTML: portales de administración, dashboards internos, formularios complejos, CMSs, e-commerce, cualquier cosa con mucha interacción tipo CRUD. Si tienes un backend existente que renderiza HTML, añadir HTMX es básicamente gratis.

No es la elección más obvia si necesitas una interfaz altamente interactiva en el cliente: editores de texto enriquecido en tiempo real, aplicaciones colaborativas, videojuegos en el navegador, o cualquier cosa que dependa de estado local complejo y sincronización bidireccional. Para esos casos React o Vue siguen siendo más apropiados.

También puedes usar HTMX de forma híbrida: la mayor parte de la aplicación es HTMX y solo los componentes que lo necesitan usan Alpine.js o un poco de JavaScript vanilla. Esta combinación es muy pragmática y evita la sobreingeniería.

Siguiente paso

Lo más útil que puedes hacer ahora es tomar una página de tu aplicación actual que tenga un formulario o una lista y reescribir solo esa parte con HTMX. No tienes que migrar nada más. Verás inmediatamente cuánto código desaparece y te harás una idea real de si este enfoque encaja con tu proyecto.

Si usas Spring Boot, la combinación HTMX + Thymeleaf tiene soporte oficial y una integración muy cuidada. Si usas Node.js, cualquier framework que devuelva HTML funciona: Express, Fastify, Hono. El punto de entrada es siempre el mismo: un atributo hx-get o hx-post y un endpoint que devuelva un fragmento HTML.