Guía Full-Stack: CRUD Interactivo con HTMX, Bun.js y Elysia (con SQLite)
El ecosistema JavaScript evoluciona a una velocidad vertiginosa. Si buscas una alternativa ágil y potente a los stacks tradicionales (como MERN o LAMP), has llegado al lugar correcto. En este tutorial, vamos a construir una aplicación web full-stack completa: un CRUD funcional (una “Lista de Tareas”) de principio a fin.
El objetivo es claro: aprender a crear un CRUD con HTMX y Bun.js. Usaremos un trío que está dando mucho que hablar: Bun.js como el rapidísimo runtime y gestor de paquetes, Elysia como nuestro framework backend optimizado para Bun, y HTMX para gestionar la interfaz.
¿Por qué tanto “hype” con esta combinación? La respuesta es simple: rendimiento extremo y una drástica reducción de la complejidad en el cliente. Estamos hablando de un desarrollo full-stack con Bun y HTMX que se siente ágil, directo y elimina capas innecesarias de abstracción.
Esta guía está pensada para desarrolladores que quieren máxima velocidad, tanto en ejecución como en la propia experiencia de desarrollo. Si te interesa explorar el stack moderno backend JavaScript y ver por qué esta combinación se postula como uno de los frameworks web modernos más interesantes del momento, prepárate. ¡Empezamos!
Puntos Clave
- Bun.js es un ecosistema todo en uno: Actúa como runtime, gestor de paquetes, bundler y test runner nativo, simplificando todo el tooling.
- Elysia es el framework “Bun-first”: Usaremos este framework backend por su rendimiento extremo y su API familiar, inspirada en Express.
- HTMX para el frontend: Sustituiremos el JavaScript pesado del cliente por atributos HTML que gestionan peticiones AJAX y actualizaciones del DOM.
- Persistencia con Bun SQLite: Aprovecharemos el driver nativo
bun:sqlitepara gestionar nuestra base de datos de forma rápida y sin dependencias externas. - CRUD completo: El tutorial te guiará paso a paso para implementar las cuatro operaciones esenciales: Crear, Leer, Actualizar y Borrar (CRUD) tareas.
- HTML “sobre el cable”: Nuestro backend de Elysia no servirá JSON. En su lugar, generará y enviará fragmentos de HTML que HTMX consumirá directamente.
- Reactividad sin JavaScript: El resultado final es una aplicación web interactiva y rápida, lograda sin escribir (casi) nada de JavaScript en el lado del cliente.
¿Por qué este stack? La Trinidad de la Velocidad (Bun + Elysia + HTMX)
Vamos a desgranar cada componente de esta “trinidad de la velocidad”. No hemos elegido estas herramientas al azar; juntas, resuelven muchos de los problemas de complejidad y rendimiento del desarrollo web moderno.
Bun.js: El Runtime Todo en Uno
Primero, Bun.js. Es crucial entender que Bun no es otro framework de JavaScript, sino un reemplazo directo de Node.js. Está construido desde cero (usando el motor JavaScriptCore de WebKit) para ser un runtime todo en uno e increíblemente rápido.
Su propuesta de valor es un rendimiento de I/O muy superior al de Node, un tiempo de arranque (startup) casi instantáneo y la integración nativa de herramientas. Olvídate de npm, tsc, jest y nodemon; Bun es tu gestor de paquetes, transpilador de TypeScript, test runner y ejecutor con hot-reload, todo en una sola dependencia.
Además, viene con APIs nativas de alto rendimiento que usaremos en este tutorial. Verás en acción Bun.file() para leer archivos y, lo más importante, bun:sqlite para una base de datos SQLite integrada sin dependencias externas.
Elysia: El Framework “Bun-first”
Si Bun es el motor, Elysia es el chasis de alto rendimiento. Es un framework backend “Bun-first”, diseñado explícitamente para exprimir cada gota de velocidad de Bun. Para quienes se inician en ElysiaJS para principiantes, la curva de aprendizaje es suave, ya que su API se inspira mucho en Express y Fastify.
Su principal ventaja es la velocidad (sus benchmarks superan a casi cualquier otro framework de Node.js o Bun) y su sistema de validación y tipado end-to-end. Gracias a esto, obtienes Type-Safety entre tus handlers y tus clientes casi sin esfuerzo.
Su ecosistema de plugins es potente y sencillo. Para este proyecto, usaremos @elysiajs/html para poder servir HTML directamente desde nuestros endpoints. Puedes (y deberías) consultar su documentación oficial para ver todo su potencial.
HTMX: El “Anti-Framework” Frontend
Finalmente, HTMX. Es el “anti-framework” que está revolucionando el frontend. Su filosofía es simple: ¿por qué usar cientos de kilobytes de JavaScript para pedir JSON, procesarlo y actualizar el DOM, cuando el navegador ya sabe cómo manejar HTML?
HTMX te permite acceder a AJAX, WebSockets y más, directamente desde atributos HTML. Usarás atributos como hx-get para hacer peticiones GET, hx-post para enviar formularios, hx-target para definir dónde se pondrá la respuesta y hx-swap para decidir cómo (reemplazando, añadiendo, etc.).
El resultado es una alternativa a React con HTMX y Bun donde tu backend (Elysia) vuelve a ser responsable de renderizar el HTML. Pides HTML, no JSON. Esto reduce drásticamente el JavaScript en el cliente. Echa un vistazo a los ejemplos en la web de HTMX para ver lo potente que es esta idea.
Paso 1: Configuración del Entorno y Proyecto
Manos a la obra. Antes de escribir la lógica de nuestro CRUD, necesitamos preparar el entorno de desarrollo. Gracias a Bun, este proceso es increíblemente ágil y directo.
El único requisito indispensable es tener Bun instalado en tu sistema. Si aún no lo tienes, elige el comando correspondiente para tu terminal:
Windows
powershell -c "irm https://bun.sh/install.ps1 | iex"macOS/Linux
curl -fsSL https://bun.sh/install | bashUna vez instalado (puedes verificarlo con bun --version), crea una carpeta para el proyecto y inicialízalo.
mkdir bun-htmx-crudcd bun-htmx-crudbun initBun te hará algunas preguntas (puedes aceptar los valores por defecto). Esto generará la estructura de archivos esencial: package.json (manifiesto del proyecto), tsconfig.json (configuración de TypeScript, que Bun usa de forma nativa) y index.ts (el punto de entrada de nuestra app).
Aunque existen scaffolders como bun create elysia, hacerlo con bun init nos ayuda a entender mejor cada pieza de este tutorial HTMX Elysia Bun.
Ahora, instalamos nuestras dependencias. Usaremos bun add (el equivalente a npm install o yarn add, pero mucho más rápido):
bun add elysia @elysiajs/html @types/bun bun-typesEstamos instalando cuatro paquetes clave:
elysia: El framework backend ultrarrápido.@elysiajs/html: Un plugin esencial para nuestro stack. Dado que HTMX espera recibir HTML (no JSON), este plugin facilita a Elysia servir respuestas con elContent-Typecorrecto (text/html).@types/bunybun-types: Los tipos de TypeScript necesarios para que el editor entienda las APIs nativas de Bun.
Finalmente, configuramos el hot-reloading. Abre tu package.json y añade el script "dev" dentro de la sección "scripts":
{ ... "scripts": { "dev": "bun --watch index.ts" }, ...}Ahora, cuando ejecutes bun run dev, tu servidor se reiniciará automáticamente con cada cambio.
Paso 2: Creando el Backend (Endpoints CRUD con Elysia y SQLite)
Con el proyecto configurado, es hora de construir el cerebro de nuestra aplicación: el backend. Aquí es donde Elysia y Bun.js brillan juntos. Vamos a definir nuestros endpoints CRUD y a conectarnos a una base de datos.
2.1: Inicializando la Base de Datos (Bun SQLite)
Para este tutorial, no necesitamos un servidor de base de datos pesado. Usaremos la base de datos SQLite nativa que Bun incluye, bun:sqlite. Es increíblemente rápida para desarrollo local y prototipado.
Crea un nuevo archivo llamado src/db.ts (o db.ts en la raíz, si lo prefieres) para gestionar la conexión y la configuración inicial.
import { Database } from "bun:sqlite";
export interface Task { id: number; text: string; completed: boolean;}
export const db = new Database("db.sqlite", { create: true });
db.query(` CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, completed INTEGER DEFAULT 0 );`).run();Este código importa Database, crea un archivo db.sqlite y ejecuta una consulta SQL para asegurar que nuestra tabla tasks exista con la estructura correcta.
2.2: Configurando Elysia y el Plugin HTML
Ahora, volvemos a nuestro archivo principal index.ts. Vamos a importar Elysia, el plugin html que instalamos y nuestra instancia de la base de datos.
Aquí configuraremos la instancia principal de Elysia. Usaremos .use(html()) para que el framework sepa cómo manejar y servir respuestas HTML, lo cual es fundamental para HTMX.
Tu index.ts inicial debería verse así:
import { Elysia } from "elysia";import { html } from "@elysiajs/html";import { db } from "./db";
const app = new Elysia() .use(html()) .decorate("db", db) .listen(3000);
console.log( `🦊 Servidor Elysia corriendo en http://${app.server?.hostname}:${app.server?.port}`);2.3: El Endpoint (R)ead - Listar Tareas
Es hora de implementar la primera operación CRUD: (R)ead. Vamos a crear una API REST con Elysia y Bun que, en lugar de JSON, devuelva HTML.
Un pilar de la filosofía HTMX es tener componentes reutilizables renderizados en el servidor. Creemos una función “componente” en components.ts que tome un objeto de tarea y devuelva un string HTML (<li>).
import type { Task } from "./db";
export const TaskItem = ({ id, text, completed }: Task) => { return ` <li> <span>${text}</span> <button>Borrar</button> </li> `;};
export const TaskList = ({ tasks }: { tasks: Task[] }): string => { return tasks .map((task) => TaskItem(task)).join("");};Con este componente listo, podemos crear el endpoint GET /tasks. Este endpoint consultará la base de datos (usando la db decorada) y usará TaskList para mapear los resultados a HTML.
Añade este handler a tu index.ts (antes de .listen(3000)):
import { Elysia } from "elysia";import { html } from "@elysiajs/html";import { db, type Task } from "./db";import { TaskList } from "./components";
const app = new Elysia() .use(html()) .decorate("db", db) .get("/tasks", ({ db }) => { const tasks = db.query("SELECT * FROM tasks ORDER BY id DESC").all() as Task[]; return TaskList({ tasks }); }) .listen(3000);
console.log( `🦊 Servidor Elysia corriendo en http://${app.server?.hostname}:${app.server?.port}`);Hemos combinado Bun.js, SQLite y HTMX (bueno, la parte del backend) para nuestro primer endpoint. Si ahora ejecutas bun run dev y visitas http://localhost:3000/tasks, no verás JSON, sino una lista <ul> de HTML crudo. ¡Perfecto! Esto es exactamente lo que HTMX necesita.
Paso 3: El Frontend (Vistas Interactivas con HTMX)
Ya tenemos nuestros endpoints de backend en Elysia sirviendo HTML. Ahora, necesitamos un frontend que los consuma. Aquí es donde HTMX entra en juego, actuando como la capa de interactividad sin necesidad de JavaScript pesado.
3.1: Sirviendo el HTML Principal
Nuestro backend ya sirve tareas en /tasks, pero necesitamos una página principal (/) que el usuario pueda cargar. Vamos a añadir un nuevo endpoint GET / a nuestro index.ts que sirva el archivo estático index.html.
Para esto, usamos Bun.file(), una API nativa de Bun optimizada para servir archivos de forma eficiente.
Añade el nuevo handler .get("/") a tu index.ts (normalmente, antes de las rutas de la API):
4 collapsed lines
import { Elysia } from "elysia";import { html } from "@elysiajs/html";import { db, type Task } from "./db";import { TaskList } from "./components";
const app = new Elysia() .use(html()) .decorate("db", db) .get("/", () => Bun.file("index.html")) .get("/tasks", ({ db }) => { const tasks = db.query("SELECT * FROM tasks ORDER BY id DESC").all() as Task[]; return TaskList({ tasks }); }) .listen(3000);
3 collapsed lines
console.log( `🦊 Servidor Elysia corriendo en http://${app.server?.hostname}:${app.server?.port}`);3.2: Estructura del index.html
Ahora, crea el archivo index.html en la raíz de tu proyecto. Esta será la única página HTML que cargaremos.
La clave está en el <head>: importamos el script de HTMX (usaremos un CDN para simplificar). Con solo esta línea, “activamos” los superpoderes de HTMX en toda la página. Para que se vea bien sin esfuerzo, incluiremos también Simple.css.
<!DOCTYPE html><html lang="es"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>CRUD con HTMX y Bun</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.js" integrity="sha384-ezjq8118wdwdRMj+nX4bevEi+cDLTbhLAeFF688VK8tPDGeLUe0WoY2MZtSla72F" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"></head><body> <main> <header> <h1>Mi Lista de Tareas (HTMX + Elysia)</h1> </header>
</main></body></html>3.3: (C)reate y (R)ead con HTMX
Es el momento de conectar el HTML con el backend. Aquí es donde se ve cómo usar HTMX con ElysiaJS de forma práctica. Vamos a implementar (R)ead y (C)reate en el frontend.
Modifica tu index.html para incluir el formulario de creación y el contenedor de la lista:
11 collapsed lines
<!DOCTYPE html><html lang="es"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>CRUD con HTMX y Bun</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.js" integrity="sha384-ezjq8118wdwdRMj+nX4bevEi+cDLTbhLAeFF688VK8tPDGeLUe0WoY2MZtSla72F" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"></head><body> <main> <header> <h1>Mi Lista de Tareas (HTMX + Elysia)</h1> </header> <form hx-post="/tasks" hx-target="#task-list" hx-swap="afterbegin" hx-on::after-request="this.reset()" > <input type="text" name="text" placeholder="Nueva tarea..." required> <button type="submit">Añadir</button> </form>
<ul id="task-list" hx-get="/tasks" hx-trigger="load" hx-swap="innerHTML" > <li>Cargando tareas...</li> </ul> </main></body></html>Analicemos la magia de HTMX en esos atributos:
Para el formulario <form> (Create):
hx-post="/tasks": Este es un ejemplo deHTMX hx-post. Intercepta elsubmity envía una petición POST vía AJAX a/tasks. (Este endpoint lo crearemos en el siguiente paso).hx-target="#task-list": Apunta a dónde debe ir la respuesta del servidor.hx-on::after-request="this.reset()": Un pequeño extra útil que limpia el input del formulario después de que la petición termine.
Para la lista <ul> (Read):
id="task-list": Identificador único para que el formulario sepa dónde actuar.hx-get="/tasks": Al cargar, haz una petición GET a nuestro endpoint/tasks.hx-trigger="load": Ejecuta la peticiónhx-getinmediatamente al cargar la página. Esto reemplaza la necesidad de unwindow.onloadouseEffect.hx-swap="innerHTML": Reemplaza el contenido interno del<ul>(el “Cargando…”) con el HTML que devuelva el servidor (nuestroTaskList).
Añadimos la ruta post correspondiente en el index.ts.
4 collapsed lines
import { Elysia } from "elysia";import { html } from "@elysiajs/html";import { db, type Task } from "./db";import { TaskList } from "./components";
const app = new Elysia() .use(html()) .decorate("db", db) .get("/", () => Bun.file("index.html")) .get("/tasks", ({ db }) => { const tasks = db.query("SELECT * FROM tasks ORDER BY id DESC").all() as Task[]; return TaskList({ tasks }); }) .post("/tasks", ({ db, body }) => { const { text } = body as { text: string }; if (!text || text.trim().length === 0) { return new Response("La tarea no puede estar vacía", { status: 400 }); } const newTask = db .query("INSERT INTO tasks (text) VALUES ($text) RETURNING *") .get({ $text: text.trim() }) as Task; return TaskItem(newTask); }) .listen(3000);
3 collapsed lines
console.log( `🦊 Servidor Elysia corriendo en http://${app.server?.hostname}:${app.server?.port}`);
Paso 4: Completando el CRUD: (U)pdate y (D)elete
Ya tenemos implementada la creación (C) y la lectura (R) de nuestras tareas. Para completar nuestro ejemplo CRUD Bun.js y HTMX, nos faltan las dos operaciones clave: actualizar (U) y borrar (D). Aquí es donde la combinación de atributos de HTMX, como HTMX hx-swap hx-target, realmente demuestra su potencia.
4.1: (D)elete - Borrando Tareas
Comencemos por el borrado. Primero, añadimos el endpoint DELETE a nuestro index.ts. Este handler recibirá el id de la tarea como parámetro en la URL (:id).
En Elysia, capturamos los parámetros de la ruta con params.id. Usaremos este ID para ejecutar una consulta DELETE en nuestra base de datos SQLite. Es importante destacar que este endpoint debe devolver una respuesta vacía, ya que el elemento simplemente desaparecerá del DOM.
4 collapsed lines
import { Elysia } from "elysia";import { html } from "@elysiajs/html";import { db, type Task } from "./db";import { TaskList } from "./components";
const app = new Elysia()17 collapsed lines
.use(html()) .decorate("db", db) .get("/", () => Bun.file("index.html")) .get("/tasks", ({ db }) => { const tasks = db.query("SELECT * FROM tasks ORDER BY id DESC").all() as Task[]; return TaskList({ tasks }); }) .post("/tasks", ({ db, body }) => { const { text } = body as { text: string }; if (!text || text.trim().length === 0) { return new Response("La tarea no puede estar vacía", { status: 400 }); } const newTask = db .query("INSERT INTO tasks (text) VALUES ($text) RETURNING *") .get({ $text: text.trim() }) as Task; return TaskItem(newTask); }) .delete("/tasks/:id", ({ db, params }) => { const id = Number(params.id); if (isNaN(id)) return new Response("ID inválido", { status: 400 });
db.query("DELETE FROM tasks WHERE id = $id").run({ $id: id });
// Importante: devolvemos una respuesta vacía (string vacío) // HTMX reemplazará el elemento con "nada" return ""; })5 collapsed lines
.listen(3000);
console.log( `🦊 Servidor Elysia corriendo en http://${app.server?.hostname}:${app.server?.port}`);Ahora, actualizamos nuestro “componente” TaskItem (en components.ts) para incluir el botón de borrado con los atributos HTMX correspondientes.
import type { Task } from "./db";
export const TaskItem = ({ id, text, completed }: Task) => { return ` <li class="${completed ? 'completed' : ''}"> <span>${text}</span>
<button hx-delete="/tasks/${id}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="¿Seguro que quieres borrar esta tarea?" > Borrar </button> </li> `;};
4 collapsed lines
export const TaskList = ({ tasks }: { tasks: Task[] }): string => { return tasks .map((task) => TaskItem(task)).join("");};Analicemos esto: hx-delete le dice a HTMX que haga una petición DELETE a la URL /tasks/ID_DE_LA_TAREA.
La magia ocurre con hx-target="closest li". Esto le instruye a HTMX: “cuando recibas la respuesta, no actúes sobre este botón, sino busca el ancestro <li> más cercano”.
Luego, hx-swap="outerHTML" dice: “reemplaza ese <li> entero (el outer HTML) por la respuesta del servidor”. Como nuestro endpoint DELETE devuelve una cadena vacía, el <li> es reemplazado por “nada” y desaparece del DOM.
4.2: (U)pdate - Marcar como Completada
La actualización sigue un patrón similar, pero con una diferencia clave: el servidor debe devolver el HTML actualizado del elemento. Crearemos un endpoint PUT que cambie el estado completed de una tarea (un toggle).
import { Elysia } from "elysia";import { html } from "@elysiajs/html";import { db, type Task } from "./db";import { TaskItem, TaskList } from "./components";
const app = new Elysia()17 collapsed lines
.use(html()) .decorate("db", db) .get("/", () => Bun.file("index.html")) .get("/tasks", ({ db }) => { const tasks = db.query("SELECT * FROM tasks ORDER BY id DESC").all() as Task[]; return TaskList({ tasks }); }) .post("/tasks", ({ db, body }) => { const { text } = body as { text: string }; if (!text || text.trim().length === 0) { return new Response("La tarea no puede estar vacía", { status: 400 }); } const newTask = db .query("INSERT INTO tasks (text) VALUES ($text) RETURNING *") .get({ $text: text.trim() }) as Task; return TaskItem(newTask); }) .put("/tasks/:id/toggle", ({ db, params }) => { const id = Number(params.id); if (isNaN(id)) return new Response("ID inválido", { status: 400 });
const currentTask = db.query("SELECT * FROM tasks WHERE id = $id") .get({ $id: id }) as Task | undefined;
if (!currentTask) return new Response("Tarea no encontrada", { status: 404 });
const newCompleted = !currentTask.completed;
db.query("UPDATE tasks SET completed = $completed WHERE id = $id") .run({ $completed: newCompleted ? 1 : 0, $id: id });
return TaskItem({ id: id, text: currentTask.text, completed: newCompleted, }); })15 collapsed lines
.delete("/tasks/:id", ({ db, params }) => { const id = Number(params.id); if (isNaN(id)) return new Response("ID inválido", { status: 400 });
db.query("DELETE FROM tasks WHERE id = $id").run({ $id: id });
// Importante: devolvemos una respuesta vacía (string vacío) // HTMX reemplazará el elemento con "nada" return ""; }) .listen(3000);
console.log( `🦊 Servidor Elysia corriendo en http://${app.server?.hostname}:${app.server?.port}`);Para el frontend, actualizamos TaskItem una vez más. Añadiremos un checkbox que será el encargado de disparar la petición PUT.
import type { Task } from "./db";
export const TaskItem = ({ id, text, completed }: Task) => { return ` <li class="${completed ? 'completed' : ''}"> <input type="checkbox" id="task-${id}" ${completed ? 'checked' : ''} hx-put="/tasks/${id}/toggle" hx-target="closest li" hx-swap="outerHTML" > <label for="task-${id}" style="${completed ? "text-decoration: line-through" : ""};">${text}</label> <button hx-delete="/tasks/${id}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="¿Seguro que quieres borrar esta tarea?" > Borrar </button> </li> `;};
4 collapsed lines
export const TaskList = ({ tasks }: { tasks: Task[] }): string => { return tasks .map((task) => TaskItem(task)).join("");};El flujo es perfecto:
- El usuario hace clic en el checkbox.
- HTMX envía una petición
PUTa/tasks/ID/toggle(gracias ahx-put). - El servidor (Elysia) actualiza el estado en la base de datos (de
0a1o viceversa). - El servidor renderiza un nuevo
TaskItemcon el estado actualizado (class="completed"ychecked) y lo devuelve como respuesta HTML. - HTMX recibe este HTML y, gracias a
hx-target="closest li"yhx-swap="outerHTML", reemplaza el<li>antiguo por el<li>nuevo que acaba de recibir. El resultado es una actualización instantánea en la UI.
Paso 5: (Opcional) Validación de Datos con Elysia
Tenemos un CRUD funcional, pero es inseguro. Aunque HTMX parezca simple, la validación en el backend es absolutamente crucial. Nunca, bajo ninguna circunstancia, debemos confiar en los datos que provienen del cliente, ya que un usuario malintencionado podría saltarse nuestro index.html y enviar peticiones POST malformadas.
Afortunadamente, Elysia tiene un sistema de validación de formularios ElysiaJS (y de cualquier payload) de primera clase, basado en TypeBox. Para usarlo, primero importamos t (el constructor de tipos) junto a Elysia.
Ahora, modificamos nuestro endpoint POST /tasks para definir un “esquema” para el body. Queremos asegurarnos de que el campo text exista y sea un string con al menos 1 carácter.
import { Elysia, t } from "elysia";import { html } from "@elysiajs/html";import { db, type Task } from "./db";import { TaskItem, TaskList } from "./components";
const app = new Elysia()7 collapsed lines
.use(html()) .decorate("db", db) .get("/", () => Bun.file("index.html")) .get("/tasks", ({ db }) => { const tasks = db.query("SELECT * FROM tasks ORDER BY id DESC").all() as Task[]; return TaskList({ tasks }); }) .post("/tasks", async ({ db, body, set }) => { const { text } = body;
const newTask = db .query("INSERT INTO tasks (text) VALUES ($text) RETURNING *") .get({ $text: text }) as Task;
set.headers['HX-Trigger'] = 'clear-error'; return TaskItem(newTask); }, { body: t.Object({ text: t.String({ minLength: 1, error: "La tarea no puede estar vacía." }), }), } )35 collapsed lines
.put("/tasks/:id/toggle", ({ db, params }) => { const id = Number(params.id); if (isNaN(id)) return new Response("ID inválido", { status: 400 });
const currentTask = db.query("SELECT * FROM tasks WHERE id = $id") .get({ $id: id }) as Task | undefined;
if (!currentTask) return new Response("Tarea no encontrada", { status: 404 });
const newCompleted = !currentTask.completed;
db.query("UPDATE tasks SET completed = $completed WHERE id = $id") .run({ $completed: newCompleted ? 1 : 0, $id: id });
return TaskItem({ id: id, text: currentTask.text, completed: newCompleted, }); }) .delete("/tasks/:id", ({ db, params }) => { const id = Number(params.id); if (isNaN(id)) return new Response("ID inválido", { status: 400 });
db.query("DELETE FROM tasks WHERE id = $id").run({ $id: id });
// Importante: devolvemos una respuesta vacía (string vacío) // HTMX reemplazará el elemento con "nada" return ""; }) .listen(3000);
console.log( `🦊 Servidor Elysia corriendo en http://${app.server?.hostname}:${app.server?.port}`);Si un usuario envía un formulario con un string vacío, Elysia detendrá la petición automáticamente y devolverá una respuesta 422 (Unprocessable Entity). Sin embargo, HTMX espera HTML, no un error JSON.
Podemos gestionar esto de forma elegante. Añadiremos un hook .onError global (aunque para aplicaciones más grandes debería ser local por cada ruta) a nuestra app de Elysia. Si el error es de tipo VALIDATION, le diremos a HTMX (usando headers especiales) que renderice el mensaje de error en un div específico. Consulta la documentación de Error Handling de Elysia.js.
4 collapsed lines
import { Elysia, t } from "elysia";import { html } from "@elysiajs/html";import { db, type Task } from "./db";import { TaskItem, TaskList } from "./components";
const app = new Elysia() .use(html()) .decorate("db", db) .onError(({ code, error, set }) => { if (code === "VALIDATION") { set.status = 422; set.headers['HX-Reswap'] = 'innerHTML'; set.headers['HX-Retarget'] = '#form-error'; return `<p style="color: red;">${error.message}</p>`; } })60 collapsed lines
.get("/", () => Bun.file("index.html")) .get("/tasks", ({ db }) => { const tasks = db.query("SELECT * FROM tasks ORDER BY id DESC").all() as Task[]; return TaskList({ tasks }); }) .post("/tasks", async ({ db, body, set }) => { const { text } = body;
const newTask = db .query("INSERT INTO tasks (text) VALUES ($text) RETURNING *") .get({ $text: text }) as Task;
set.headers['HX-Trigger'] = 'clear-error'; return TaskItem(newTask); }, { body: t.Object({ text: t.String({ minLength: 1, error: "La tarea no puede estar vacía." }), }), } ) .delete("/tasks/:id", ({ db, params }) => { const id = Number(params.id); if (isNaN(id)) return new Response("ID inválido", { status: 400 });
db.query("DELETE FROM tasks WHERE id = $id").run({ $id: id });
// Importante: devolvemos una respuesta vacía (string vacío) // HTMX reemplazará el elemento con "nada" return ""; }) .put("/tasks/:id/toggle", ({ db, params }) => { const id = Number(params.id); if (isNaN(id)) return new Response("ID inválido", { status: 400 });
const currentTask = db.query("SELECT * FROM tasks WHERE id = $id") .get({ $id: id }) as Task | undefined;
if (!currentTask) return new Response("Tarea no encontrada", { status: 404 });
const newCompleted = !currentTask.completed;
db.query("UPDATE tasks SET completed = $completed WHERE id = $id") .run({ $completed: newCompleted ? 1 : 0, $id: id });
return TaskItem({ id: id, text: currentTask.text, completed: newCompleted, }); }) .listen(3000);
console.log( `🦊 Servidor Elysia corriendo en http://${app.server?.hostname}:${app.server?.port}`);Finalmente, en index.html, añadimos el div #form-error que recibirá el mensaje, y un trigger para limpiarlo cuando una petición sea exitosa (el HX-Trigger: clear-error que añadimos al POST). Le hemos quitado el required al input con fines de testeo, pero siempre se debe mantener una validación frontend & backend.
6 collapsed lines
<!DOCTYPE html><html lang="es"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>CRUD con HTMX y Bun</title> <meta name="htmx-config" content='{ "responseHandling":[ {"code":"422", "swap": true}, {"code":"...", "swap": true} ] }' /> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.js" integrity="sha384-ezjq8118wdwdRMj+nX4bevEi+cDLTbhLAeFF688VK8tPDGeLUe0WoY2MZtSla72F" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"></head><body> <main> <header> <h1>Mi Lista de Tareas (HTMX + Elysia)</h1> </header>
<form hx-post="/tasks" hx-target="#task-list" hx-swap="afterbegin" hx-on::after-request="this.reset()" hx-on:clear-error="htmx.find('#form-error').innerHTML = ''" > <input type="text" name="text" placeholder="Nueva tarea..."> <button type="submit">Añadir</button> </form>
<div id="form-error"></div>
11 collapsed lines
<ul id="task-list" hx-get="/tasks" hx-trigger="load" hx-swap="innerHTML" > <li>Cargando tareas...</li> </ul>
</main></body></html>
Es necesaria la <meta name="htmx-config" ...> para que HTMX sepa modificar el HTML según ciertos errores HTTP.
Ahora, si la validación falla, HTMX recibirá el código 422, leerá los headers HX-Retarget y HX-Reswap, e insertará el mensaje de error en el div#form-error sin recargar la página. Para un desglose completo de todas las opciones, consulta la documentación oficial de Validación de Elysia.
Conclusión: ¿El Stack del Futuro para la Web?
Hemos llegado al final y el resultado es un CRUD completo, funcional y ultrarrápido. Hemos visto cómo la combinación de Bun.js y Elysia proporciona un rendimiento de backend excepcional, mientras que HTMX simplifica drásticamente el frontend, permitiéndonos crear interacciones ricas sin apenas escribir JavaScript en el cliente.
La ventaja clave es la simplicidad de volver al “HTML sobre el cable”. Reducimos la carga de JavaScript en el cliente al mínimo, eliminamos capas de state management y bundling complejo, y ganamos una velocidad de desarrollo increíble.
Este stack moderno backend JavaScript es ideal para dashboards internos, aplicaciones de contenido, side projects o cualquier sitio que necesite interactividad sin la complejidad de una SPA. Quizás no sea la elección para una aplicación offline-first o una UI tan compleja como Figma, pero suple un hueco enorme en el desarrollo web actual.
¿Próximos pasos? Las posibilidades son muchas: puedes añadir estilos con Tailwind o Pico.css, desplegarlo en servicios como Fly.io o Railway, o (muy recomendable) proteger tus endpoints con JWT.
NOTA: En el repositorio de GitHub tenéis una versión un poco mejorada en cuanto a funcionalidad y usabilidad, como os muestro aquí:
¡Ahora es tu turno! ¿Qué opinas de este stack? ¿Crees que HTMX y Bun tienen un hueco en tus futuros proyectos o prefieres seguir con arquitecturas tipo SPA? Déjanos tu opinión en los comentarios.
Preguntas Frecuentes
¿Necesito JavaScript en el cliente si uso HTMX?
Casi nunca. HTMX está diseñado para manejar la gran mayoría de la interactividad (peticiones AJAX, actualizaciones del DOM, WebSockets) usando solo atributos HTML. Solo necesitarías JS para casos de uso muy específicos, como integrar una librería de terceros (ej. un date picker complejo) o para lógica de cliente muy particular.
¿Es ElysiaJS estable para usar en producción?
Sí. Elysia alcanzó su versión 1.0 y se considera estable y listo para producción. Gracias a su rendimiento 'Bun-first' y su excelente sistema de validación y tipado, es una opción muy robusta para construir APIs de alto rendimiento.
¿Cómo conecto este stack a PostgreSQL o MySQL en lugar de SQLite?
¡Totalmente posible! Usamos bun:sqlite para simplificar este tutorial. Para producción, puedes integrar Elysia con un ORM como Drizzle o Prisma. Ambos tienen excelente compatibilidad con el ecosistema de Bun y te permitirán conectarte a PostgreSQL, MySQL u otras bases de datos robustas.
Referencias
Posts Relacionados
Guía Práctica de Accesibilidad Web WCAG: Implementación y Testing para Desarrolladores en 2025
Domina la accesibilidad web en 2025. Te explicamos los 4 principios WCAG, los niveles (A, AA, AAA) y cómo auditar e implementar todo con ejemplos de código.
Cómo Configurar tu Astro robots.txt Sin Errores (Estático vs. Integración)
¿Dudas con tu astro robots txt? Aprende a configurarlo: método estático en /public o la integración "astro-robots-txt". Optimiza tu crawl budget. ¡Entra ya!
Cómo Integrar y Personalizar Tailwind CSS en Proyectos con Astro Framework
Aprende a integrar Tailwind en Astro paso a paso. Cubrimos la instalación, configuración de "tailwind.config" y cómo solucionar errores comunes. Entra ya.
Mejores prácticas para mejorar la Optimización de tu Página Web
Descubre cómo se realiza una optimización de páginas web, analizar el rendimiento del sitio y mejorar el SEO y la experiencia de los usuarios.
Cómo Crear y Configurar un Sitemap en Astro para Mejorar tu SEO
¿Problemas con tu sitemap de Astro? Te enseñamos a configurarlo correctamente, filtrar páginas y evitar errores comunes. ¡Optimiza tu SEO ahora!
Desarrolla tu primera extensión de Chrome en 5 minutos
️Descubre cómo desarrollar una extensión de Chrome desde cero y amplía las funciones del navegador fácilmente.