volver a la página principal

Un año con el enrutador de aplicación (App Router) de Next.js — por qué decidimos cambiar

Una crítica de los componentes de servidor de React y Next.js 15.

EnglishEspañol한국어+
webdev
technical analysis
opinion

He estado usando Next.js profesionalmente para el desarrollo de aplicaciones web en mi trabajo y el diseño de su enrutador de aplicación (App Router) y los componentes de servidor (React Server Components/RSC) me parece extremadamente frustrante a nivel fundamental. No es por pequeños bugs o porque la API me parezca confusa, sino por grandes discrepancias en las decisiones de diseño fundamentales que tomaron Vercel y el equipo de React en su creación.

Cuanto más asisto a eventos de desarrollo web, más veo a gente a quien no le gusta Next.js, pero que aún así debe usarlo. Al final del artículo, compartiré cómo escapamos de este infierno, migrando todo nuestro frontend a TanStack Start sin problemas.

Contents:

§Un repaso técnico: ¿qué son los componentes de servidor?

El punto clave de RSC es que los componentes se dividen en dos categorías, componentes de "servidor" y componentes de "cliente". Los componentes de servidor pueden usar ni useState ni useEffect, pero pueden hacer uso de funciones async y emplear herramientas de backend, permitiendo por ejemplo llamar directamente a una base de datos. Los componentes de cliente constituyen el modelo tradicional, en el que hay código en el backend para generar HTML y en el frontend para administrar el DOM usando window.document.*.

Ahí va el primer desastre: ¡la nomenclatura! React usa las palabras "servidor" y "cliente" para referirse a cosas muy específicas, ignorando sus definiciones existentes. No habría ningún problema, si no fuera por que ¡los componentes de cliente también pueden correr en el backend! En este artículo, utilizaré los términos "backend" y "frontend" para describir los ámbitos de ejecución en los que existen las aplicaciones web: un proceso de Node.js y un navegador web, respectivamente.

Este modelo de componentes de servidor y de cliente es curioso. Dado que las funciones integradas, como <Suspense />, se serializan a través de la red, la obtención de datos se puede modelar de manera trivial con componentes de servidor asíncronos, y la interfaz de usuario alternativa funciona como si fuera del lado del cliente.

src/app/[username]/page.tsx
// En este artículo, los componentes de servidor están resaltados en rojo.
export default async function Page({ params }) {
    // Los parámetros de la página se pasan como una Promise resuelta.
    const { username } = await params;

    // Los componentes `UserInfo` y `UserPostList` serán corridos a la vez. En
    // cuanto `UserInfo` esté listo, el visitante verá la página con un
    // `PostListSkeleton` si la lista de publicaciones no está lista.
    return <main>
        <UserInfo username={username} />

        <Suspense fallback={<PostListSkeleton />}>
            <UserPostList username={username} />
        </Suspense>
    </main>
}

// Evitamos waterfalls teniendo varios componentes evaluados a la vez.

async function UserInfo({ username }) {
    const user = await fetchUserInfo(username);
    return <>
        <h1>{user.displayName}</h1>
        {user.bio ? <Markdown content={user.bio} /> : ""}
    </>
}

async function UserPostList({ username }) {
    const posts = await fetchUserPostList(username);
    return /* interfaz de usuario de la lista de publicaciones omitida por brevedad */;
}

El ejemplo anterior no usa nada de JavaScript (sin contar el tamaño del empaquetado gzipped de 40kB del propio React) para la interfaz de usuario ni para la obtención de datos — ¡sólo envía el HTML! Por ejemplo, el parser (analizador) imaginario de Markdown dentro del componente <Markdown /> se queda en el backend. En aquellos casos en los que se necesita un frontend interactivo, simplemente se puede crear un componente de cliente poniéndolo en un archivo empezando con "use client".

src/components/CopyButton.tsx
"use client"; // Este comentario marca el archivo para el empaquetado del lado del cliente.

