Arquitectura HTMX

HTMX, Composición Funcional y el Problema del SEO

Construyendo páginas web que funcionan tanto para usuarios como para crawlers

Fecha:
htmxwebarquitecturabackend

Cuando decidí construir este blog usando HTMX, me topé con un problema arquitectónico bastante interesante. De esos que parecen simples en la superficie, pero que terminan afectando cómo estructuras toda tu aplicación.

Uno de los retos principales es lograr que la misma URL funcione tanto para visitas directas (desde buscadores, marcadores, enlaces compartidos) como para la navegación interna con HTMX.

El Dilema

HTMX propone extender la web tradicional de forma que puedas hacer peticiones HTTP directamente desde elementos HTML e intercambiar partes del DOM con la respuesta del servidor, todo con muy poca complejidad mental.

Pero esta simplicidad trae consigo una pregunta de diseño: cuando alguien hace clic en un enlace vía HTMX, lo ideal es enviar solo el fragmento de contenido. Pero cuando un crawler o usuario llega directamente a esa misma URL, necesita el documento HTML completo con navegación, estilos, scripts, todo el paquete.

Podrías resolver esto con endpoints separados, pero eso significa mantener dos versiones de cada ruta, y ya sabemos que cada línea de código extra es un dolor de cabeza a futuro.

Visualizando las Páginas como Estructuras Anidadas: Composición Funcional

Aquí van dos ideas clave: la primera es que las páginas web son estructuras anidadas. Tu contenido vive dentro de una sección, que vive dentro del body, que vive dentro del documento HTML. Algo así:

plaintext

Documento HTML → Body → Sección → Contenido del Artículo

Cuando navegas dentro de tu sitio, la mayor parte de esta estructura se queda igual. Solo cambia el contenido más interno. Por eso las aplicaciones de una sola página se sienten tan rapidas, no tienen que volver a renderizar desde cero todo cada vez.

La segunda idea es que HTMX nos permite enviar únicamente ese contenido interno.

Juntando ambos enfoques, conseguimos el mismo beneficio de las aplicaciones de una sola página, pero con una diferencia clave: no es código del lado del cliente haciendo malabarismos con el enrutamiento interno o manejando estado, sino el servidor el que ya sabe qué contenido enviar.

Al ver este anidamiento, podemos tratar cada nivel de la estructura como una función que se puede componer. Cada función toma el contenido del siguiente nivel y lo envuelve en el HTML correspondiente.

javascript

function body(content) { return ` <html> <head>...</head> <body> <nav>...</nav> <div id='content-slot'> ${content} </div> <footer>...</footer> </body> </html> `; } function blogSection(content) { return ` <section class='blog'> <h1>Blog</h1> <div id='post-slot'> ${content} </div> </section> `; } function article() { return `<article>¡Hola Mundo!</article>`; }

Ahora solo tienes que componer estas funciones para armar páginas completas:

javascript

// Página completa para peticiones externas body(blogSection(article())) // Solo el fragmento para peticiones HTMX article()

Detectando el Tipo de Petición

Para saber qué respuesta mandar, hay que distinguir entre peticiones externas e internas. HTMX lo hace super fácil con headers personalizados. Puedes agregar un header a todas las peticiones HTMX:

html

<body hx-headers='{"X-Requested-With": "htmx"}'> <!-- Todas las peticiones HTMX desde aquí incluirán este header --> </body>

Y luego en el servidor:

javascript

