Puede que este artículo no sea apto para todo el mundo. Para entenderlo, debes ser al menos aspirante a desarrollador frontend o full-stack, tener conocimientos básicos de conceptos como SSR, SSG y ISR, y haber usado Next.js al menos una vez.
Prefacio
Tal vez se deba a que siento desde siempre una gran curiosidad por los principios fundamentales de las cosas y a que, en ocasiones, me empeño en averiguar exactamente por qué algo no puede hacerse de cierta manera. Supongo que quizá sea eso lo que me permite tener de vez en cuando ideas singulares, y esta es una de ellas. Se me ocurrió ya en noviembre de 2025 y entonces hablé de ella con muchos amigos cercanos, pero los conflictos de tiempo con los proyectos que tenía entre manos hicieron que siguiera posponiéndola hasta que, alrededor del Año Nuevo de 2026, por fin me puse a trabajar y preparé una primera versión. Después volvió a quedar aparcada, porque me fui a crear un sitio web. Aunque el blog aún no está del todo terminado, al menos ya tengo un lugar más o menos adecuado donde dejar constancia de las cosas, así que quiero recuperar este asunto y volver a hablar de él.
Antes que nada, quiero dejar clara una cosa: no estoy diciendo que SSR sea malo. Simplemente creo que, por la tendencia que he observado, demasiada gente está abusando de ello y que la dirección general consiste en dejarse llevar por la corriente para eludir un problema. He hablado de esto con bastantes personas, pero la mayoría de sus respuestas han sido poco concluyentes. También he explicado mi propuesta, aunque los demás siempre temen que pueda plantear toda clase de problemas y casos límite. Y, sinceramente, tampoco me apetece explicar cada vez todo el razonamiento de principio a fin. Así que hoy he decidido detallarlo por escrito e intentar aclarar algunas dudas y los principios técnicos implicados.
El abuso del SSR
Cuando apenas comenzaba a aprender desarrollo front-end web, el primer framework full-stack que conocí probablemente fue Next.js. Reconozco que es un framework excelente. Aunque tiene algunos problemas de rendimiento, estos son el precio muy real que se paga por su experiencia de desarrollo, y eso es perfectamente razonable. Sin embargo, a medida que fui profundizando en los conceptos y conocimientos del front-end, descubrí que quizá cada vez era menos adecuado para mí. Me encanta escribir en Rust, disfruto investigando las capas de bajo nivel y he experimentado con sistemas embebidos, así que tengo una fijación muy particular con el rendimiento. Esa fijación no significa llevarlo todo al límite; se trata más bien de preguntarme por qué nadie utiliza una solución que ya existe, ofrece un rendimiento claramente superior y quizá requiere muy poco esfuerzo. Por eso, cada vez que uso Next, pienso: incluso en una página muy sencilla, donde casi todo es fijo y solo hay que cambiar una parte, ¿por qué es necesario volver a renderizar la página entera? ¿Por qué? Según lo entiendo, aunque el back-end no esté escrito en Rust, aunque no se busque el rendimiento extremo e incluso aunque se ejecute sobre un intérprete de JavaScript, la pequeña cantidad de datos que cambia realmente debería suponer un coste relativamente bajo; el «renderizado» debería realizarse de una forma muy barata, en lugar de volver a renderizar la página entera.
Con el tiempo, llegó a convertirse en un obstáculo mental para mí, en una cuestión sobre la que no tenía más remedio que reflexionar. Sin embargo, resulta irónico que, después de repasar casi diez años de evolución del desarrollo de interfaces, junto con Vue, Svelte, Solid y sus marcos de desarrollo Next.js, TanStack Start, Remix, Nuxt, Sveltekit y Soild Start, me pareciera que todos se habían desviado del rumbo. En particular, el RSC de React es un error, aunque en realidad su existencia tiene sus razones; quizá, si se presenta la ocasión, lo explique con detalle más adelante...
Desde mi punto de vista, RSC y toda una serie de frameworks SSR están abusando de render. SSG sí consigue el estilo que busco —la página se genera durante la compilación y no necesita volver a renderizarse al recibir una solicitud—, pero carece de capacidades verdaderamente dinámicas (x). Por su parte, ISR parece encontrar un equilibrio entre SSG y SSR y ofrecer cierta flexibilidad, pero en esencia no cumple mis expectativas. Creo que ISR surgió realmente para compensar el bajo rendimiento de SSR; las distintas estrategias posteriores de almacenamiento en caché mediante CDN y demás operaciones similares no dejan de ser apaños, en lugar de soluciones verdaderamente elegantes.
Por supuesto, el SSR no es un error en sí mismo; es una técnica muy útil que resuelve muchos casos reales. El problema es que estos frameworks han llevado el SSR hasta el extremo de convertirlo en la opción predeterminada, de modo que quizá el 95 % de las situaciones en las que los desarrolladores lo usan realmente no lo necesitan en absoluto. La mayor parte del contenido de una página es fijo y hay muy pocas partes verdaderamente dinámicas, pero aun así se ejecuta todo el árbol de componentes con cada solicitud.
En los últimos años, React sí ha mejorado un poco: React 19.2 incorpora PPR, pero todavía conlleva un renderToString() costoso en tiempo de ejecución, así que es más bien una solución provisional que una solución real. Por eso pensé: si la mayoría de las cosas ya están determinadas durante la construcción, ¿por qué no realizar el renderizado directamente durante la compilación?
¿Renderizar al compilar?
Llevar el renderizado al tiempo de compilación suena bastante absurdo, porque la razón fundamental por la que existe el renderizado del lado del servidor es que hay valores y condiciones que sencillamente no se pueden resolver hasta llegar a ese punto concreto de la ejecución: no sabes cuáles son y, por tanto, no puedes tomar una decisión. Así que es comprensible que la mayoría descarte mi idea de inmediato; no les falta razón.
Pero lo cierto es que se me ocurrió un Pipeline bastante ingenioso para conseguirlo, y precisamente por eso necesito llamarlo una especie de Protocol. Además, siempre me ha parecido muy estúpido que los entornos de desarrollo de pila completa desdibujen la frontera entre el cliente y el servidor. (Aunque tengo que admitir que, en los últimos años, Next.js ha ofrecido una experiencia de desarrollo excelente en este aspecto, lo que lleva a muchos principiantes a pensar que crear una aplicación de pila completa es muy sencillo, cuando en realidad también genera muchos riesgos de seguridad; pero me estoy desviando del tema.)
Lo mismo ocurre con la obtención de datos dentro de los componentes: prefiero un enfoque con límites claros; los componentes deben ser puros y la obtención de datos debe extraerse por completo. Una vez que aceptas esta premisa y separas los componentes puros de los datos, la cuestión se vuelve interesante: descubres que los propios datos pueden clasificarse.
También tengo que agradecer a TypeScript la inspiración para esta idea. En realidad, no importa qué sea en sí un «valor» durante la compilación; lo importante es su tipo. Sea cual sea el contenido que haya que poner en un slot, mientras no sea algo como Open String (una cadena con infinitos valores posibles), puede encapsularse en un tipo que represente un conjunto finito de posibilidades. Por ejemplo, al desarrollar un panel de control y tener que mostrar una sección de forma condicional, al final todo se reduce a casos como usuario o administrador, es decir, a tipos que siempre pueden definirse de manera exhaustiva. En el mundo real, casi todas las situaciones que requieren representación condicional o decisiones lógicas pueden reducirse a unas pocas posibilidades determinadas.
Y precisamente hay una forma excelente de describir estas posibilidades: ¡JTD!
Especificación JTD
JTD (definición de tipos JSON), definida en la RFC 8927, tiene ocho Schema Forms: Empty, Ref, Type (booleano, cadena, marca temporal y tipos numéricos de distintas precisiones), Enum, Elements, Properties, Values y Discriminator; además, cualquier esquema puede marcarse como nullable. La ventaja de JTD es que funciona entre lenguajes: existen correspondencias de tipos para JavaScript, Rust, Go y casi cualquier otro lenguaje. Esto la convierte de forma natural en un puente entre el frontend y el backend, mientras que su formato, JSON, ya es de por sí el mínimo común denominador entre el frontend y el backend. En el mundo real, el 95% de las aplicaciones web apenas hacen algo más que mostrar cadenas o campos numéricos, o tomar decisiones mediante valores booleanos, y todo ello queda dentro de este ámbito. Una vez aclarado esto, el margen con el que podemos trabajar es realmente muy amplio. Por supuesto, JTD tampoco es perfecta y existen excepciones, como Markdown, pero hablaremos de ellas con más detalle más adelante. Aquí es, en realidad, donde verdaderamente reconozco el valor de SSR.
Valor centinela
Empecemos por lo sencillo: como ya conocemos el tipo de cada valor dinámico, podemos ejecutar una vez el componente de React con renderToString() durante la compilación, pero no con datos reales, sino con datos simulados mediante Sentinel. ¿Qué significa esto exactamente?
{ user: { name: "Alice", age: 30 } }Supongamos que tus datos tienen este aspecto; entonces podemos reemplazarlos por
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }Estos %%SEAM:...%% son centinelas que ocupan la posición de cada valor dinámico. Cuando React termina de ejecutar renderToString() con estos datos centinela, esas posiciones quedan marcadas en el HTML generado. A continuación, la cadena de compilación convierte los centinelas en marcadores de espacio con formato de comentarios HTML.
<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>
<!-- Slot -->
<span><!--seam:user.name--></span>A estas alturas, quizá ya hayas visto que estos slot son «espacios con marcadores de tipo». En este punto, la tarea del entorno de ejecución del servidor se vuelve extremadamente sencilla: obtener los datos reales y rellenar estos huecos con sus valores mediante una mera sustitución de cadenas. No hace falta en absoluto renderToString(), ni un entorno de ejecución de JavaScript, ni vDOM. Cualquier lenguaje capaz de analizar comentarios HTML y realizar una sustitución de cadenas puede usarse en el servidor; Rust, Go y TypeScript sirven por igual. Por eso es un protocolo y no un marco de trabajo.
Llegados a este punto, quizá te preguntes cómo gestionar el renderizado condicional. Por ejemplo, si un campo es null, no debería aparecer todo un bloque de contenido. Sin embargo, esto tampoco requiere que JavaScript lo determine en tiempo de ejecución; el protocolo puede definir cómo tratar este caso.
<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->Lo mismo ocurre con el renderizado condicional y el renderizado de listas.
<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->Incluso la coincidencia de patrones
<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->Este es el arte de los comentarios: aquí se eligieron simplemente porque, por casualidad, constituyen HTML válido; no hay más. Entonces, ¿cómo se determinan y generan estos bloques condicionales y bucles en tiempo de compilación? En realidad, el método es bastante ingenioso: aunque no tenemos un DOM virtual, podemos renderizar el HTML dos veces y comparar las diferencias. Tomemos como ejemplo el renderizado condicional: primero se renderiza con todos los datos centinela; después, se asigna null a un campo nullable determinado y se vuelve a renderizar. Al comparar ambas salidas, la parte del HTML que desapareció es el bloque condicional controlado por ese campo, así que basta con envolverla directamente en <!--seam:if:...-->. Con los arreglos sucede lo mismo.
¿Quizá el CTR?
Seguro que alguien volverá a preocuparse: ¿no crecerá exponencialmente el número de combinaciones del renderizado condicional? Por ejemplo, si entre tres y cinco variables controlan el renderizado condicional y cada una tiene diez valores posibles, al multiplicarlos se obtiene una cifra que parece bastante grande. Sin embargo, en la práctica esa cifra intimida más de lo que debería. En primer lugar, no hace falta enumerar el contenido real de muchos valores de tipo cadena; solo importa si hay un valor o no nullable, así que en realidad existen únicamente dos posibilidades. En segundo lugar, el tipo de cualquier campo que de verdad intervenga en una condición debe ser enumerable; no usarías un open string para realizar una comprobación if, ¿verdad? Además, aunque realmente hubiera que enumerar todas las combinaciones, un CPU moderno probablemente terminaría en apenas unos ms. Y todo ese trabajo ocurre durante la compilación, igual que al compilar Rust: se paga un coste relativamente alto al compilar, pero durante la ejecución todo se reduce a una simple sustitución de cadenas. Se mire como se mire, el intercambio compensa, sobre todo porque aquí no hay ninguna magia especial. No obstante, puedo dejar planteado un pequeño detalle: más adelante introduzco un diminuto entorno de ejecución de JavaScript integrado para realizar algunas deducciones complejas. Es una concesión; después explicaré por qué fue necesaria y por qué, en teoría, podría prescindirse de ella.
Esto es lo que llamo CTR (renderizado en tiempo de compilación). Sus limitaciones son exactamente las mismas que las del SSR: por ejemplo, <!--seam:path--> aplica automáticamente el escape de HTML al insertar texto (&, <, >, etc.); para insertar HTML sin procesar es necesario usar <!--seam:path:html--> de forma explícita. Una ruta de datos inexistente se convierte en una cadena vacía en el texto slot, mientras que en el atributo slot se omite la inserción. Asimismo, un bloque each se omite directamente si recibe algo que no sea un arreglo. Estos comportamientos no difieren en esencia de los casos límite que aparecen en los frameworks de SSR; lo único que he hecho es trasladar a la fase de compilación el paso de renderizado del SSR. Durante la construcción, CTR recorre el producto cartesiano de todos los valores tipados posibles de las variables condicionales, utilizando valores simulados arbitrarios generados automáticamente que cumplan los tipos o valores simulados especiales definidos por el usuario (por lo general, no hace falta proporcionar manualmente los valores necesarios para generar todas las variantes de HTML, aunque es posible hacerlo; de lo contrario, se elige automáticamente un valor compatible con cada tipo). Cada combinación se renderiza una vez y los resultados se comparan para detectar los límites de todos los bloques condicionales y repetitivos, con lo que finalmente se obtiene un esqueleto de HTML completamente desplegado. Matemáticamente, siempre que los datos proporcionados en tiempo de ejecución cumplan estas definiciones de tipos, podrán insertarse correctamente en el esqueleto, pues todas las rutas de bifurcación posibles ya se habrán enumerado exhaustivamente durante la compilación.
¿Coherencia?
Entonces, ¿cómo se garantiza la coherencia? La respuesta está en el contrato JTD: aunque el frontend y el backend estén separados, mientras ambos sigan el mismo esquema JTD, sus tipos de datos permanecerán alineados y no habrá incoherencias.
Pero yo tenía un reto adicional frente a otros marcos de trabajo: al liberar el lado servidor del entorno de ejecución de JavaScript, este ya no tenía que formar un todo con el lado cliente; puedes escribirlo perfectamente en Rust o Go. En un marco de pila completa basado en TypeScript, la coherencia entre cliente y servidor puede garantizarse directamente mediante los tipos; ¿pero qué ocurre con otros lenguajes? En realidad, aquí usé generación de código: sin importar el lenguaje en que esté escrito el servidor, este actúa como referencia, y las variables y los tipos disponibles para el cliente se generan directamente como TypeScript para que pueda importarlos. Así, aunque la frontera entre cliente y servidor queda claramente delimitada, ambos pueden vivir en la misma carpeta como en un marco de pila completa basado en TypeScript, de acuerdo con las prácticas de un repositorio único, y llamarse directamente entre sí sin escribir a mano interfaces de programación ni elementos como gen OpenAPI. En realidad, uso la ruta privada /_seam/ como punto de conexión predeterminado del marco, configurable igual que en Nuxt; ejecutar JTD Typed-RPC basta para resolver los problemas de transmisión de datos y de CORS.
CTR × SSR
Después está raw HTML slot, que nos devuelve precisamente a ese 5 % de casos límite: en elementos como Markdown y Rich Text es sencillamente imposible conocer su valor durante la compilación y, además, restringirlos mediante tipos resulta extremadamente costoso. Por supuesto, se podría ampliar el protocolo, enumerar toda la sintaxis de Markdown y analizarla durante la ejecución, pero ese trabajo equivaldría prácticamente a reescribir un renderizador de Markdown, ¿no? Restringir únicamente los pocos tipos imprescindibles no supone ni de lejos el mismo trabajo que reescribir todo el motor de renderizado. Esto también nos devuelve a la filosofía que mencioné al principio: resolver los problemas de una forma más elegante y con un coste menor.
Así que la buena noticia es que raw HTML slot permite que CTR y SSR coexistan. Esto significa que se puede crear la mayor parte de la interfaz de usuario de una página con CTR, prácticamente sin coste en tiempo de ejecución, y después representar el artículo Markdown principal con SSR. Como el servidor ya no está sujeto a esas limitaciones, se puede importar con TypeScript el método de representación existente de SSR, ¡o abrirse a otras posibilidades! Si el servidor utiliza Rust, naturalmente se puede representar el contenido con un compilador de Markdown para Rust. Al final, basta con producir una cadena HTML e insertarla en raw slot para que se muestre. Usar CTR no significa que no se pueda usar SSR; ambos pueden coexistir perfectamente.
La brecha del PPR
Entendido así, CTR es, en esencia, la forma ideal de PPR (prerrenderizado parcial): todo lo que se puede almacenar en caché queda completamente renderizado durante la compilación, sin ningún coste en tiempo de ejecución, y solo generan costes los pocos datos que realmente cambian. Entonces, ¿en qué se diferencia del PPR de React 19.2? La respuesta es que mi enfoque es más exhaustivo y más agresivo. PPR Incluso en el caso ideal, con todas las partes estáticas almacenadas en caché y solo un pequeño fragmento de contenido dinámico pendiente de actualización, React tiene que volver a ejecutar renderToReadableStream() aunque el único cambio sea una simple cadena de texto, y ese coste no es insignificante.
En cambio, CTR delimita esta frontera con total claridad: los tipos simples, como cadenas, números y valores booleanos, se procesan mediante sustitución directa de cadenas; solo los tipos complejos como Markdown, cuya enumeración exhaustiva tendría un coste enorme, pasan por un auténtico proceso de renderizado. Además, aquí el renderizado ya no se refiere al SSR en el sentido tradicional: puede emplear un método de renderizado implementado en cualquier lenguaje de programación.
Y RSC
Hablemos ahora de los RSC (componentes de servidor de React). Antes dije que los RSC podrían ser un error, porque difuminaban demasiado los límites entre el frontend y el backend, introducían bastantes riesgos de seguridad y numerosas CVE. Sin embargo, en realidad esto es más culpa de Next.js y de otros enfoques de SSR: los usuarios sencillamente no tienen la opción de prescindir de ellos. Si quieren contenido dinámico, tienen que usar SSR, aunque una sustitución de cadenas como la nuestra también funciona perfectamente. Asimismo, hay que reconocer que los RSC proporcionan al servidor una capacidad muy importante: renderizar cualquier componente de React, es decir, ejecutar código arbitrario.
Sin embargo, si de todos modos hay que ejecutar código arbitrario, resulta prácticamente imposible describirlo exhaustivamente mediante tipos. Incluso si fuera posible, el trabajo necesario no sería necesariamente menor que el de escribir un compilador de React. Por eso, una capacidad como la de RSC tiene razón de ser. ¿Pueden coexistir CTR y RSC? Sí, y sin ningún conflicto. No obstante, es algo que debe resolverse en la capa del framework y no forma parte del alcance del protocolo. Todavía no lo he implementado, pero parece que podría tomar prestado el enfoque de TanStack Start: bastaría con enviar un paquete html y un paquete js adicionales. En teoría, implementarlo debería ser bastante sencillo; como mínimo, el volumen de trabajo está claramente delimitado.
Volviendo a raw HTML slot: lo que resuelve son sobre todo casos como Markdown o Rich Text — el HTML renderizado se inyecta mediante dangerouslySetInnerHTML y, tras la hidratación, esa zona no participa en ninguna interacción, está "muerta". Esta parte debería reducirse al mínimo: si quieres darle bordes o estilos al artículo, eso se escribe con componentes de React, no mezclado dentro de ese HTML. El inconveniente es que ya no puede cambiar después de la hidratación, pero sí baja el coste del "SSR" bajo esas premisas hasta un nivel bajísimo, cercano al Zero Cost. De los casos que de verdad necesitan SSR, alrededor del 60% son esta inyección de HTML estático; solo el 40% restante necesita lo que ofrece RSC: ejecutar componentes arbitrarios en el servidor.
Independencia de la UI
Por último está la muy atractiva independencia del protocolo. En esencia, solo necesitamos captar un punto clave, renderToString; el framework de interfaz que se utilice por delante no tiene realmente ninguna relación con el protocolo. Entonces, ¿en qué se diferencia esto de Astro? Pero no te preocupes: desde luego, no estoy creando otro Astro. A primera vista, mi concepto puede parecerse un poco a las islas de Astro, pero en realidad existen grandes diferencias.
No estoy hidratando varios entornos de ejecución en una sola página. Mmm, en realidad, creo que los casos en los que esto resulta verdaderamente necesario son muy pocos. La comunicación del estado de los componentes entre distintas pilas tecnológicas se vuelve extremadamente costosa. Se parece más a una solución de transición para migrar de una pila a otra cuando no es posible sustituirlo todo de una vez. Además, Astro es, en esencia, una MPA, mientras que nosotros podemos ser una MPA antes de la hidratación y convertirnos en una SPA después, con enrutamiento del lado del cliente como Next.js y animaciones entre páginas, algo con lo que Astro solo puede soñar.
Astro y SSG
Además, el diseño de Astro solo resulta realmente útil en escenarios que abarcan varias pilas tecnológicas. Si lo usas por su velocidad pero empleas un único framework suyo—por ejemplo, si importas React pero no Vue—, entonces creo que la «rapidez» que promete es una falsa premisa. Es cierto que la primera pantalla se carga íntegramente como HTML, pero en cuanto quieres cualquier interacción hay que hidratarla, y el coste de esa hidratación es descargar todo el entorno de ejecución de React; en esencia, no hay ninguna diferencia con nuestra hidratación. Por supuesto, más adelante también podremos adoptar el concepto de islas y añadir un enrutador del armazón de la aplicación para permitir la navegación de una aplicación de página única entre distintos frameworks de interfaz de usuario, pero eso forma parte de una etapa posterior de la hoja de ruta y, al menos por ahora, no tengo prisa.
Por último, en comparación con el SSG tradicional, conseguí lo mismo que el SSG: en esencia, renderizar por completo todo lo que puede determinarse en tiempo de compilación. Pero nosotros somos más dinámicos, porque los valores slot de tipos simples pueden sustituirse por completo en tiempo de ejecución. Puedes entenderlo así: renderizamos el SSG como una especie de punto de entrada a una MPA; antes de la hidratación es una MPA y, después, se convierte en una SPA, sin perder la capacidad «dinámica».
¿Error de hidratación?
Por último está la discrepancia de hidratación que tanto detesto, y estoy seguro de que a ti tampoco te gusta. Sin embargo, en el fondo no es más que una incoherencia en el estado de DOM. Los frameworks tradicionales, como el App Router de Next.js, intentan envolver toda la aplicación con React; por eso, si el navegador inserta alguna etiqueta del lado del usuario, existe la posibilidad de que se produzca un error de hidratación. En realidad, tenemos las mismas limitaciones que el renderizado tradicional del lado del servidor, pero cuando usas TS también añado una envoltura de hidratación div llamada __root. De este modo, la zona de hidratación deja de abarcar la zona de metadatos, lo que hace que la hidratación sea más resistente. Además, React 19 incorpora compatibilidad con etiquetas de metadatos del documento como <title>, <meta> y <link>. Aunque solo hidrates un <div> concreto de la página en lugar de todo el <html>, puedes renderizar <title>My Page</title> directamente dentro de un componente y React lo elevará automáticamente hasta <head>.
Volviendo a las discrepancias de hidratación, como ya conocemos durante la compilación el tipo de cada slot, podemos añadir una comprobación de equivalencia CTR: rellenamos el HTML completamente expandido con datos simulados deducidos de las definiciones de tipos, ejecutamos una vez el renderToReadableStream() tradicional y después comparamos si ambos árboles DOM son semánticamente equivalentes, sin tener en cuenta las diferencias de formato. Puede haber pequeñas diferencias de formato, pero, siempre que sus estructuras DOM sean estricta y completamente equivalentes, realmente podemos despedirnos para siempre de las discrepancias de hidratación por completo. ¿Por qué no puede hacerlo el SSR tradicional? Porque no realiza esta operación hasta el tiempo de ejecución, mientras que la estructura CTR exige aplicar todas estas restricciones durante la compilación. Por supuesto, también ofrecemos any como vía de escape, pero, igual que con TypeScript, si usas any debes asumir deliberadamente las consecuencias: durante la compilación, la CLI te advertirá de que any, la vía de escape mediante una cadena abierta, puede provocar una discrepancia.
Sin servidor
Por supuesto, tampoco puede faltar la opción sin servidor. En los últimos años, la experiencia de los sistemas sin servidor se ha vuelto excelente; mi valoración es que «aparte del coste, prácticamente no tienen ninguna desventaja». CTR, sin embargo, encaja de forma natural en este escenario: el trabajo que realizamos durante la ejecución es ligero y reducido, por lo que funciona con gran rapidez en un entorno sin servidor y mejora notablemente tanto el tiempo de respuesta como la sobrecarga. Lo único que no puede mejorarse de este modo son casos como el renderizado de Markdown, pero eso debería optimizarse por completo en la lógica de negocio; por ejemplo, se puede prerenderizar y almacenar Markdown para no tener que volver a renderizarlo con cada solicitud, tal como hace mi sitio web actual. Este es un problema de la capa de negocio, no algo que pueda resolver un marco de trabajo, aunque este sí puede reducir casi a cero la sobrecarga del resto de la lógica sencilla. En comparación con el SSR tradicional, la sobrecarga ya ni siquiera pertenece al mismo orden de magnitud. ¿Hasta qué punto es pequeña? Se sitúa aproximadamente entre unos cientos de microsegundos y 1 milisegundo, algo impensable para el SSR tradicional.
Pero ¿qué ocurre si el servidor está escrito en otro lenguaje? Tomemos Cloudflare Workers como ejemplo: muchas plataformas sin servidor admiten WASM, por lo que otros lenguajes también pueden compilarse en binarios WASM y utilizarse en el servidor. En esencia, compilo la interfaz en recursos puramente estáticos, como en el renderizado del lado del cliente; pero, al combinar esos recursos con un puente privado, /_seam/, se obtienen las mismas capacidades dinámicas que ofrecen los verdaderos entornos de trabajo de pila completa, además de una compatibilidad total y natural con las plataformas sin servidor.
Seam y SeamJS
Puede que a estas alturas te hayas hecho un lío con la relación entre Seam y SeamJS. En realidad es muy sencillo: Seam es el protocolo. Define cómo marcar las posiciones dinámicas con Sentinel, cómo eso se convierte en marcadores slot, cómo se hace la detección diff de los bloques condicionales y de bucle, y cómo el runtime inyecta los datos a partir del AST. El protocolo en sí es independiente del lenguaje: cualquier backend capaz de analizar comentarios HTML y hacer sustitución de cadenas puede implementarlo. SeamJS es el framework, una implementación concreta construida sobre ese protocolo. entre sí ruedas ya existentes como Vite, TanStack Router y TanStack Query, y luego cubre lo que ellas no alcanzan: mi extracción de skeleton, el motor de inyección, la CLI y demás.
Pero, sinceramente, SeamJS todavía se encuentra en una fase muy básica. Funciona sin mayores problemas, pero aún necesitará mucho trabajo antes de que pueda usarse de verdad para desarrollar proyectos. También estoy reflexionando más sobre algunos cambios arquitectónicos, como abstraer el canal de transmisión de datos, aunque no sé si mantendré esta dirección en versiones posteriores. En cuanto a los frameworks, sin duda crearé un framework full-stack en TypeScript y otro framework insignia con Rust como backend. Por otro lado, tengo previsto retirar la implementación relacionada con Go en una versión futura, porque creo que no tengo capacidad suficiente para seguir manteniéndola.
Rendering is a protocol, not a render-time computation.
Más que un framework web
Entonces, ¿para qué sirve esto? En realidad, su uso no se limita a la web. Por ejemplo, una vez abstraído también el canal Transport, más adelante podrá adaptarse a entornos de escritorio como Electron o Tauri. Bastará con sustituir la canalización de transporte HTTP por una comunicación IPC, mientras se sigue ejecutando el mismo protocolo Seam. Así, las aplicaciones Electron ya no necesitarán mostrar una pantalla de carga al iniciarse y muchos elementos podrán renderizarse inmediatamente de forma local, como con el renderizado en servidor. ¡Esa es la magia de CTR!
El precio de no tener runtime JS
Entonces, ¿por qué SeamJS terminó incorporando de todos modos un entorno de ejecución de JS? Porque, al llevar este enfoque a la práctica, me encontré con un problema: el ideal de CTR impone restricciones extremadamente estrictas a los datos de la primera renderización. Los datos deben formar una estructura determinista que pueda deducirse por completo; ni siquiera pueden contener lógica de cálculo, solo condiciones. Esto resulta excesivamente restrictivo. Desde la perspectiva de la experiencia de desarrollo de un framework, quienes están acostumbrados a escribir React de la forma tradicional esperan, naturalmente, poder realizar algunos cálculos dentro de un componente y utilizar directamente los valores obtenidos. Cumplir estrictamente las restricciones de CTR para alcanzar un estado ready-to-display haría que el proceso fuera sumamente engorroso e incluso podría obligar a escribir dos veces el mismo componente.
Sin embargo, sí hay una solución: debido al funcionamiento de la Web, el frontend solo puede ejecutarse sobre JavaScript, así que los componentes naturalmente solo pueden funcionar como JS. Por tanto, para resolver este problema, el backend también debe poder ejecutar JS; un proyecto full stack en TypeScript puede aprovechar directamente su entorno de ejecución existente. En Rust solo hace falta integrar un entorno de ejecución de JS diminuto como QuickJS. Cabe señalar que este entorno de ejecución de JavaScript solo implementa un subconjunto estándar de JS y no se parece en absoluto a los de Bun o Node, que incluyen API completas del sistema operativo. En realidad, solo se utiliza para inferir y derivar datos.
Esto permite seguir escribiendo cierta lógica de cálculo dentro del componente: cuando el servidor recibe los datos, ejecuta un pequeño fragmento de JavaScript para derivar de ellos el estado ready-to-display y después lo devuelve a la interfaz para hidratar la primera vista. CTR queda satisfecho porque obtiene datos derivados con una estructura estricta, y la experiencia de desarrollo también mejora porque el componente solo tiene que escribirse una vez. Por supuesto, si se respetan rigurosamente las restricciones de tipos puros, el servidor puede funcionar sin un entorno de ejecución de JavaScript, por lo que técnicamente la promesa sigue siendo válida. En cualquier caso, el pequeño entorno de ejecución de JavaScript añadido tiene una sobrecarga de rendimiento, un consumo de memoria y un tamaño muy inferiores a los de algo como Node: ocupa apenas unos 200–300 KB, pero aporta una enorme flexibilidad.
Perspectivas de futuro
Después de tanto hablar, ¿cuándo se podrá ? Seguramente falte mucho, muchísimo. De verdad que no es que quiera ; es que, a estas alturas, este trasto me tiene secuestrado demasiado, muchísimo. Si solo puedo usarlo para desarrollarlo, entonces básicamente no escribo aplicaciones: escriba lo que escriba, el framework me pone la zancadilla primero. Y menos aún cuando últimamente he estado sobre todo con esta web y cosas por el estilo. Así que lo he visto claro: mejor darle antes cierta envergadura a la web, y así sabré cuáles son de verdad mis necesidades. Más adelante, cuando vuelva a SeamJS, tendré una TODO List; implemento las funciones una a una y ya se puede usar. Y después todavía puedo rellenar con una migración y un Benchmark? Listo, . La idea está aquí. Da igual si hoy se puede usar o no; al menos sobre el papel el prototipo ya funciona. Buenas noches 💤