export function CopyButton({ url }) {
    return <>
        <span>{url}</span>
        <button onClick={() => {
            const full = new URL(url, location.href);
            navigator.clipboard.writeText(full.href);
            // omitiendo todo el manejo de errores, la interfaz de éxito, los estilos...
        }}>copy</button>
    </>
}
src/app/q+a/Card.tsx
export function Card() {
    return <article>
        <header>
            {/* Hace que el navegador importe el botón de copiar */}
            <CopyButton url="/q+a/2506010139" />
        </header>
        <p>
            {/* Procesa el Markdown en el backend */}
            <Markdown content=".........." />
        </p>
    </article>
}

§Los inconvenientes del enrutador de aplicación

Tras dejar Bun como ingeniero de runtime (donde implementé el empaquetado de componentes de servidor y una plantilla de RSC), me uní a una pequeña compañía, trabajando en la línea de fuego: una aplicación de Next.js con un backend de Hono. Lo siguiente es una versión simplificada de los problemas con los que me enfrenté tratando de mantener y desarrollar características nuevas. Como resultado de todos ellos, perdimos todos el tiempo intentando eludir fallos de diseño o explicándonos por qué algo que no debería ser un problema para empezar se ha convertido en un obstáculo inamovible.

§Las actualizaciones optimistas son imposibles

La documentación de Next.js respecto a la mutación no menciona las actualizaciones optimistas, parece ser que no pensaron en este caso. Los componentes renderizados por el servidor de React no pueden ser modificados tras ser montados por diseño. Los elementos que puedan tener cambios deben estar dentro de un componente de cliente, pero no puede estos componentes no pueden dar lugar a obtención de datos, incluso durante el renderizado del lado del servidor (SSR) en el backend. Esto deriva en componentes de servidor incómodamente diminutos que sólo se encargan de obtener ciertos datos y tienen un componente homólogo de cliente que contiene una versión prácticamente estática de la página.

src/app/user/[username]/page.tsx
export default async function Page() {
    const user = await fetchUserInfo(username);
    return <ProfileLayout>
        <UserProfile user={user} />
    </ProfileLayout>;
}
src/app/user/[username]/UserProfile.tsx
"use client"; // Toca mover el código de cliente a otro archivo!

export function UserProfile({ user: initialUser }) {
    // Existen muchas librerías de gestión de estado excelentes;
    // para simplificar, usaremos una celda de estado.
    const [user, optimisticUpdateUser] = useState(initialUser);

    async function onEdit(newUser) {
        optimisticUpdateUser(newUser);
        const resp = await fetch("...", {
            method: 'POST',
            body: JSON.stringify(newUser),
            ... // (encabezados, credenciales, trazado y más)
        })
        if (!resp.ok) /* ¡recuerda siempre comprobar si se ha dado algún error! */
    }

    return <main>{/* interfaz de usuario con campos editables... */}</main>:
}

A medida que más partes de la página necesitan interactividad, se vuelve más lioso mantener las partes estáticas puramente del lado del servidor. En la aplicación de trabajo, casi cada componente de la interfaz de usuario muestra datos dinámicos. Un WebSocket sincroniza los datos en tiempo real a medida que se actualizan (por ejemplo, el estado en línea y el perfil básico de una tarjeta de usuario). Como estas configuraciones de componentes son más difíciles de entender y mantener para los ingenieros, casi todas nuestras páginas están marcadas como "use client" con un page.tsx que define la obtención de datos necesaria.

Un ejemplo más concreto de cómo queda esto en la práctica con la librería de obtención de datos que usamos en el trabajo, TanStack Query.

