Alpine.js + HTMX: El Stack Ligero Que Reemplaza Tu SPA
Combina Alpine.js y HTMX para crear apps interactivas sin frameworks pesados. Menos JavaScript, misma experiencia de usuario.
Introduccion
Cada vez que empezamos un proyecto nuevo, la tentacion es la misma: instalar React, configurar Vite, agregar un router, un state manager, y de pronto tenemos 200MB en node_modules para una app que muestra una tabla con filtros.
No todos los proyectos necesitan un SPA. Y en DEVXI lo aprendimos despues de construir dashboards internos, landing pages interactivas y paneles administrativos donde React era como usar un canon para matar una mosca.
La alternativa que adoptamos: Alpine.js para interactividad del lado del cliente y HTMX para comunicacion con el servidor. Juntos pesan menos de 31KB minificados. Cero build step obligatorio. Cero virtual DOM. Y la experiencia de usuario es practicamente identica a la de un SPA.
En este articulo te muestro como funcionan juntos, cuando tiene sentido usarlos, y ejemplos listos para copiar y usar en tu proximo proyecto.
Por Que Este Stack Existe
Los frameworks SPA resolvieron un problema real: las paginas web se sentian lentas porque cada interaccion recargaba todo. React, Vue y Angular solucionaron eso con el virtual DOM y la renderizacion del lado del cliente.
Pero crearon nuevos problemas:
- Bundle sizes enormes para funcionalidad simple
- Complejidad de tooling (webpack, babel, transpilers)
- Duplicacion de logica entre frontend y backend
- SEO complicado sin server-side rendering
Alpine.js (version 3.15.x actualmente) y HTMX (version 2.0+) atacan estos problemas desde otra filosofia: el servidor ya sabe renderizar HTML, dejalo hacer su trabajo.
Imagen: arquitectura
Placeholder — la imagen se agregará próximamente
La Division de Responsabilidades
La clave de este stack es que cada herramienta tiene un rol claro:
HTMX: El Mensajero del Servidor
HTMX se encarga de hacer requests al servidor y actualizar partes del DOM con el HTML que recibe. No necesitas escribir fetch(), no necesitas parsear JSON, no necesitas construir HTML en JavaScript.
<!-- Click en boton → GET al servidor → reemplaza el contenido del div -->
<button hx-get="/api/usuarios"
hx-target="#lista-usuarios"
hx-swap="innerHTML">
Cargar Usuarios
</button>
<div id="lista-usuarios">
<!-- HTMX inyecta el HTML del servidor aqui -->
</div>
El servidor responde con HTML puro:
<!-- Respuesta del servidor (no JSON, HTML directo) -->
<ul>
<li>Maria Garcia - maria@email.com</li>
<li>Carlos Lopez - carlos@email.com</li>
<li>Ana Martinez - ana@email.com</li>
</ul>
Alpine.js: El Maestro del Cliente
Alpine.js maneja todo lo que pasa sin necesidad de ir al servidor: toggles, dropdowns, modales, validaciones en tiempo real, filtros locales.
<!-- Dropdown completo sin una sola linea de JS en archivo separado -->
<div x-data="{ open: false }">
<button @click="open = !open">
Menu
</button>
<ul x-show="open"
x-transition
@click.outside="open = false">
<li><a href="/perfil">Mi Perfil</a></li>
<li><a href="/config">Configuracion</a></li>
<li><a href="/salir">Cerrar Sesion</a></li>
</ul>
</div>
Ejemplo Real: Tabla con Busqueda, Filtros y Paginacion
Este es el ejemplo que convence. Un componente que en React necesitaria useState, useEffect, un servicio HTTP, manejo de loading states y probablemente un debounce hook.
Con Alpine.js + HTMX:
<div x-data="{
search: '',
category: 'all',
loading: false
}">
<!-- Barra de busqueda con Alpine.js -->
<div class="flex gap-4 mb-4">
<input type="text"
x-model="search"
placeholder="Buscar producto..."
hx-get="/productos/buscar"
hx-trigger="keyup changed delay:300ms"
hx-target="#tabla-productos"
hx-include="[name='category']"
hx-indicator="#spinner"
:name="'search'"
:value="search"
class="border rounded px-3 py-2 w-full">
<!-- Filtro por categoria con Alpine.js -->
<select x-model="category"
name="category"
hx-get="/productos/buscar"
hx-trigger="change"
hx-target="#tabla-productos"
hx-include="[name='search']"
class="border rounded px-3 py-2">
<option value="all">Todas</option>
<option value="electronics">Electronica</option>
<option value="clothing">Ropa</option>
<option value="food">Alimentos</option>
</select>
</div>
<!-- Indicador de carga -->
<div id="spinner" class="htmx-indicator">
<span class="animate-spin">Cargando...</span>
</div>
<!-- Tabla de resultados (HTMX la actualiza) -->
<div id="tabla-productos">
<!-- El servidor renderiza la tabla aqui -->
</div>
</div>
El controlador en Laravel (o cualquier backend) devuelve HTML parcial:
// ProductoController.php
public function buscar(Request $request)
{
$productos = Producto::query()
->when($request->search, fn($q, $s) => $q->where('nombre', 'like', "%{$s}%"))
->when($request->category !== 'all', fn($q) => $q->where('categoria', $request->category))
->paginate(20);
// Retorna SOLO el fragmento HTML, no la pagina completa
return view('productos._tabla', compact('productos'));
}
<!-- recursos/views/productos/_tabla.blade.php -->
<table class="w-full">
<thead>
<tr>
<th class="text-left p-2">Producto</th>
<th class="text-left p-2">Categoria</th>
<th class="text-right p-2">Precio</th>
</tr>
</thead>
<tbody>
@forelse($productos as $producto)
<tr class="border-t">
<td class="p-2">{{ $producto->nombre }}</td>
<td class="p-2">{{ $producto->categoria }}</td>
<td class="p-2 text-right">${{ number_format($producto->precio, 2) }}</td>
</tr>
@empty
<tr>
<td colspan="3" class="p-4 text-center text-gray-500">
No se encontraron productos
</td>
</tr>
@endforelse
</tbody>
</table>
<!-- Paginacion con HTMX -->
<div class="flex justify-center mt-4 gap-2">
@if($productos->previousPageUrl())
<button hx-get="{{ $productos->previousPageUrl() }}"
hx-target="#tabla-productos"
class="px-3 py-1 border rounded">
Anterior
</button>
@endif
<span class="px-3 py-1">
Pagina {{ $productos->currentPage() }} de {{ $productos->lastPage() }}
</span>
@if($productos->nextPageUrl())
<button hx-get="{{ $productos->nextPageUrl() }}"
hx-target="#tabla-productos"
class="px-3 py-1 border rounded">
Siguiente
</button>
@endif
</div>
Resultado: busqueda con debounce, filtros, paginacion, indicador de carga. Cero useState. Cero useEffect. Cero JSON parsing.
Estado Global con Alpine.store
Cuando necesitas compartir estado entre componentes que no estan anidados, Alpine.js ofrece Alpine.store():
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('cart', {
items: [],
total: 0,
add(product) {
const existing = this.items.find(i => i.id === product.id);
if (existing) {
existing.qty++;
} else {
this.items.push({ ...product, qty: 1 });
}
this.recalculate();
},
remove(productId) {
this.items = this.items.filter(i => i.id !== productId);
this.recalculate();
},
recalculate() {
this.total = this.items.reduce((sum, i) => sum + (i.price * i.qty), 0);
},
get count() {
return this.items.reduce((sum, i) => sum + i.qty, 0);
}
});
});
</script>
<!-- En el header (navbar) -->
<div x-data>
<a href="/carrito" class="relative">
Carrito
<span x-show="$store.cart.count > 0"
x-text="$store.cart.count"
x-transition
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 text-xs flex items-center justify-center">
</span>
</a>
</div>
<!-- En la lista de productos (otra parte de la pagina) -->
<div x-data>
<button @click="$store.cart.add({ id: 1, name: 'Camiseta', price: 15.00 })"
class="bg-blue-600 text-white px-4 py-2 rounded">
Agregar al Carrito - $15.00
</button>
</div>
<!-- En el resumen del carrito -->
<div x-data>
<p>Total: $<span x-text="$store.cart.total.toFixed(2)"></span></p>
</div>
El badge del carrito en el navbar, el boton de agregar en la lista de productos, y el resumen del carrito: tres componentes separados compartiendo estado reactivo sin Redux, sin Context API, sin Zustand.
Cuando HTMX y Alpine Se Encuentran
La magia real ocurre cuando los combinas en un mismo flujo. Alpine.js configura un MutationObserver en el documento, lo que significa que cuando HTMX inyecta HTML nuevo en el DOM, Alpine.js automaticamente inicializa cualquier directiva x-data que encuentre.
Ejemplo: un formulario de contacto con validacion local (Alpine) y envio al servidor (HTMX):
<form x-data="{
name: '',
email: '',
message: '',
errors: {},
validate() {
this.errors = {};
if (!this.name.trim()) this.errors.name = 'El nombre es requerido';
if (!this.email.includes('@')) this.errors.email = 'Email invalido';
if (this.message.length < 10) this.errors.message = 'Minimo 10 caracteres';
return Object.keys(this.errors).length === 0;
}
}"
@submit.prevent="if (validate()) $el.requestSubmit()"
hx-post="/contacto"
hx-target="#form-result"
hx-swap="innerHTML">
<div class="mb-4">
<input type="text" name="name" x-model="name"
placeholder="Tu nombre"
:class="errors.name ? 'border-red-500' : 'border-gray-300'"
class="border rounded px-3 py-2 w-full">
<p x-show="errors.name" x-text="errors.name"
class="text-red-500 text-sm mt-1"></p>
</div>
<div class="mb-4">
<input type="email" name="email" x-model="email"
placeholder="tu@email.com"
:class="errors.email ? 'border-red-500' : 'border-gray-300'"
class="border rounded px-3 py-2 w-full">
<p x-show="errors.email" x-text="errors.email"
class="text-red-500 text-sm mt-1"></p>
</div>
<div class="mb-4">
<textarea name="message" x-model="message"
placeholder="Tu mensaje"
:class="errors.message ? 'border-red-500' : 'border-gray-300'"
class="border rounded px-3 py-2 w-full" rows="4"></textarea>
<p x-show="errors.message" x-text="errors.message"
class="text-red-500 text-sm mt-1"></p>
</div>
<button type="submit"
class="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700">
Enviar
</button>
</form>
<div id="form-result"></div>
Alpine valida antes de enviar. Si pasa la validacion, HTMX envia el formulario. El servidor responde con HTML (puede ser un mensaje de exito o errores del servidor).
Imagen: comparativa
Placeholder — la imagen se agregará próximamente
Comparativa de Tamano: Numeros Reales
| Stack | Tamano (min+gzip) | Build step |
|---|---|---|
| React + ReactDOM | ~44 KB | Obligatorio |
| Vue 3 | ~33 KB | Recomendado |
| Svelte (compilado) | ~2-10 KB | Obligatorio |
| Alpine.js 3.15 | ~17 KB | Opcional |
| HTMX 2.0 | ~14 KB | No necesario |
| Alpine + HTMX | ~31 KB | No necesario |
La diferencia no es solo tamano. Es que Alpine + HTMX no necesitan build step. Puedes incluirlos con dos tags <script> desde un CDN y empezar a trabajar. Para proyectos Laravel con Blade, esto es enorme: no necesitas un pipeline de frontend separado.
Cuando NO Usar Este Stack
Seamos honestos. Este stack no es para todo:
- Apps con mucha interactividad offline (como editores de texto o herramientas de diseno) necesitan mas capacidad del lado del cliente
- Dashboards con actualizaciones en tiempo real cada segundo funcionan mejor con WebSockets + un framework reactivo completo
- Equipos que ya dominan React/Vue no necesitan cambiar; la productividad con herramientas conocidas tiene su valor
- SPAs con routing complejo del lado del cliente donde la navegacion es 100% client-side
La regla que usamos en DEVXI: si el servidor ya renderiza las paginas (Laravel Blade, Django templates, Rails views), Alpine + HTMX es la opcion natural. Si necesitas una app completamente independiente del servidor para la UI, usa un framework SPA.
Setup en 2 Minutos
Opcion 1: CDN (cero configuracion)
<!DOCTYPE html>
<html>
<head>
<title>Mi App</title>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script src="https://unpkg.com/htmx.org@2/dist/htmx.min.js"></script>
</head>
<body>
<div x-data="{ count: 0 }">
<button @click="count++">Clicks: <span x-text="count"></span></button>
</div>
<button hx-get="/api/hora" hx-target="#hora">
Ver hora del servidor
</button>
<span id="hora"></span>
</body>
</html>
Opcion 2: NPM (con Vite/Laravel)
npm install alpinejs htmx.org
// resources/js/app.js
import Alpine from 'alpinejs';
import htmx from 'htmx.org';
window.Alpine = Alpine;
window.htmx = htmx;
Alpine.start();
Ambas opciones funcionan. La primera es ideal para prototipos rapidos. La segunda para proyectos Laravel con Vite.
Tip de Produccion: CSP y Seguridad
Un detalle que muchos tutoriales ignoran: si tu servidor tiene headers de Content Security Policy (CSP), Alpine.js necesita unsafe-eval habilitado porque usa new Function() internamente para evaluar expresiones.
Si tu CSP es estricto, Alpine ofrece el plugin @alpinejs/csp como alternativa:
npm install @alpinejs/csp
import Alpine from '@alpinejs/csp';
Con esto, puedes usar Alpine sin unsafe-eval, aunque pierdes la capacidad de escribir expresiones JavaScript inline directamente en los atributos. Es un trade-off que vale la pena conocer antes de que te encuentres el error en produccion.
Conclusion
Alpine.js + HTMX no es la solucion para todo, pero es la solucion correcta para muchos proyectos que hoy estan sobre-engineered con frameworks SPA completos. Si tu backend ya genera HTML, si tu interactividad es formularios, filtros, modales y tablas dinamicas, este stack te da la misma experiencia de usuario con una fraccion de la complejidad.
En DEVXI lo usamos para paneles administrativos, landing pages interactivas y herramientas internas. La velocidad de desarrollo es notablemente mayor cuando no tienes que mantener dos aplicaciones separadas (API + SPA).
Probalo en tu proximo proyecto. Dos scripts, cero build step, y toda la interactividad que necesitas.
Fuentes
- Alpine.js - Documentacion oficial — directivas, stores y API de Alpine.js 3.15
- HTMX - Documentacion oficial — atributos y configuracion de HTMX 2.0
- HTMX and Alpine.js: How to combine two great, lean front ends - InfoWorld — guia practica de integracion
- Alpine.js Stores: usage guide and best practices — patrones de estado global con Alpine.store
- Add Alpine.js to any Laravel project - Benjamin Crozat — integracion con Laravel y Vite
- Using Alpine.js in HTMX - Ben Nadel — comportamiento del MutationObserver y compatibilidad
Recursos Adicionales
- Alpine.js - Start Here — tutorial oficial para comenzar
- HTMX Examples — ejemplos interactivos de HTMX
- Alpine.js DevTools — extension de navegador para debugging
- GitHub: alpinejs/alpine — codigo fuente y releases
Etiquetas
¿Necesitas ayuda con tu proyecto?
En DEVXI te ayudamos con desarrollo web, hosting administrado y aplicaciones a medida para tu negocio.
Contactar