diff --git a/src/blog/backend.ts b/src/blog/backend.ts index e4fcde90797abb80f23ca3edc90876291436f367..9ac595e5caa51772c636d8f59e90731a2b28af95 100644 --- a/src/blog/backend.ts +++ b/src/blog/backend.ts @@ -6,6 +6,9 @@ app.get("/blog/webdev/one-year-next-app-router", async (c) => { app.get("/blog/webdev/one-year-next-app-router.ko", async (c) => { return assets.serveAsset(c, "/blog/webdev/one-year-next-app-router/ko", 200); }); +app.get("/blog/webdev/one-year-next-app-router.es", async (c) => { + return assets.serveAsset(c, "/blog/webdev/one-year-next-app-router/es", 200); +}); app.get("/blog/community-translations", async (c) => { return assets.serveAsset(c, "/blog/community-translations", 200); diff --git a/src/blog/localization.tsx b/src/blog/localization.tsx index 2dbf342199e8a476db09ffe856003d8fe3fe2b70..4362b55ae4ccd1263cee0b3f2617c70c3caaee93 100644 --- a/src/blog/localization.tsx +++ b/src/blog/localization.tsx @@ -3,6 +3,7 @@ interface LanguageConfig { return: render.Node; writtenByClover: string; translatedBy: (_: { author: string }) => render.Node; + tableOfContents: string; } type LanguageMap = Record & { en: LanguageConfig }; @@ -12,12 +13,21 @@ export const languages: LanguageMap = { return: "back to the home page", writtenByClover: `clover caruso`, // "written by clover caruso" in other languages translatedBy: ({ author }) => `translated by ${author}`, + tableOfContents: "Contents", }, ko: { title: "한국어", return: "홈 페이지로 돌아가기", writtenByClover: `clover caruso 작성`, translatedBy: ({ author }) => `${author} 번역`, + tableOfContents: "Contents", + }, + es: { + title: "Español", + return: "volver a la página principal", + writtenByClover: "escrito por clover caruso", + translatedBy: ({ author }) => `traducido por ${author}`, + tableOfContents: "Indice", }, }; diff --git a/src/blog/pages/webdev/one-year-next-app-router/es.mdx b/src/blog/pages/webdev/one-year-next-app-router/es.mdx new file mode 100644 index 0000000000000000000000000000000000000000..5901196a6a53d5d98a28e67d66d8cd43ea8ed479 --- /dev/null +++ b/src/blog/pages/webdev/one-year-next-app-router/es.mdx @@ -0,0 +1,868 @@ +import Heading from "@/blog/tags/heading.tsx"; +import TableOfContents from "@/blog/tags/table-of-contents.tsx"; +import { Layout } from "@/blog/tags/layout.tsx"; +export { theme } from "@/blog/tags/layout.tsx"; + +export const meta = { + title: "Un año con el enrutador de aplicación (App Router) de Next.js — por qué decidimos cambiar", + description: "Una crítica de los componentes de servidor de React y Next.js 15.", + keywords: ["webdev", "technical analysis", "opinion"], + authors: ["clover caruso"], + embed: { + thumbnail: "/open-graph/next-js.es.png" + }, + twitter: { + image: "https://paperclover.net/open-graph/next-js.png" + }, + canonical: "/blog/webdev/one-year-next-app-router.es" +}; + + + +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)][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. + +[Next.js]: https://nextjs.org +[rsc]: https://react.dev/reference/rsc/server-components + + + +- [Un repaso técnico: ¿qué son los componentes de servidor?][§1] +- [Los inconvenientes del enrutador de aplicaciones][§2] + - [Las actualizaciones optimistas son imposibles][§2.1] + - [Cada navegación supone una petición][§2.2] + - [Los layouts tienen restricciones artificiales][§2.3] + - [Te llevas todo el contenido dos veces de todas formas][§2.4] + - [Turbopack da asco][§2.5] +- [Dejando atrás Next.js y Vercel en el trabajo][§3] + - [`next/metadata` es maravilloso][§3.1] + - [`next/og` está bien también][§3.2] +- [Mi experiencia parece ser la típica][§4] +- [Opta por herramientas que te respeten][§5] + + + +[§1]: #technical-review + +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 +``, 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. + +```tsx filename="src/app/[username]/page.tsx" tint="server" +// 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
+ + + }> + + +
+} + +// Evitamos waterfalls teniendo varios componentes evaluados a la vez. + +async function UserInfo({ username }) { + const user = await fetchUserInfo(username); + return <> +

{user.displayName}

+ {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 `` 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"`. + +```tsx filename="src/components/CopyButton.tsx" tint="client" +"use client"; // Este comentario marca el archivo para el empaquetado del lado del cliente. + +export function CopyButton({ url }) { + return <> + {url} + + +} +``` +```tsx filename="src/app/q+a/Card.tsx" tint="server" +export function Card() { + return
+
+ {/* Hace que el navegador importe el botón de copiar */} + +
+

+ {/* Procesa el Markdown en el backend */} + +

+
+} +``` + +[§2]: #real-world-pitfalls + +Los inconvenientes del enrutador de aplicación + +Tras dejar [Bun] como ingeniero de runtime (donde implementé el [empaquetado de +componentes de servidor][Server Components bundling] y [una plantilla de +RSC][bun-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. + +[Bun]: https://bun.com +[Server Components bundling]: https://github.com/oven-sh/bun/blob/67f0c3e016aa479738469adac2b79a1862b88122/src/bake/bake.d.ts +[bun-rsc]: https://github.com/oven-sh/bun/tree/e7790894d92b730758ecadf971cb935063508dfb/src/bake/bun-framework-react + +[§2.1]: #optimistic-updates + +Las actualizaciones optimistas son imposibles + +La documentación de Next.js respecto a la mutación [no menciona las +actualizaciones optimistas][nextjs-updating-data], 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. + +```tsx filename="src/app/user/[username]/page.tsx" tint="server" + +export default async function Page() { + const user = await fetchUserInfo(username); + return + + ; +} +``` +```tsx filename="src/app/user/[username]/UserProfile.tsx" tint="client" + +"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
{/* interfaz de usuario con campos editables... */}
: +} +``` + +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`][ws] 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]. + +[TanStack Query]: https://github.com/tanstack/query#readme + +```ts filename="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 */ +}); +``` +```tsx filename="src/app/user/[username]/page.tsx" tint="server" +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 + + ; +} +``` +```tsx filename="src/app/user/[username]/ClientPage.tsx" tint="client" +"use client"; +export function ClientPage() { + const { username } = useParams(); + const { data: user } = useSuspenseQuery(queryUserInfo(username)); + + // ... algunos hooks + + return
+ {/* ... una página web interactiva */} +
; +} +``` + +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`. + +[ws]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API +[nextjs-updating-data]: https://nextjs.org/docs/app/getting-started/updating-data + + +[§2.2]: #redundant-fetches + +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. +> +> ```json whitespace="pre-wrap" +> 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? +> +> ```json +> ["$","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`][nextjs-stale], 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. + +[nextjs-stale]: https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes +[nextjs-stale-bug]: https://github.com/vercel/next.js/issues/70661 + +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. + +```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server" +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. + +```tsx filename="src/app/user/[username]/loading.tsx" tint="client" +"use client"; +export default function PageLoadingSkeleton() { + return ; +} +``` + +> 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. + +[§2.3]: #layout-restrictions + +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_][nextjs-fetch] 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. + +[nextjs-fetch]: https://nextjs.org/docs/app/api-reference/functions/fetch + +[§2.4]: #rsc-payload + +Te llevas todo el contenido dos veces de todas formas + +A diferencia de la ["arquitectura de islas"][islands], 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][first paint], 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í: + +[first paint]: https://web.dev/articles/fcp + +```html filename="GET /user/clover" + + + + {etiquetas link y meta} + + + {renderizado del lado del servidor} + + + + +
+ {renderizado del lado del servidor de una frontera de Suspense} +
+ + + + + +``` + +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](https://nextjs.org/docs), 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??? + +[islands]: https://www.patterns.dev/vanilla/islands-architecture/ + +[§2.5]: #turbopack + +Turbopack da asco + +Esta sección no es constructiva. + +- Turbopack no es ágil +- Turbopack emite código difícil de depurar (en modo de desarrollo) +- Turbopack produce mensajes de error malos en muchos casos + +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](/file/2025/blog-everyone-hates-nextjs/asyncerror.png) + +Otro ejemplo de un error horrible: + +![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png) + +> 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? + +[§3]: #ditching-nextjs + +Dejando atrás Next.js y Vercel en el trabajo + +Hay dos tipos de proyectos web: + +- Una página web con contenido mayoritariamente estático. +- Una aplicación web con componentes mayormente dinámicos e interactivos. + +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. + +[Astro]: https://astro.build/ +[Fresh]: https://fresh.deno.dev/ +[TanStack Start]: https://tanstack.com/start/latest + +Todo empezó con esta configuración de Vite. + +```ts filename="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`: + +```tsx filename="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 ; +} +``` + +> 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"](/file/2025/blog-everyone-hates-nextjs/pr.png) + +> 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][type-safety features] 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. + +[type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety + +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][superwall-twitter], 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][chatgpt-1]] [[2][chatgpt-2]] [[3][chatgpt-3]]). + +[superwall-twitter]: https://twitter.com/BriansAngles/status/1978834116079436242#m +[chatgpt-1]: https://xcancel.com/ryanflorence/status/1831379475654947233 +[chatgpt-2]: https://old.reddit.com/r/reactjs/comments/1f97zgr/chatgpt_migrates_from_nextjs_to_remix +[chatgpt-3]: https://old.reddit.com/r/nextjs/comments/1f92jdv/chatgptcom_switched_from_nextjs_to_remix + +[§3.1]: #next-metadata + +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][lib-jsr] o [NPM][lib-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`](https://paperclover.net/file/2025/blog-everyone-hates-nextjs/meta.tanstack.ts). + +```tsx +// 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: <> + , + , + }), + + 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. + +[lib-jsr]: https://jsr.io/@clo/lib +[lib-npm]: https://npmjs.com/@paperclover/lib + +[§3.2]: #vercel-og + +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`. + +[§4]: #experience-feels-like-the-usual + +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. + +[§5]: #prefer-respectful-tools + +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][progress], [proxy para archivos +estáticos][file-cache], 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][lib], 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][serverless empire]. 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][vite-plus] 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][next-conf]. En vez +de comprarme una entrada de $800, decidí destinar ese dinero al [equipo de +TanStack][tanstack-donate] por [respetar y mejorar el ecosistema de desarrollo +web][tanstack-ethos]. + +[paper clover]: https://paperclover.net/ +[lib]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib#readme +[progress]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib/progress.ts +[file-cache]: https://git.paperclover.net/clo/sitegen/src/branch/master/src/file-viewer/cache.ts + +[next-conf]: https://nextjs.org/conf +[vite-plus]: https://viteplus.dev/ +[tanstack-ethos]: https://tanstack.com/ethos +[tanstack-donate]: https://github.com/sponsors/tannerlinsley +[serverless empire]: https://youtu.be/SCIfWhAheVw + +## 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 inicio](#top) — [pregúntame algo sobre este artículo](/q+a) + +
+
+
+
+
+
+
+2025 (c) paper clover +
+ +
+ +
+ diff --git a/src/static/open-graph/next-js.es.png b/src/static/open-graph/next-js.es.png new file mode 100644 index 0000000000000000000000000000000000000000..d7b0079e360326cf403f789e647342bceecb62ca Binary files /dev/null and b/src/static/open-graph/next-js.es.png differ