Puede que este artículo no sea apto para todo el mundo. Para entender de qué trata, al menos deberías ser un aspirante a desarrollador frontend o desarrollador full stack, tener una comprensión básica de conceptos como SSR, SSG y ISR, y haber utilizado Next.js al menos una vez.
Prefacio
Puede que haya nacido con una gran curiosidad por los principios subyacentes y, a veces, me obsesione con preguntar por qué no se puede hacer de cierta forma; supongo que es así, y por eso a veces logro idear ideas únicas: esta es una de ellas. Surgió ya en noviembre de 2025, la comenté con muchos amigos en ese momento, pero debido a conflictos de agenda con proyectos en curso, se pospuso hasta alrededor del Año Nuevo 2026, cuando finalmente desarrollé una versión. Después la dejé en pausa nuevamente, porque me dediqué a montar un sitio web. El blog aún no está completamente listo, pero al menos tengo un lugar donde puedo registrar cosas, así que quiero retomar este asunto y volver a hablar de él.
En primer lugar, quiero dejar algo claro: no estoy diciendo que SSR sea malo. Solo siento que, según las tendencias que he observado, demasiada gente lo está abusando y se deja llevar por la corriente para evitar afrontar un problema. He discutido esto con bastantes personas, pero la mayoría de las respuestas eran bastante inciertas; también he compartido mi enfoque, pero a la otra parte siempre le preocupaba que surgieran todo tipo de problemas y casos límite. Además, siendo sincero, tampoco tengo ganas de explicar todo mi razonamiento de principio a fin cada vez. Así que hoy me he decidido a ponerlo detalladamente por escrito para intentar aclarar algunas dudas y los principios técnicos involucrados.
El abuso de SSR
Cuando me estaba iniciando en el desarrollo frontend Web, el primer framework full-stack con el que entré en contacto fue probablemente Next.js. Admito que es un framework excelente; aunque tiene algunos problemas de rendimiento, estos se compensan con una verdadera experiencia DX, lo cual es perfectamente comprensible. Pero a medida que fui comprendiendo mejor los conceptos y conocimientos del frontend, me di cuenta de que tal vez se estaba volviendo cada vez menos adecuado para mí: me encanta escribir en Rust, me gusta mucho investigar el bajo nivel y he trabajado con sistemas embebidos, por lo que me une una obsesión muy especial con el rendimiento; una obsesión que no busca el extremo en todo, sino que piensa: si existe una forma de lograr un rendimiento significativamente mejor con un esfuerzo quizás mínimo, ¿por qué no lo hace todo el mundo? Por eso, cada vez que uso Next me pregunto: ¿por qué, incluso en una página muy sencilla donde la mayor parte es fija y solo un punto necesita cambiar, toda la página se tiene que volver a renderizar por completo? ¿Por qué? A mi modo de ver, aunque el backend no esté escrito en Rust y aunque no se busque el extremo, incluso ejecutándose sobre un intérprete de JavaScript, ese pequeño dato que cambia debería suponer un coste relativamente pequeño, logrando el "renderizado" de una forma muy económica en lugar de volver a renderizar la página completa.
Hasta el punto de que, más adelante, se convirtió en una barrera mental para mí, un problema sobre el que no podía dejar de reflexionar. Pero lo irónico es que, tras repasar los casi 10 años de evolución del frontend—Vue, Svelte, Solid y sus frameworks Next.js, TanStack Start, Remix, Nuxt, Sveltekit y Soild Start—, me da la sensación de que todo el mundo se ha desviado del camino. En especial el RSC de React, que me parece un error, aunque su existencia tiene su razón de ser, algo sobre lo que quizás vuelva a hablar con más detalle en otra ocasión...
En mi opinión, RSC y toda una serie de frameworks SSR están abusando en realidad de render. SSG sí que logró el estilo que yo buscaba —las páginas se generan en el momento de la compilación y no requieren renderizarse de nuevo al recibir peticiones—, pero carece de verdaderas capacidades dinámicas(x). En cuanto a ISR, parece haber encontrado un equilibrio entre SSG y SSR, logrando cierta flexibilidad, pero en el fondo no ha cumplido mis expectativas; creo que el nacimiento de ISR fue simplemente para compensar el bajo rendimiento de SSR, e incluso las posteriores estrategias de caché en CDN y maniobras similares no son más que una especie de workaround, en lugar de una solución verdaderamente elegante.
Por supuesto, SSR en sí no es un error; es algo excelente que resuelve muchos escenarios del mundo real. Pero el problema radica en que estos frameworks han empujado a SSR a una posición de opción predeterminada extrema, lo que hace que, en probablemente el 95 % de los casos en los que los desarrolladores lo usan, en realidad no lo necesiten en absoluto. La mayor parte del contenido de la página es fijo y las partes verdaderamente dinámicas son mínimas, pero cada solicitud aún tiene que ejecutar todo el árbol de componentes.
Sin embargo, en los últimos años React ha mejorado un poco. React 19.2 cuenta con PPR, pero esto en realidad aún conlleva un costoso renderToString() en tiempo de ejecución, por lo que no deja de ser un workaround más que una solución real. Así que pensé: si la mayoría de las cosas ya están determinadas en tiempo de build, ¿por qué no llevar el renderizado directamente al tiempo de compilación?
¿Renderizado en tiempo de compilación?
Llevar el renderizado al tiempo de compilación suena como algo insensato, ya que la razón fundamental de la existencia del SSR es que ciertos valores y condiciones resultan imposibles de resolver hasta llegar al tiempo de ejecución; no sabes qué son ni puedes tomar decisiones al respecto. Por eso, es comprensible y no carece de sentido que la mayoría descarte mi idea a la primera.
Pero en realidad se me ocurrió un Pipeline bastante ingenioso para lograr esto, y esa es también la razón por la que necesito llamarlo una especie de Protocol. Por lo demás, siempre he pensado que borrar la frontera entre el frontend y el backend en los frameworks full-stack es algo muy estúpido (aunque hay que admitir que en los últimos años Next.js ha hecho un gran trabajo en cuanto a DX en este aspecto, lo que hace que muchos principiantes sientan que escribir una aplicación full-stack es algo muy sencillo, pero en realidad de ahí surgen muchos riesgos de seguridad; me estoy desviando del tema).
Así que escribir la obtención de datos dentro de los componentes es lo mismo: prefiero un enfoque con límites claros —los componentes son componentes puros, y la obtención de datos debería extraerse por completo. Una vez que aceptas esta premisa y separas los componentes puros de los datos, la cosa se vuelve interesante: te das cuenta de que los datos en sí realmente se pueden categorizar.
Aquí también quiero agradecer a TypeScript por la inspiración. En realidad, lo que el llamado "valor" en sí sea en tiempo de compilación no importa; lo que importa es su tipo. En cuanto al contenido que se debe rellenar en un slot, siempre que no sea del tipo Open String (una cadena de texto con infinitas posibilidades), se puede envolver en un tipo de posibilidades finitas. Por ejemplo, cuando estás escribiendo un dashboard y necesitas renderizar condicionalmente cierto bloque de contenido, no es más que User o Admin, etc., pero al final es un tipo que se puede definir por completo; en el mundo real, casi todos los lugares que requieren renderizado condicional o juicio lógico se pueden reducir a unas pocas posibilidades determinadas.
Y da la casualidad de que estas posibilidades pueden describirse con un enfoque excelente, que es JTD!
Especificación JTD
En JTD (JSON Type Definition) RFC 8927, se definen ocho Schema Form: Empty, Ref, Type (boolean, string, timestamp y diversos tipos numéricos de diferente precisión), Enum, Elements, Properties, Values y Discriminator, además de que cualquier schema se puede marcar como nullable. La ventaja de JTD radica en que es independiente del lenguaje: casi todos los lenguajes, como JavaScript, Rust y Go, cuentan con sus correspondientes mapeos de tipos, lo que lo convierte de forma natural en un puente entre el frontend y el backend. Además, su portador es JSON, que de por sí es el mínimo común denominador entre frontend y backend. En el mundo real, el 95 % de las aplicaciones de los sitios web no hacen más que mostrar cadenas de texto, campos numéricos o tomar decisiones con valores booleanos, todo lo cual entra dentro de este alcance. Una vez aclarado este punto, el margen de maniobra que tenemos es realmente amplio. Por supuesto, JTD tampoco es perfecto y existen excepciones como Markdown, pero ya hablaremos de eso en detalle más adelante; de hecho, es aquí donde realmente reconozco el valor de SSR.
Sentinel
Primero lo más fácil: como ya sabemos que cada valor dinámico tiene un tipo, podemos hacer una cosa en tiempo de compilación: ejecutar el componente React a través de renderToString(), pero no con datos reales, sino con datos simulados usando Sentinel. Entonces, ¿qué significa esto?
{ user: { name: "Alice", age: 30 } }Suponiendo que tus datos se ven así, podemos reemplazarlos por:
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }Estos %%SEAM:...%% son los Sentinel, los cuales ocupan la posición de cada valor dinámico. Después de que React ejecute renderToString() con estos datos Sentinel, estas posiciones quedan marcadas en el HTML resultante. A continuación, el pipeline de compilación convierte estos centinelas en marcas slot en forma de comentarios HTML.
<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>
<!-- Slot -->
<span><!--seam:user.name--></span>Llegados a este punto, quizás ya te hayas dado cuenta de que estos slot son "espacios con tipo". En este momento, lo que el runtime del servidor tiene que hacer se vuelve extremadamente simple: tomar los datos reales y rellenar esos huecos, una pura sustitución de cadenas. No se necesita renderToString() en absoluto, no se necesita un runtime de JavaScript, no se necesita vDOM. Cualquier lenguaje capaz de analizar comentarios HTML y hacer sustitución de cadenas puede servir de backend -- Rust, Go, TypeScript, todos funcionan. Por eso es un protocolo y no un framework.
Llegados a este punto, tal vez se pregunte: ¿qué pasa con el renderizado condicional? Por ejemplo, cuando un determinado campo es null, todo un bloque de contenido no debería aparecer. Sin embargo, esto tampoco requiere JavaScript en tiempo de ejecución para evaluarlo; al contrario, el propio protocolo puede definir esta situación.
<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->El renderizado condicional es así, y lo mismo se aplica al renderizado de listas.
<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->Incluso coincidencia de patrones
<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->Ese es el arte de los comentarios: aquí se eligieron comentarios simplemente porque resultan ser caracteres HTML válidos, nada más. Entonces, ¿cómo se determinan y generan estos bloques condicionales y de bucle en build-time? En realidad es un método bastante ingenioso: aunque no tenemos vDOM, podemos renderizar el HTML dos veces para hacer un Diff. Tomando la renderización condicional como ejemplo, la primera vez se renderiza con los datos Sentinel completos, y la segunda vez se establece un determinado campo nullable como null y se vuelve a renderizar. Al comparar ambas salidas, el bloque de HTML que desaparece es el bloque condicional controlado por ese campo, por lo que basta con envolverlo con <!--seam:if:...-->. Lo mismo se aplica a los arrays.
¿Quizás CTR?
Seguro que alguien volverá a preocuparse: ¿las combinaciones de renderizado condicional no sufrirán una explosión exponencial? Por ejemplo, si entre 3 y 5 variables controlan el renderizado condicional y cada variable tiene 10 posibilidades, al multiplicarlas se obtiene efectivamente un número que parece enorme. Pero en realidad, ese número solo impresiona a primera vista. En primer lugar, para muchos valores de tipo cadena no necesitamos enumerar realmente su contenido; solo nos importa si tiene valor o no nullable, lo que aquí son en realidad solo 2 casos. En segundo lugar, los campos que realmente requieren una comprobación condicional tienen necesariamente un tipo enumerable; por ejemplo (¿no vas a usar un open string para hacer una comprobación de if, verdad?). Además, aunque realmente hubiera que enumerar todas las combinaciones, ejecutarlas en una CPU moderna tal vez solo llevaría unos pocos ms. Y esta carga es totalmente en tiempo de compilación: igual que la compilación de Rust, se paga un coste alto en tiempo de compilación, pero en tiempo de ejecución se convierte en una pura sustitución de cadenas. Se mire como se mire, el negocio sale a cuenta, más aún teniendo en cuenta que no hay ninguna magia especial aquí. Sin embargo, cabe dejar caer un pequeño detalle: más adelante introduje en realidad un pequeñísimo entorno de ejecución JavaScript embebido para realizar algunas deducciones complejas, pero esto es una especie de compromiso; más adelante explicaré las razones y por qué, en teoría, se podría prescindir de él.
Esto es lo que llamo CTR (Compile-Time Rendering). En cuanto a las limitaciones, son exactamente las mismas que las del SSR. Por ejemplo, cuando <!--seam:path--> realiza una inserción de texto, realiza automáticamente el escape de HTML (&, <, >, etc.); si necesitas insertar HTML sin procesar, debes usar explícitamente <!--seam:path:html-->; las rutas de datos que faltan se convierten en cadenas vacías en el texto slot y omiten la inyección en el atributo slot; y si un bloque each recibe algo que no es un arreglo, simplemente se omite. Estos comportamientos no son esencialmente diferentes de los casos límite que encuentras en los frameworks SSR: simplemente he trasladado el paso de renderizado del SSR para ejecutarlo en tiempo de compilación, nada más. Por su parte, CTR realiza en el build-time un recorrido de producto cartesiano sobre los valores de tipo de todas las variables condicionales, utilizando un valor Mock arbitrario que coincida con el tipo o un valor Mock especial sobrescrito (Override) por el usuario (por lo general, no es necesario escribir manualmente los valores Mock requeridos para generar las variantes de HTML, pero se pueden proporcionar manualmente; de lo contrario, se ingresa automáticamente un valor que coincida con tu tipo). De esta manera, cada combinación se renderiza una vez para calcular las diferencias (diff) de los límites de todos los bloques condicionales y bloques de bucle, obteniendo finalmente un esqueleto HTML completamente desplegado. Desde el punto de vista matemático, siempre que los datos pasados en el runtime cumplan con estas definiciones de tipo, se garantiza que se inyectarán correctamente en este esqueleto, ya que todas las posibles rutas de ramificación ya se han exhaustado en tiempo de compilación.
¿Consistencia?
Entonces, ¿cómo se puede garantizar la coherencia? En realidad, la respuesta es este Contract de JTD. Aunque el frontend y el backend estén separados, mientras ambos cumplan con el mismo JTD schema, los tipos de datos estarán alineados y no habrá problemas de incoherencia.
Pero me enfrentaba a un desafío adicional respecto a otros frameworks: dado que el backend se ha liberado del entorno de ejecución de JavaScript, ya no tiene por qué formar una sola pieza con el frontend; se puede escribir el backend perfectamente en Rust o Go. En un framework full-stack de TypeScript, la coherencia entre frontend y backend se puede garantizar directamente mediante los tipos; ¿pero qué ocurre con otros lenguajes? En realidad, aquí utilicé codegen: independientemente del lenguaje en el que esté escrito el backend, se toma el backend como referencia y las variables y tipos que puede utilizar el frontend se generan directamente mediante codegen en TypeScript para que el frontend los importe. De este modo, aunque la frontera entre frontend y backend quede claramente delimitada, en realidad pueden convivir dentro de una misma carpeta al igual que en un framework full-stack de TypeScript, adaptándose a los hábitos de un Monorepo, y pueden llamarse directamente entre sí sin necesidad de escribir a mano APIs o cosas como gen OpenAPI. En la práctica, utilicé una ruta privada /_seam/ como extremo predeterminado del framework (configurable, al igual que en Nuxt), por lo que al ejecutar JTD Typed-RPC se resuelven los problemas de transmisión de datos y CORS.
CTR x SSR
Luego está raw HTML slot, lo cual nos lleva de nuevo a ese 5% de Edge Case. Para cosas como Markdown o Rich Text, es imposible saber su valor en tiempo de compilación y el coste de restringirlos mediante tipos es extremadamente alto. Por supuesto, podrías decir que extendamos el protocolo, enumeremos toda la sintaxis de Markdown y la analicemos en tiempo de ejecución, pero ¿ese trabajo no equivaldría básicamente a reescribir un renderizador de Markdown? En cambio, restringir solo esos pocos tipos mínimos no tiene nada que ver con el esfuerzo de reescribir todo el motor de renderizado, lo que nos devuelve a la filosofía que mencioné al principio: resolver problemas con un menor coste y de una forma más elegante.
Así que la buena noticia es que raw HTML slot realmente permite que CTR y SSR coexistan. Esto significa que la mayor parte de la interfaz de usuario de tu página se construye con CTR, con un coste de ejecución casi nulo; y para el artículo en Markdown principal, utilizas SSR para renderizarlo. Dado que el backend se ha liberado, puedes optar por usar TypeScript para importar tu método de renderizado original de SSR, ¡o abrir tu mente! Si el backend usa Rust, por supuesto que puedes usar el compilador de Markdown de Rust para renderizarlo; al fin y al cabo, siempre que al final proporciones una cadena HTML y la insertes en este raw slot, se podrá mostrar. Usar CTR no significa que no puedas usar SSR; ambos pueden coexistir por completo.
El ideal y la realidad del PPR
Entendido así, CTR es esencialmente el caso ideal de PPR (Partial Prerendering): todo lo que se puede cachear se renderiza por completo en tiempo de compilación, con cero sobrecarga en tiempo de ejecución — solo el pequeño fragmento de datos que realmente cambia tiene algún coste. Entonces, ¿en qué se diferencia del PPR de React 19.2? La respuesta es que soy más radical, más agresivo PPR Incluso en el caso más ideal, donde todas las partes estáticas ya están cacheadas y solo hace falta actualizar un pequeño fragmento de contenido dinámico, aunque ese contenido dinámico sea solo un simple cambio de cadena de texto, React todavía tiene que volver a ejecutar renderToReadableStream(), y ese coste no es pequeño.
Por su parte, CTR traza este límite con mucha claridad: si se trata de tipos simples como cadenas, números y booleanos, todo se hace mediante reemplazo directo de cadenas; solo los tipos complejos con un coste de enumeración extremadamente alto, como Markdown, pasan por el proceso de render real. Además, el render aquí ya no es el concepto de SSR en el sentido tradicional; puede ser el método de renderizado de cualquier lenguaje de programación.
RSC tampoco puede faltar
Hablemos ahora de RSC (React Server Component). Mencioné anteriormente que RSC podría ser un error: eso se debe a que desdibujó demasiado los límites entre el front-end y el back-end, trajo bastantes riesgos de seguridad y numerosos CVE, pero en realidad esta falla se debe más a Next.js y a otras corrientes de SSR: los usuarios ni siquiera tienen la oportunidad de no usarlo, convirtiéndose en que si quieres contenido dinámico debes usar SSR. Sin embargo, en la práctica, un reemplazo de cadenas como el nuestro también funciona perfectamente. Además, hay que admitir que RSC le otorga al lado del servidor una capacidad muy importante: renderizar cualquier componente de React, es decir, ejecutar código arbitrario.
Sin embargo, dado que vas a ejecutar código arbitrario, intentar enumerarlo exhaustivamente mediante tipos es prácticamente imposible; e incluso si fuera posible, la carga de trabajo no tendría por qué ser menor que la de escribir un compilador de React. Por lo tanto, esta capacidad de RSC tiene su razón de ser. ¿Pueden coexistir CTR y RSC? La respuesta es sí, y no se contraponen en absoluto. Pero esto es algo que debe resolverse a nivel de framework y no entra en el alcance del propio protocolo. Por el momento no lo he implementado, pero parece que se puede tomar como referencia TanStack Start: siempre que se envíe un paquete html y un paquete js adicionales, la implementación teórica es muy sencilla; al menos se trata de un volumen de trabajo claramente delimitado.
Volviendo a raw HTML slot, esta solución está dirigida principalmente a escenarios como Markdown y Rich Text. El HTML renderizado se inyecta a través de dangerouslySetInnerHTML y, tras la hidratación, esta área no participa en la interacción, está "muerta"; esta parte del contenido debe minimizarse. Si deseas añadir bordes o estilos al artículo, estos deberían escribirse utilizando componentes de React en lugar de mezclarse en este HTML. La desventaja de este enfoque es que no puede cambiar después de la hidratación, pero bajo esta premisa logra reducir el coste de "SSR" a un nivel extremadamente bajo, casi Zero Cost. En los escenarios en los que realmente se necesita SSR, aproximadamente el 60 % corresponde a este tipo de inyección de HTML estático; solo el 40 % restante requiere la capacidad de RSC para ejecutar componentes arbitrarios en el servidor.
Agnosticismo de la UI frontend
Por último, está la muy atractiva agnosticidad respecto al protocolo: en esencia, solo necesitamos centrarnos en el punto clave de renderToString; qué marco de UI utilicemos por delante no tiene en realidad nada que ver con el protocolo. Siendo así, ¿qué diferencia hay con Astro? Pero no se preocupe, definitivamente no estoy creando otro Astro. A primera vista, puede parecerse un poco al concepto de islas de Astro, pero en realidad hay una gran diferencia.
No estoy hidratando múltiples runtimes dentro de una sola página; humm, de hecho, creo que los escenarios donde realmente necesitas esto son muy limitados. La comunicación del estado de los componentes entre diferentes stacks tecnológicos se vuelve extremadamente costosa; se parece más a una solución de transición para migrar de un stack tecnológico a otro cuando no se puede cambiar todo de una vez, por lo que primero se hace una transición. En segundo lugar, Astro es esencialmente una MPA, mientras que nosotros podemos lograr ser una MPA antes de la hidratación y convertirnos en una SPA después de la hidratación. Al igual que Next.js, contamos con enrutamiento del lado del cliente y podemos hacer animaciones entre páginas, algo que Astro desearía con todas sus fuerzas.
Astro y SSG
Además, el diseño de Astro realmente solo resulta útil en escenarios que abarcan múltiples stacks tecnológicos. Si lo usas únicamente por la velocidad y solo empleas uno de sus frameworks —por ejemplo, incluyendo únicamente React sin Vue—, creo que esa pretendida "alta velocidad" es una falsa premisa. Es cierto que la carga inicial se realiza completamente en HTML, pero en cuanto deseas cualquier tipo de interacción, requiere hidratación, y el coste de esa hidratación es descargar todo el React Runtime, lo cual no difiere en nada esencial de nuestra hidratación. Por supuesto, en el futuro también podríamos implementar el concepto de Island y añadir un shell router para lograr una navegación SPA entre marcos de UI, pero eso queda para más adelante en la Roadmap; al menos por ahora no me resulta urgente.
Por último, está la comparación con el SSG tradicional. He logrado lo mismo que el SSG: esencialmente renderizar todo lo que se puede determinar en tiempo de compilación; pero somos más dinámicos, porque esos valores de slot de tipos simples se pueden reemplazar por completo en tiempo de ejecución. Puedes entenderlo como si renderizáramos el SSG en una especie de punto de entrada para una MPA: antes de la hidratación es una MPA, después de la hidratación se convierte en una SPA, manteniendo al mismo tiempo la capacidad "dinámica".
¿Quizás decir adiós a los errores de hidratación?
Por último está el hydration mismatch, que detesto y estoy seguro de que a ti tampoco te gusta; pero, al fin y al cabo, solo se trata de una inconsistencia en el estado DOM. Los frameworks tradicionales como el App Router de Next.js intentan envolver toda la aplicación con React, por lo que una vez que el navegador del lado del usuario inyecta alguna etiqueta, existe la probabilidad de que ocurra un error de hidratación. Nuestras limitaciones son en realidad las mismas que las del SSR tradicional, pero cuando usas TS, también envolví un div de hidratación llamado __root para que el área de hidratación ya no cubra el área de metadata, lo que da como resultado una hidratación más duradera. Además, React 19 incluye soporte nativo para etiquetas de metadatos de documentos como <title>, <meta> y <link>. Aunque solo hidrates un <div> específico de la página (en lugar de todo el <html>), al renderizar <title>My Page</title> directamente en un componente, React lo elevará (hoist) automáticamente a <head>.
Volviendo al hydration mismatch, dado que ya prevemos el tipo de cada slot en tiempo de compilación, podemos añadir una comprobación de equivalencia CTR adicional: rellenar el HTML completamente desplegado con datos ficticios (mock) derivados de las definiciones de tipos, ejecutarlo una vez con el renderToReadableStream() tradicional y luego comparar si la semántica del árbol DOM de ambos es equivalente, sin importar las diferencias de formato (fmt). Puede haber pequeñas diferencias de formato, pero mientras la estructura del DOM sea estricta y totalmente equivalente, en realidad es posible despedirse del hydration mismatch; ¿por qué los SSR tradicionales no pueden hacer esto? Porque lo hacen en tiempo de ejecución, mientras que la estructura CTR exige aplicar todas estas restricciones en tiempo de compilación. Por supuesto, también tenemos una vía de escape en any, pero al igual que en TypeScript, si usas any debes asumir las consecuencias; la CLI te advertirá (warn) durante la compilación de que el open string de escape any podría provocar un mismatch.
Explorando Serverless
Por supuesto, tampoco puede faltar la posibilidad de Serverless. En los últimos años, la experiencia con Serverless ha sido sumamente buena; yo la valoro como "sin más desventajas que ser costoso". Sin embargo, CTR es de por sí muy adecuado para este escenario: lo que realiza nuestro runtime es muy ligero y pequeño, por lo que ejecutarse en Serverless será extremadamente rápido, mejorando notablemente tanto el tiempo de respuesta como la sobrecarga (overhead). La parte restante que no se puede mejorar son escenarios como el renderizado de Markdown, pero eso debería optimizarse por completo en la lógica de negocio; por ejemplo, pre-renderizando el Markdown y almacenándolo para no tener que volver a renderizarlo en cada petición, tal como lo hace mi sitio web actual. Este es un problema a nivel de negocio y no algo que el framework pueda resolver, pero el framework puede ayudarte a reducir el gasto de esa otra lógica simple a casi cero. En comparación con el SSR tradicional, ya no es un gasto del mismo orden de magnitud; ¿qué tan pequeño es? Es aproximadamente una cuestión de unos pocos cientos de us ~ 1ms, algo que el SSR tradicional ni siquiera se atrevería a imaginar.
¿Y qué ocurre si el backend está en otro lenguaje? Tomando como ejemplo Cloudflare Workers, lo cierto es que muchos Serverless admiten WASM, por lo que otros lenguajes, tras compilarse en WASM BIN, pueden utilizarse como backend de la misma manera. En esencia, me limito a compilar el frontend en recursos estáticos puros, al igual que en CSR, pero este conjunto de recursos estáticos, combinado con un puente privado /_seam/, puede lograr las mismas capacidades dinámicas que los verdaderos frameworks full-stack, siendo de forma natural totalmente compatible con Serverless.
SeamJS
¿Así que ya te has confundido con la relación entre Seam y SeamJS? En realidad, los dos son muy sencillos: Seam es un protocolo. Define cómo marcar posiciones dinámicas usando Sentinel, cómo convertirlas en marcas slot, cómo realizar la detección diff para bloques condicionales y de bucle, y cómo hacer la inyección de datos en tiempo de ejecución basada en el AST. El protocolo en sí es independiente del lenguaje; cualquier backend capaz de analizar comentarios HTML y realizar sustituciones de cadenas puede implementarlo. SeamJS es un framework, una implementación concreta basada en este protocolo. Une piezas existentes como Vite, TanStack Router y TanStack Query, y luego cubre lo que no llegan a abarcar, como mi extracción de skeleton, motor de inyección, CLI y cosas por el estilo.
Pero para ser sincero, SeamJS todavía se encuentra en un estado muy básico. Funciona sin problemas, pero para usarlo realmente en proyectos aún necesita bastante pulido. También he estado pensando más en algunas reformas arquitectónicas, como la abstracción del canal de transmisión de datos, aunque no estoy seguro de si mantendré esta dirección en versiones futuras. A nivel de framework, definitivamente crearé un framework full-stack de TypeScript y un framework insignia con Rust como backend; en cuanto a la implementación relacionada con Go, se eliminará en versiones posteriores, ya que siento que no tengo la energía suficiente para mantenerla.
Más que un simple framework web
Entonces, ¿para qué sirve hacer esto? En realidad, no se limita solo a la Web. Por ejemplo, una vez que también abstraiga el canal Transport, se podrá portar posteriormente a entornos Desktop como Electron o Tauri. Mientras se reemplace la canalización de transporte HTTP por la comunicación IPC, seguirá ejecutando el mismo protocolo Seam. Como resultado, esas aplicaciones de Electron ya no tendrán que mostrar una pantalla de loading al abrirse, y muchas cosas se podrán renderizar directamente de forma local al igual que en SSR. ¡Esa es la magia de CTR!
El coste de No JS Runtime
¿Por qué, entonces, SeamJS terminó incorporando un JS Runtime al final? Porque al implementar esta solución me encontré con un problema: el ideal de CTR impone restricciones extremadamente estrictas a los datos de la primera pantalla. Los datos deben ser una estructura determinista que se pueda derivar por completo, sin incluir ninguna lógica de cálculo, únicamente condiciones. Esto resultó ser extremadamente riguroso. Como se puede imaginar en cuanto a la experiencia de desarrollo de un framework, quienes escriben React de forma tradicional esperan realizar algunos cálculos dentro del componente y usar directamente los valores obtenidos. Si se siguieran de forma estricta las restricciones de CTR para alcanzar un estado ready-to-display, el proceso sería sumamente laborioso, e incluso llevaría a escribir el mismo componente dos veces.
Pero en realidad hay una solución: por cuestiones de la Web, el frontend solo puede ejecutarse en JavaScript, por lo que, naturalmente, los componentes solo pueden ejecutar JS. Por lo tanto, el backend debe contar con capacidad de ejecución de JS para resolver este problema. El propio runtime de un proyecto full-stack de TypeScript sirve para resolverlo; mientras que en Rust solo hace falta incrustar un JS Runtime muy pequeño como QuickJS. Tenga en cuenta que el JavaScript Runtime aquí es un subconjunto estándar de JS, nada que ver con un runtime que incluye API completas del sistema operativo como Bun o Node; realmente solo sirve para realizar deducciones, para hacer derive.
De esta manera, aún puedes escribir cierta lógica de cálculo dentro de los componentes. Una vez que el backend obtiene los datos, esta lógica ejecuta un pequeño fragmento de JS para derivarlo al estado ready-to-display, y luego lo envía de vuelta al frontend para la hidratación de la primera pantalla. De este modo, CTR queda satisfecho al obtener datos derivados de estructura estricta, la DX del desarrollador mejora y los componentes solo se escriben una vez. Por supuesto, si estás dispuesto a cumplir estrictamente con restricciones de tipos puras, el backend efectivamente puede lograr No JS Runtime, esta promesa en realidad sigue siendo válida. De cualquier forma, este pequeñísimo JavaScript Runtime añadido tiene un sobrecoste de rendimiento, un uso de memoria y un tamaño mucho menores que cosas como Node (básicamente unos 200-300 KB), pero aporta una flexibilidad enorme.
Perspectivas de futuro
Después de hablar tanto, ¿cuándo se podrá usar realmente? Probablemente dentro de mucho, mucho tiempo. Realmente no es que quiera dar esquinazo, sino que siento que en esta etapa este proyecto me exige demasiado. Si solo puedo usarlo para desarrollar el propio framework, básicamente no puedo escribir ninguna aplicación: intente lo que intente, el framework me pone zancadillas a cada paso, por no hablar de que últimamente me he dedicado sobre todo a programar este sitio web. Así que lo he pensado bien: más vale construir primero este sitio web a cierta escala, y así sabré cuáles son mis necesidades reales. Luego, cuando vuelva a programar SeamJS, tendré una TODO List; al ir implementando cada función una a una, ya estará listo para usarse. ¿Y más adelante hasta podré sacar otra publicación sobre migración y Benchmark? En fin, las promesas ya están hechas y el concepto está aquí. Dejando a un lado si se puede usar hoy o no, al menos el prototipo conceptual ya funciona. Buenas noches 💤