src/queries/users.ts
// Tenemos una función auxiliar `defineQuery` para la seguridad de tipos en el trabajo.
// Los obtenedores de datos son triviales y pueden correr tanto en el backend como en el frontend.
export const queryUserInfo = (username) => ({
    queryKey: ['user', username],
    queryFn: async ({ ... }) => /* obtener datos */
});
src/app/user/[username]/page.tsx
export default async function Page({ params }) {
    const { username } = await params; 

    // No hay estado global en el servidor de React. Como los layouts
    // se ejecutan en paralelo, toca reconstruir el `QueryClient` de
    // TanStack varias veces por ruta.
    const queryClient = new QueryClient();
    await queryClient.ensureQueryData(queryUserInfo(username));

    // HydrationBoundary es un componente de cliente que pasa datos
    // JSON del servidor de React al componente de cliente.
    return <HydrationBoundary state={dehydrate(queryClient)}>
        <ClientPage />
    </HydrationBoundary>;
}
src/app/user/[username]/ClientPage.tsx
"use client";
export function ClientPage() {
    const { username } = useParams();
    const { data: user } = useSuspenseQuery(queryUserInfo(username));

    // ... algunos hooks

    return <main>
        {/* ... una página web interactiva */}
    </main>;
}

Este ejemplo necesita tres archivos por las reglas del empaquetado de los componentes de servidor (el componente de cliente necesita "use client" y los archivos de componentes de servidor no suelen poderse importar en el cliente debido a las importaciones exclusivas de servidor). En el enrutador de páginas (Pages Router), podría haberlo hecho todo en un solo archivo por el tree-shaking que poseen getStaticProps y getServerSideProps.

§Cada navegación supone una petición