app.get('/blog/mi-post', (req, res) => { const content = article(); if (req.headers['x-requested-with'] === 'htmx') { // Navegación interna - mandar solo el fragmento return res.send(content); } // Petición externa - mandar la página completa res.send(body(blogSection(content))); });

La Implementación Real

En este blog, uso un objeto PageFragment para representar cada nivel. Tiene el body HTML, metadatos, estilos, todo lo que necesita esa pieza de la página. Luego tengo funciones que componen estos fragmentos.

Cuando Express maneja una ruta, revisa el header personalizado headers['calaverd-blog'] 1 y devuelve el fragmento directamente o lo envuelve en la plantilla completa.

Lo bonito es que las rutas se generan automáticamente desde una estructura de árbol, y cada ruta ya sabe cómo componerse:

javascript

// La estructura del sitio como un árbol const siteMap = { fragmentFunction: rootContent, children: { 'es': { fragmentFunction: mainPage, children: { 'blog': { fragmentFunction: blogPage, children: { 'mi-post': { fragmentFunction: postContent, children: {} } } } } } } }; // Las rutas se construyen recursivamente function constructSite(map, route='/', fragmentFunctions=[]) { const allFunctions = [...fragmentFunctions, map.fragmentFunction]; app.get(route, (req, res) => { if (req.headers['calaverd-blog']) { // Petición HTMX interna - mandar solo este fragmento return res.send(map.fragmentFunction(null).bundle); } // Petición externa - componer todo desde la raíz const fullPage = allFunctions.reduceRight( (fragment, fun) => fun(fragment), new PageFragment ); res.send(fullPage.body); }); // Construir las rutas hijas de forma recursiva Object.entries(map.children).forEach( ([path, child]) => constructSite(child, route + path, allFunctions) ); }

Lo Que Ganas Con Esto

Esta arquitectura te regala varias cosas:

  • SEO que funciona sin esfuerzo - Los crawlers reciben documentos HTML completos
  • Navegación rápida - Los usuarios solo reciben el contenido que cambió
  • URLs para compartir - Cada URL funciona como enlace directo
  • Una sola fuente de verdad - Una ruta, una función de composición
  • Cero problemas de hidratación - El servidor ya sabe qué mandar

Y todo esto solo con un conjunto de funciones componiendo strings de HTML, código simple y fácil de entender.

Los Compromisos

Para sitios enfocados en contenido con algo de interactividad, este patrón de composición funcional con HTMX funciona muy bien. Tienes la simplicidad de desarrollo del renderizado tradicional del servidor con la experiencia fluida de una aplicación de una sola página.

Claro, no hay solución universal, esto no es una varita mágica para todos los casos. Si necesitas manejo de estado complejo en el cliente, colaboración en tiempo real, o funcionalidad offline, vas a necesitar un framework de JavaScript para esas cosas.

Todo el sistema surge de partes simples que se componen. Cada función hace una cosa. La complejidad que ves es la que realmente existe.

El Helper de Enlaces

Para no tener que escribir los atributos de HTMX manualmente en cada enlace, uso una función helper que pone el target correcto según la profundidad de la URL:

javascript

function link(url, text) { const levelNumber = Math.max( [...url].filter(c => c === '/').length - 1, 0 ); return `<a href="${url}" hx-get="${url}" hx-target="#content-level-${levelNumber}" hx-swap="innerHTML show:top swap:300ms" hx-push-url="true">${text}</a>`; }

Los IDs de content-level se definen en las plantillas cuando creas la estructura de la página.

La Clase PageFragment

Cada pedazo de contenido está representado por un objeto PageFragment:

javascript

class PageFragment { constructor() { this.title = ''; this.description = ''; this.body = ''; this.styles = ''; this.lang = 'es'; this.keywords = []; } get bundle() { return ` <head hx-head="re-eval"> <title>${this.title}</title> <meta name="description" content="${this.description}"> </head> ${this.body} `; } }

El getter bundle envuelve el contenido con tags <head> marcados como hx-head="re-eval". La extensión head-support de HTMX usa esto para actualizar el título de la página mientras navegas.

Pros y Contras

Lo Que Funciona Bien

  • SEO - HTML completo para los crawlers
  • Depuración - Abre la pestaña Network y ves exactamente qué manda el servidor
  • Sin proceso de build - Módulos ES puros, nada más
  • Rápido - El servidor manda HTML completo, sin esperar hidratación
  • Ligero - ~14kb de HTMX vs más de 200kb en SPAs típicas de React

Las Desventajas

  • Rutas manuales - Tienes que agregar cada página al mapa del sitio a mano
  • Template literals - No hay resaltado de sintaxis sin plugins del editor
  • No escala tan bien - El anidamiento profundo se vuelve complicado

Pruébalo

Muestra una cáptura de pantalla demostrativa del ejemplo
Una ejemplo más simple.

Si estás armando un blog, sitio de documentación, o alguna aplicación con mucho contenido, este patrón podría funcionarte. Empieza con páginas como funciones que se componen y ve a dónde te lleva.

Hice un repositorio de ejemplo simplificado que muestra la arquitectura básica: htmx_minimalist_blog_example 2

El ejemplo deja fuera el soporte bilingüe, el sistema de contenido basado en archivos, y otras características de producción para enfocarse solo en el patrón arquitectónico.

[1] Sí, le puse un nombre personalizado al header. ¿Por qué no?

[2] Una implementación mínima enfocada en los conceptos centrales de composición funcional, respuestas condicionales, y rooting basado en un árbol, sin la complejidad del sistema completo del blog.