Como el enrutador de aplicación inicia cada página como componente de servidor, con áreas pequeñas de interactividad (idealmente), navegar a una nueva página ¡fuerza una petición al servidor de Next.js, independientemente de los datos que el cliente ya tenga disponibles! Incluso con un archivo loading.tsx, al abrir /, navegar a /other/ y luego volver a /`, se mostrará el estado de carga mientras vuelve a obtener los datos de la página de inicio.

Para lo único para lo que esto funciona bien es para el contenido perfectamente estático, donde las navegaciones instantáneas y la precarga (prefetching) funcionan genial. Pero las aplicaciones web no son estáticas, tienen mucho contenido dinámico. Haber iniciado sesión afecta a la página de inicio, cosa que es irritante porque el cliente literalmente ya tiene todo lo que necesita para mostrar la página instantáneamente. Ni siquiera han cambiado las cookies.

nota: Probándolo más en un proyecto en blanco, he observado casos en los que el código de frontend de Next precarga rutas sin ningún contenido real. En el ejemplo de hello world, era una carga de 1.8kB de RSC que apuntaba a 2 fragmentos de JS diferentes 4 veces distintas. Esto es un desperdicio puro de nuestro ancho de banda y egreso, especialmente considerando que toda esta información se vuelve a obtener cuando de verdad hago clic en el enlace.

1:"$Sreact.fragment"
2:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
3:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
4:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"ViewportBoundary"]
6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"MetadataBoundary"]
7:"$Sreact.suspense"
0:{"b":"TdwnOXsfOJapNex_HjHGt","f":[["children","other",["other",{"children":["__PAGE__",{}]}],["other",["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":null},[["$","div","l",{"children":"loading..."}],[],[]],false],["$","$1","h",{"children":[null,["$","$1","KCFxAJdIDH3BlYXAHsbcVv",{"children":[["$","$L4",null,{"children":"$L5"}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],["$","$L6","KCFxAJdIDH3BlYXAHsbcVm",{"children":["$","div",null,{"hidden":true,"children":["$","$7",null,{"fallback":null,"children":"$L8"}]}]}]]}],false]],"S":false}
5:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
9:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"IconMark"]
8:[["$","title","0",{"children":"Create Next App"}],["$","meta","1",{"name":"description","content":"Generated by create next app"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0b3bf435.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L9","3",{}]]

Revisándolo, me di cuenta de que en realidad hay algo de contenido: el estado de carga. ¿Lo ves?

["$","div","l",{"children":"loading..."}]

Sigue siendo un desperdicio grande, ya que todos estos datos se vuelven a emitir en el RSC de la propia página.

Parece ser que la solución a esto es staleTime, pero está marcado como experimental y "no recomendado para producción". Es una vergüenza el hecho de que esto sea una opción que no está por defecto y que parece haber sido una idea de último momento. Incluso usándola, no es posible hacer que varias páginas que hacen referencia a los mismos datos los compartan.

Un ejemplo de un estado de carga irrepresentable con el enrutador de aplicación es el de ciertas páginas, como una página de propuestas (issues) en un proyecto de git, al hacer clic en un nombre de usuario para ir a su página de perfil. Con loading.tsx, la página entera es un esqueleto, pero al modelar estas consultas con TanStack Query es posible mostrar el nombre de usuario y avatar instantáneamente mientras cargan la biografía del usuario y sus repositorios. Los componentes de servidor son incompatibles con esta tipo de navegación porque los datos sólo están disponibles en componentes renderizados, con lo cual deben ser obtenidos una vez más.

En nuestro sitio Next.js, tenemos esta línea de código en nuestros obtenedores de datos en los componentes de servidor para hacer que las navegaciones suaves vayan más rápido, saltándose la fase de obtención de datos por completo.

src/util/tanstack-query-helpers.server.ts
export function serverSidePrefetchQueries(queries) {
    if ((await headers()).get("next-url")) {
        // Se trata de una navegación suave. Nos SALTAMOS la precarga para
	// aumentar la velocidad. Puede que el cliente ya tenga estos datos y,
	// de lo contrario, tienen el estado de carga. Idealmente, no existiría
	// esta petición -- el lado de cliente ya tiene casi TODO el código ya
	// que la aplicación está escrita principalmente usando componentes de
	// cliente. Un fallo de diseño de parte del enrutador de aplicación, la
	// verdad.
        return;
    }
    // ... lógica de precarga de datos ...
}

Aparte, loading.tsx debería contener las llamadas a useQuery para que, mientras se realiza la petición para el RSC vacío, se obtengan los datos si de verdad se necesitan. De hecho, el estado loading.tsx puede ser directamente el componente de cliente y se mostrará la página de cliente.

src/app/user/[username]/loading.tsx
"use client";
export default function PageLoadingSkeleton() {
    return <ClientPage />;
}

En el trabajo, simplemente hacemos que nuestros archivos loading.tsx contengan las llamadas a useQuery y muestren un esqueleto. Esto se debe a que cuando Next.js carga el componente de servidor en sí, remonta la página entera sí o sí. No se da ningún tipo de VDOM diffing, así que todos los hooks (useState) se reiniciarán un poco después de que la petición se complete. Intenté reproducir un caso simple en el que estaba suplicándole a Next.js que tan solo actualizara el DOM existente y preservara el estado, pero directamente no lo hace. Por suerte, el tiempo que tarda la llamada RSC en blanco es suficientemente corto.

§Los layouts tienen restricciones artificiales

Los layouts pueden obtener datos, pero no pueden observar o alterar la petición de ninguna manera. Está hecho así para que Next.js pueda obtener y cachear los layouts cada vez que quiera. En cualquier otro framework, los layouts son componentes normales sin diferencia de otros componentes en la página.

Obtener los layouts por sí mismos es una idea chula, pero acaba siendo estúpida, pues toda obtención de datos debe rehacerse para cada layout. No puedes compartir un QueryClient, en su lugar debes usar su fetch monkey-patched para cachear la misma petición GET tal y como prometen.

Cuando me pregunta un compañero de trabajo por qué Next.js rechaza código, ya ni intento explicar los entresijos, directamente digo "Es un problema de habilidad de Next.js, no te preocupes, que le voy a dar fuego pronto". Estas reglas son demasiado difíciles para que un desarrollador promedio las entienda.

§Te llevas todo el contenido dos veces de todas formas

A diferencia de la "arquitectura de islas", los componentes de servidor deben ser hidratados en el frontend para poder emplear Suspense y mantener el estado de los componentes de cliente. Al hacer navegaciones suaves, se obtiene mediante fetch la "carga de RSC" (que no es HTML en absoluto). Al hacer una recarga fresca de la página, se necesita HTML para pintar por primera vez, pero la información sobre los componentes de cliente y Suspense no está en dicho HTML. La solución de React es enviar una segunda copia del markup de la página entera. Un ejemplo de lo que enviaría un servidor de producción de Next.js en un renderizado dinámico de una página sería algo así:

GET /user/clover
<!DOCTYPE html>
<html>
<head>
    {etiquetas link y meta}
</head>
<body>
    {renderizado del lado del servidor}
    <script>
        // un script de inicialización que inicia `__next_f`
        // como un arreglo. en cuanto carga React, la función
        // `.push` se modifica para escribir nuevos fragmentos
        // al decodificador de RSC directamente. tiene algunos
        // auxiliares de DOM también
        (self.__next_f=self.__next_f||[]).push([0])
    </script>
    <script>
        // la carga de RSC para el shell de la aplicación.
        self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[658993,[\"/_next/st{...}"])
    </script>

    <!--
        NO está escrita todavía la etiqueta </body>, ya que hay una
        frontera de Suspense sin resolver. con el paso del tiempo,
        se escriben más datos.
    -->
    <div class="user-post-list">
        {renderizado del lado del servidor de una frontera de Suspense}
    </div>
    <script>
        // la carga de RSC para la frontera de Suspense
        self.__next_f.push([2,"14:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h4\"{...}"])
    </script>
    
    <!-- se repiten etiquetas HTML y script hasta que la página acaba -->
</body>
</html>

Esta solución duplica el tamaño de la carga inicial de HTML. Aún peor, la carga de RSC incluye JSON en cadenas de texto de JS, un formato mucho menos eficiente que HTML. Aunque parece comprimirse bien con brotli y renderizarse rápido en el navegador, esto es un desperdicio. Con el patrón de hidratación, los datos se podrían reutilizar localmente, por lo menos, para la interactividad y otras páginas.

Incluso en páginas con ínfima interactividad, pagas el precio. Como ejemplo, la documentación de Next.js, al cargar su página de inicio, carga una página de alrededor de 750kB (250kB de HTML y 500kB de etiquetas script), y el contenido aparece dos veces.

Puedes comprobarlo pulsando Cmd + Opt + u en Mac o Ctrl + u en otras plataformas y después Cmd / Ctrl + f para ubicar cualquier cadena en el blog, como "construyendo aplicaciones web full-stack". Aparece dos veces. Y es inevitable, ya que es una parte fundamental de los componentes de servidor de React.

El formato de RSC definitivamente tiene más fallas, pero no tengo ganas de ponerme a investigar por qué la cadena /_next/static/chunks/6192a3719cda7dcc.js aparece 27 veces. ¿Qué diablos? ¿¿¿Dais el ancho de banda por gratis???

§Turbopack da asco

Esta sección no es constructiva.

Normalmente no le habría dado una sección en el blog a este punto, pero quiero mostrar tres ejemplos de verdad, directamente extraídos del proyecto.

El primero es un lugar donde, al refactorizar código para satisfacer los modelos de componente de servidor/cliente, hice asíncrono un componente de cliente. Era irritante porque no indicaba dónde estaba el error, sólo contenía el stack trace del servidor.

Next.js error

Otro ejemplo de un error horrible:

Next.js error

Tras arreglar el problema detrás de este segundo error (que ni siquiera recuerdo), el servidor de desarrollo se quedó colgado y tuve que reiniciarlo para que se recuperara.

El último caso es el millón de veces que he puesto un breakpoint (punto de interrupción) en el depurador y la variable hola se convierte en __TURBOPACK__imported__module__$5b$project$5d2f$client$2f$src$2f$utils$2f$filename$2e$ts__$5b$app$2d$client$5d$__$28$ecmascript$29$__["hola"] y más mierda.

Vale. Todo esto da asco. ¿Qué podemos hacer?

§Dejando atrás Next.js y Vercel en el trabajo

Hay dos tipos de proyectos web:

Y Next.js es la herramienta equivocada para ambos. Si quieres una página web estática, usa Astro o Fresh. Para quienes necesiten la potencia de React, esta sección trata cómo pasé de estar atado a Next a TanStack Start, de forma incremental y sin baches.

Todo empezó con esta configuración de Vite.

vite.config.ts
const config = defineConfig(({ mode }) => {
    const env = loadEnv(mode, process.cwd(), "NEXT_PUBLIC_");
    return {
        // Usa el puerto predeterminado de Next.js, 3000
        server: { port: 3000 },
        // Usa el prefijo de variables de entorno predeterminado de Next.js, "NEXT_PUBLIC_"
        define: Object.fromEntries(Object.entries(env).map(
            ([k, v]) => [`process.env.${k}`, JSON.stringify(v)])),
        plugins: [
            viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
            tailwindcss(),
            // Para que mis compañeros de trabajo lo entendieran bien, empecé
            // a portar las rutas en `src/tanstack-routes`; en cuanto acabara,
            // volvería a cambiarlo a `src/routes`, como estaba por defecto.
            tanstackStart({
                router: { routesDirectory: "src/tanstack-routes" },
            }),
            viteReact(),
        ],
        resolve: {
            // La clave para la migración incremental: redirigir `next` a otro lugar
            alias: { next: path.resolve("./src/tanstack-next/") },
            conditions: ["tanstack"],
            extensions: [
                // Permitir que un archivo como `utils/session.tanstack.ts`
                // sobreescriba a `utils/session.ts` al ser importado.
                ".tanstack.tsx", ".tanstack.ts",
                // Extensiones de importación predeterminadas
                ".mjs", ".js", ".mts", ".ts",
                ".jsx", ".tsx", ".json",
            ],
        },
    };
});

Después, me puse a buscar cada uso de una API de Next.js, para o eliminarlo o crear un talón (stub) para TanStack. Por ejemplo, src/tanstack-next/link.tsx implementa next/link:

src/tanstack-next/link.tsx
import { Link } from "@tanstack/react-router";
import type { LinkProps } from "next/link";

export default function LinkAdapter({ href, ...rest }: LinkProps) {
  return <Link {...rest} to={href as unknown as any} />;
}

Algunos de estos talones pueden ser extremadamente simples. Al principio, mi implementación de useRouter era return {}. Más tarde, ya tuve que añadir algunos métodos al objeto. El código no tiene por qué ser limpio, ya que es temporal.

De ahí, el sitio nuevo puede importar casi cada componente de cliente o creando talones para las APIs de Next.js que necesite o usando la extensión .tanstack.ts para reimplementar la lógica archivo por archivo. Poco después, conseguí que la página principal del sitio funcionara en TanStack Start e hicimos merge.

Mi PR (solicitud de incorporación de cambios) "nextgate"

En esta primera PR (solicitud de incorporación de cambios) sólo funcionaba una de nuestras páginas, y conseguí que funcionara con unas mil líneas de código añadido y 40 líneas eliminadas. Algunos parches previos eliminaban los pocos usos de next/image y next/font.

Lo que quedaba era portar el resto de rutas. Lo único que perdemos al migrar de Next.js a cualquier otro framework es el poder usar await con funciones de obtención de datos en la interfaz de usuario. En la práctica, mover cada ruta a una función loader esclareció qué era lo que pasaba al renderizar cada página desde el servidor (SSR). Para páginas con varias llamadas de obtención de datos, estas podían combinarse en una llamada API especial que devolviera todos los datos relevantes a ellas.

Para reiterar, en negrita: El camino de migración de componentes de servidor es simplificar tu código — RSC intrínsecamente te lleva por un camino caótico repleto de cosas innecesarias. Casi todas las partes complejas de nuestro sitio se volvieron más fáciles de entender para todos nuestros ingenieros. La única excepción fue acostumbrar a todos a las nuevas convenciones para el enrutado del sistema de archivos. Con suficientes ejemplos, todos lo acabamos pillando.

Con la migración incremental ya operativa, no se rompía el deployment existente al añadir código nuevo. TanStack fue apoderándose del código y, con el tiempo, fuimos eliminando todos los talones de Next.js y ganamos todas las maravillosas características de seguridad de tipos que ofrece el enrutador de TanStack (TanStack Router). Al final, el sitio rendía más rápido en todos los aspectos: el modo de desarrollo, tiempos de carga en producción, navegaciones suaves, y todo a un menor coste que nuestro deployment de Next con Vercel.

No somos los únicos sintiendo el cambio. Aunque intento evitar las redes sociales, alguien me envió los resultados del trabajo de Brian Anglin en Superwall, mostrando reducciones de CPU increíbles usando TanStack Start. También recuerdo el cambio de ChatGPT de Next.js a Remix hace un año (conversaciones relevantes: [1] [2] [3]).

§next/metadata es maravilloso

Bajo mi punto de vista, esta es una de las pocas APIs buenas que tiene Next.js, y fue el único lugar en nuestro código en el que el cambio a TanStack resultó en mayor dificultad. En vez de empeorar el código, porté su API de metadatos a una función normal y corriente, para que todos la pudieran usar. Solía tener un puerto 1:1 en NPM, pero a comienzos del año simplifiqué su API a un archivo pequeño y comprensible. En el momento de escribir esta publicación, he añadido una API meta.toTags compatible con TanStack que puede instalarse desde JSR o NPM, o simplemente la puedes copiar a tu proyecto.

aviso: Por limitaciones de tiempo para escribir este artículo, la librería todavía no está actualizada. Probablemente lo haré ~~para el final de esta semana (24 de octubre)~~ pronto... Por ahora, puedo compartir la versión que uso en el trabajo: meta.tanstack.ts.

// una vez en tu proyecto
import * as meta from "@clo/lib/meta.ts";

export const defineHead = meta.toTags.bind(null, {
    // opciones para todo el sitio
    base: new URL("https://paperclover.net"),
    titleTemplate: (title) => [title, "paper clover"]
        .filter(Boolean).join(' | '),
    // ...
});

// para cada página...
export const Route = createFileRoute("/blog")({
    head: () =>
        defineHead({
            title: "blog de clover", // usando la plantilla `titleTemplate`
            description: "una gatita maúlla sobre sus opiniones tecnológicas",
            canonical: "/blog", // yuxtapuesto a `base`

            // Cuando se especifica, configura el embed (embebido)
            // de Open Graph y Twitter, usando el título y la
            // descripción de la página por defecto.
            // La configuración predeterminada está bien,
            // pero se pueden especificar más opciones.
            embed: {
                image: "/img/blog.webp",
            }, 

            // Todas las etiquetas meta exóticas se hacen con un fragmento
            // JSX. No se renderiza React, sólo se itera sobre las etiquetas.
            // Mi objetivo era cubrir el 99% de los casos comunes.
            extra: <>
                <meta name="site-verification" content="waffles" />,
            </>,
        }),

    component: Page,
});

function Page() {
    ...
}

Mi versión no se preocupaba con cubrir todas las posibilidades del objeto de metadatos de Next.js; usa JSX en línea (inline) para llenar ese hueco.

§next/og está bien también

No tengo una opinión fuerte al respecto. Sólo quiero recordar a todos que existe el paquete @vercel/og.

§Mi experiencia parece ser la típica

En la Next.js Conf 2024, todos hablaban maravillas de los componentes de servidor. No recuerdo exactamente con quién fue que hablé, pero todos los grandes nombres estaban completamente a favor de ellos. Habiendo implementado el empaquetador de RSC, yo vi algunos de los problemas en el formato. Ahora, viendo que Next 15 "estabilizó" el enrutador de aplicaciones el año pasado, muchas compañías están construyendo productos con él, dándose cuenta de estos inconvenientes de primera mano.

Llegué tarde al mundo de Next.js, pues empecé en junio con la versión 15. Pero todos con los que he hablado están de acuerdo con mis observaciones. Todos con los que hablé en la 1.3 Party de Bun estaban de acuerdo conmigo. Incluso gente de Vercel me ha dicho que no les gusta cómo es usar Next.js.

Espero que, al estabilizarse TanStack Start, se vuelva el nuevo sustituto de Next.js que todos quieran.

§Opta por herramientas que te respeten

El ecosistema de JavaScript es un desastre y ese desastre es por el que la gente se burla del desarrollo web. Muchas veces he pensado que el desastre de trabajar en la web no tenía solución, pero el desastre eran en verdad las librerías comúnmente usadas de las que me rodeaba. Al quitar esa capa, las tecnologías de desarrollo web modernas son maravillosas.

Llevo desarrollando esta página web desde cero, sin framework, desde finales de 2024, implementando sistemas como mi propio widget de progreso para aplicaciones de terminal, proxy para archivos estáticos, sistema de compilación incremental, y muchos más componentes. Trabajar en este código ha llevado a las mejores sesiones de código (en términos de felicidad) en años. Los visitantes de paper clover se llevan una página web de mejor calidad; las minilibrerías que he creado las extraigo para uso público, todos salen ganando.

Este nivel de "desde cero" es demasiado para la mayoría, especialmente en el trabajo. Diría que, como mínimo, deberíamos dedicar atención y dinero sólo a herramientas de alta calidad que nos respeten. Y Next.js y la empresa detrás de ello, Vercel, no caen dentro de ese criterio.

Si usas Next.js y sientes que tu experiencia tampoco te recuerda al respeto, reflexiona si tus compañeros de trabajo y tú queréis seguir apoyando su imperio serverless. El ecosistema de Vite parece bastante decente para desarrollar ahora mismo, pero no tengo mucha experiencia todavía en usar sus herramientas a gran escala en producción. El lanzamiento de Vite+ de Void0 parece interesante, pero sólo el tiempo dirá si estas herramientas, financiadas por capital, nos respetarán (a los usuarios y a los desarrolladores) a largo plazo.

La Next.js Conf 2025, en el momento de escribir, es mañana. En vez de comprarme una entrada de $800, decidí destinar ese dinero al equipo de TanStack por respetar y mejorar el ecosistema de desarrollo web.

Lo que aguarda el futuro

Paulatinamente, he estado reemplazando mucho software que me falta al respeto con mejores alternativas. Algunos ejemplos son GitHub, Visual Studio Code, DaVinci Resolve, Discord, Google Drive/Workspace, entre muchos otros. Pienso escribir más en este blog sobre mis proyectos técnicos (la librería de progreso, el propósito de mi generador de sitio propio, aprendizajes de mi trabajo actual), incluyendo algunos de mis proyectos pasados en Bun (detalles sobre HMR, el sistema de reporte de errores y la locura de sistema para empaquetar módulos integrados). Si te interesa, por favor suscríbete a la lista de correos:

haz clic aquí para enviar un correo a subscribe@paperclover.net, pidiendo ser añadido a la lista de correos. (llevo esta lista de correos a mano)

volver al iniciopregúntame algo sobre este artículo