1---
2layout: "#src/blog/tags/blog-layout.marko"
3meta:
4 title: Un año con el enrutador de aplicación (App Router) de Next.js — por qué decidimos cambiar
5 description: Una crítica de los componentes de servidor de React y Next.js 15.
6 keywords: ["webdev", "technical analysis", "opinion"]
7 authors: ["clover caruso", "trubiso"]
8 embed:
9 thumbnail: /open-graph/next-js.es.png
10 canonical: /blog/webdev/one-year-next-app-router.es
11date: "Oct 21st, 2025"
12translation:
13 lang: es
14 author: "@trubiso"
15 href: null
16 date: Jan 13th, 2026
17---
18
19He estado usando [Next.js] profesionalmente para el desarrollo de aplicaciones
20web en mi trabajo y el diseño de su enrutador de aplicación (App Router) y los
21[componentes de servidor (React Server Components/RSC)][rsc] me parece
22extremadamente frustrante a nivel fundamental. No es por pequeños bugs o porque
23la API me parezca confusa, sino por grandes discrepancias en las decisiones de
24diseño fundamentales que tomaron Vercel y el equipo de React en su creación.
25
26Cuanto más asisto a eventos de desarrollo web, más veo a gente a quien no le
27gusta Next.js, pero que aún así debe usarlo. Al final del artículo, compartiré
28cómo escapamos de este infierno, migrando todo nuestro frontend a [TanStack
29Start] sin problemas.
30
31[Next.js]: https://nextjs.org
32[rsc]: https://react.dev/reference/rsc/server-components
33
34<table-of-contents />
35
36<h2#technical-review>Un repaso técnico: ¿qué son los componentes de servidor?</>
37
38El punto clave de RSC es que los componentes se dividen en dos categorías,
39componentes de <b class='server'>"servidor"</b> y componentes de <b class='client'>"cliente"</b>. Los componentes de servidor pueden usar ni
40`useState` ni `useEffect`, pero pueden hacer uso de funciones `async` y emplear
41herramientas de backend, permitiendo por ejemplo llamar directamente a una base
42de datos. Los componentes de cliente constituyen el modelo tradicional, en el
43que hay código en el backend para generar HTML y en el frontend para administrar
44el DOM usando `window.document.*`.
45
46> Ahí va el primer desastre: ¡la nomenclatura! React usa las palabras <b class='server'>"servidor"</b> y <b class='client'>"cliente"</b> 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 <b class='client'>cliente</b> también pueden correr en el backend! En este artículo, utilizaré los términos <b>"backend"</b> y <b>"frontend"</b> para describir los ámbitos de ejecución en los que existen las aplicaciones web: un proceso de Node.js y un navegador web, respectivamente.
47
48Este modelo de componentes de <b class='server'>servidor</b> y de <b class='client'>cliente</b> es curioso. Dado que las funciones integradas, como
49`<Suspense />`, se serializan a través de la red, la obtención de datos se puede
50modelar de manera trivial con <b class='server'>componentes de servidor</b>
51asíncronos, y la interfaz de usuario alternativa funciona como si fuera del lado
52del cliente.
53
54```tsx filename="src/app/[username]/page.tsx" tint="server"
55// En este artículo, los componentes de servidor están resaltados en rojo.
56export default async function Page({ params }) {
57 // Los parámetros de la página se pasan como una Promise resuelta.
58 const { username } = await params;
59
60 // Los componentes `UserInfo` y `UserPostList` serán corridos a la vez. En
61 // cuanto `UserInfo` esté listo, el visitante verá la página con un
62 // `PostListSkeleton` si la lista de publicaciones no está lista.
63 return <main>
64 <UserInfo username={username} />
65
66 <Suspense fallback={<PostListSkeleton />}>
67 <UserPostList username={username} />
68 </Suspense>
69 </main>
70}
71
72// Evitamos waterfalls teniendo varios componentes evaluados a la vez.
73
74async function UserInfo({ username }) {
75 const user = await fetchUserInfo(username);
76 return <>
77 <h1>{user.displayName}</h1>
78 {user.bio ? <Markdown content={user.bio} /> : ""}
79 </>
80}
81
82async function UserPostList({ username }) {
83 const posts = await fetchUserPostList(username);
84 return /* interfaz de usuario de la lista de publicaciones omitida por brevedad */;
85}
86```
87
88El ejemplo anterior no usa nada de JavaScript (sin contar el tamaño del empaquetado
89_gzipped_ de 40kB del propio React) para la interfaz de usuario ni para la
90obtención de datos &mdash; ¡sólo envía el HTML! Por ejemplo, el _parser_ (analizador)
91imaginario de Markdown dentro del componente `<Markdown />` se queda en el
92backend. En aquellos casos en los que se necesita un frontend interactivo,
93simplemente se puede crear un <b class='client'>componente de cliente</b>
94poniéndolo en un archivo empezando con `"use client"`.
95
96```tsx filename="src/components/CopyButton.tsx" tint="client"
97"use client"; // Este comentario marca el archivo para el empaquetado del lado del cliente.
98
99export function CopyButton({ url }) {
100 return <>
101 <span>{url}</span>
102 <button onClick={() => {
103 const full = new URL(url, location.href);
104 navigator.clipboard.writeText(full.href);
105 // omitiendo todo el manejo de errores, la interfaz de éxito, los estilos...
106 }}>copy</button>
107 </>
108}
109```
110```tsx filename="src/app/q+a/Card.tsx" tint="server"
111export function Card() {
112 return <article>
113 <header>
114 {/* Hace que el navegador importe el botón de copiar */}
115 <CopyButton url="/q+a/2506010139" />
116 </header>
117 <p>
118 {/* Procesa el Markdown en el backend */}
119 <Markdown content=".........." />
120 </p>
121 </article>
122}
123```
124
125<h2#real-world-pitfalls>Los inconvenientes del enrutador de aplicación</>
126
127Tras dejar [Bun] como ingeniero de runtime (donde implementé el [empaquetado de
128componentes de servidor][Server Components bundling] y [una plantilla de
129RSC][bun-rsc]), me uní a una pequeña compañía, trabajando en la línea de fuego:
130una aplicación de Next.js con un backend de Hono. Lo siguiente es una versión
131simplificada de los problemas con los que me enfrenté tratando de mantener y
132desarrollar características nuevas. Como resultado de todos ellos, perdimos
133todos el tiempo intentando eludir fallos de diseño o explicándonos por qué algo
134que no debería ser un problema para empezar se ha convertido en un obstáculo
135inamovible.
136
137[Bun]: https://bun.com
138[Server Components bundling]: https://github.com/oven-sh/bun/blob/67f0c3e016aa479738469adac2b79a1862b88122/src/bake/bake.d.ts
139[bun-rsc]: https://github.com/oven-sh/bun/tree/e7790894d92b730758ecadf971cb935063508dfb/src/bake/bun-framework-react
140
141<h3#optimistic-updates>Las actualizaciones optimistas son imposibles</>
142
143La documentación de Next.js respecto a la mutación [no menciona las
144actualizaciones optimistas][nextjs-updating-data], parece ser que no pensaron en
145este caso. Los componentes renderizados por el <b class='server'>servidor de
146React</b> no pueden ser modificados tras ser montados por diseño. Los elementos
147que puedan tener cambios deben estar dentro de un componente de cliente, pero no
148puede estos componentes no pueden dar lugar a obtención de datos, incluso
149durante el renderizado del lado del servidor (SSR) en el backend. Esto deriva en
150componentes de servidor incómodamente diminutos que sólo se encargan de obtener
151ciertos datos y tienen un componente homólogo de cliente que contiene una
152versión prácticamente estática de la página.
153
154```tsx filename="src/app/user/[username]/page.tsx" tint="server"
155
156export default async function Page() {
157 const user = await fetchUserInfo(username);
158 return <ProfileLayout>
159 <UserProfile user={user} />
160 </ProfileLayout>;
161}
162```
163```tsx filename="src/app/user/[username]/UserProfile.tsx" tint="client"
164
165"use client"; // Toca mover el código de cliente a otro archivo!
166
167export function UserProfile({ user: initialUser }) {
168 // Existen muchas librerías de gestión de estado excelentes;
169 // para simplificar, usaremos una celda de estado.
170 const [user, optimisticUpdateUser] = useState(initialUser);
171
172 async function onEdit(newUser) {
173 optimisticUpdateUser(newUser);
174 const resp = await fetch("...", {
175 method: 'POST',
176 body: JSON.stringify(newUser),
177 ... // (encabezados, credenciales, trazado y más)
178 })
179 if (!resp.ok) /* ¡recuerda siempre comprobar si se ha dado algún error! */
180 }
181
182 return <main>{/* interfaz de usuario con campos editables... */}</main>:
183}
184```
185
186A medida que más partes de la página necesitan interactividad, se vuelve más
187lioso mantener las partes estáticas puramente del lado del servidor. En la
188aplicación de trabajo, casi cada componente de la interfaz de usuario muestra
189datos dinámicos. Un [`WebSocket`][ws] sincroniza los datos en tiempo real a
190medida que se actualizan (por ejemplo, el estado en línea y el perfil básico de
191una tarjeta de usuario). Como estas configuraciones de componentes son más
192difíciles de entender y mantener para los ingenieros, casi todas nuestras
193páginas están marcadas como `"use client"` con un `page.tsx` que define la
194obtención de datos necesaria.
195
196Un ejemplo más concreto de cómo queda esto en la práctica con la librería de
197obtención de datos que usamos en el trabajo, [TanStack Query].
198
199[TanStack Query]: https://github.com/tanstack/query#readme
200
201```ts filename="src/queries/users.ts"
202// Tenemos una función auxiliar `defineQuery` para la seguridad de tipos en el trabajo.
203// Los obtenedores de datos son triviales y pueden correr tanto en el backend como en el frontend.
204export const queryUserInfo = (username) => ({
205 queryKey: ['user', username],
206 queryFn: async ({ ... }) => /* obtener datos */
207});
208```
209```tsx filename="src/app/user/[username]/page.tsx" tint="server"
210export default async function Page({ params }) {
211 const { username } = await params;
212
213 // No hay estado global en el servidor de React. Como los layouts
214 // se ejecutan en paralelo, toca reconstruir el `QueryClient` de
215 // TanStack varias veces por ruta.
216 const queryClient = new QueryClient();
217 await queryClient.ensureQueryData(queryUserInfo(username));
218
219 // HydrationBoundary es un componente de cliente que pasa datos
220 // JSON del servidor de React al componente de cliente.
221 return <HydrationBoundary state={dehydrate(queryClient)}>
222 <ClientPage />
223 </HydrationBoundary>;
224}
225```
226```tsx filename="src/app/user/[username]/ClientPage.tsx" tint="client"
227"use client";
228export function ClientPage() {
229 const { username } = useParams();
230 const { data: user } = useSuspenseQuery(queryUserInfo(username));
231
232 // ... algunos hooks
233
234 return <main>
235 {/* ... una página web interactiva */}
236 </main>;
237}
238```
239
240Este ejemplo necesita tres archivos por las reglas del empaquetado de los
241componentes de servidor (el componente de cliente necesita `"use client"` y los
242archivos de componentes de servidor no suelen poderse importar en el cliente
243debido a las importaciones exclusivas de servidor). En el enrutador de páginas
244(Pages Router), podría haberlo hecho todo en un solo archivo por el tree-shaking
245que poseen `getStaticProps` y `getServerSideProps`.
246
247[ws]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
248[nextjs-updating-data]: https://nextjs.org/docs/app/getting-started/updating-data
249
250<h3#redundant-fetches>Cada navegación supone una petición</h3>
251
252Como el enrutador de aplicación inicia cada página como componente de servidor,
253con áreas pequeñas de interactividad (idealmente), navegar a una nueva página
254¡*fuerza* una petición al servidor de Next.js, independientemente de los datos
255que el cliente ya tenga disponibles! Incluso con un archivo `loading.tsx`, al
256abrir `/`, navegar a `/other/ y luego volver a `/`, se mostrará el estado de
257carga mientras vuelve a obtener los datos de la página de inicio.
258
259Para lo único para lo que esto funciona bien es para el **contenido
260perfectamente estático**, donde las navegaciones instantáneas y la precarga
261(prefetching) funcionan genial. Pero **las aplicaciones web no son estáticas**,
262tienen mucho contenido dinámico. Haber iniciado sesión afecta a la página de
263inicio, cosa que es irritante porque el cliente literalmente ya tiene todo lo
264que necesita para mostrar la página instantáneamente. Ni siquiera han cambiado
265las cookies.
266
267> **nota**: Probándolo más en un proyecto en blanco, he observado casos en los
268> que el código de frontend de Next precarga rutas **sin ningún contenido
269> real**. En el ejemplo de hello world, era una carga de 1.8kB de RSC que
270> apuntaba a 2 fragmentos de JS diferentes 4 veces distintas. Esto es un desperdicio
271> puro de nuestro ancho de banda y egreso, especialmente considerando que toda
272> esta información se vuelve a obtener cuando de verdad hago clic en el enlace.
273>
274> ```json whitespace="pre-wrap"
275> 1:"$Sreact.fragment"
276> 2:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
277> 3:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
278> 4:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"ViewportBoundary"]
279> 6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"MetadataBoundary"]
280> 7:"$Sreact.suspense"
281> 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}
282> 5:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
283> 9:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"IconMark"]
284> 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",{}]]
285> ```
286>
287> Revisándolo, me di cuenta de que en realidad hay algo de contenido: el estado
288> de carga. ¿Lo ves?
289>
290> ```json
291> ["$","div","l",{"children":"loading..."}]
292> ```
293>
294> Sigue siendo un desperdicio grande, ya que todos estos datos se vuelven a
295> emitir en el RSC de la propia página.
296
297Parece ser que la solución a esto es [`staleTime`][nextjs-stale], pero está
298marcado como experimental y "no recomendado para producción". Es una vergüenza
299el hecho de que esto sea una opción que no está por defecto y que parece haber
300sido una idea de último momento. Incluso usándola, no es posible hacer que
301varias páginas que hacen referencia a los mismos datos los compartan.
302
303Un ejemplo de un estado de carga irrepresentable con el enrutador de aplicación
304es el de ciertas páginas, como una página de propuestas (issues) en un proyecto
305de git, al hacer clic en un nombre de usuario para ir a su página de perfil. Con
306`loading.tsx`, la página entera es un esqueleto, pero al modelar estas consultas
307con TanStack Query es posible mostrar el nombre de usuario y avatar
308instantáneamente mientras cargan la biografía del usuario y sus repositorios.
309Los componentes de servidor son incompatibles con esta tipo de navegación porque
310los datos sólo están disponibles en componentes renderizados, con lo cual deben
311ser obtenidos una vez más.
312
313[nextjs-stale]: https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes
314[nextjs-stale-bug]: https://github.com/vercel/next.js/issues/70661
315
316En nuestro sitio Next.js, tenemos esta línea de código en nuestros obtenedores
317de datos en los componentes de servidor para hacer que las navegaciones suaves
318vayan más rápido, saltándose la fase de obtención de datos por completo.
319
320```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server"
321export function serverSidePrefetchQueries(queries) {
322 if ((await headers()).get("next-url")) {
323 // Se trata de una navegación suave. Nos SALTAMOS la precarga para
324 // aumentar la velocidad. Puede que el cliente ya tenga estos datos y,
325 // de lo contrario, tienen el estado de carga. Idealmente, no existiría
326 // esta petición -- el lado de cliente ya tiene casi TODO el código ya
327 // que la aplicación está escrita principalmente usando componentes de
328 // cliente. Un fallo de diseño de parte del enrutador de aplicación, la
329 // verdad.
330 return;
331 }
332 // ... lógica de precarga de datos ...
333}
334```
335
336Aparte, `loading.tsx` debería contener las llamadas a `useQuery` para que,
337mientras se realiza la petición para el RSC vacío, se obtengan los datos si de
338verdad se necesitan. De hecho, el estado `loading.tsx` puede ser directamente el
339componente de cliente y se mostrará la página de cliente.
340
341```tsx filename="src/app/user/[username]/loading.tsx" tint="client"
342"use client";
343export default function PageLoadingSkeleton() {
344 return <ClientPage />;
345}
346```
347
348> En el trabajo, simplemente hacemos que nuestros archivos `loading.tsx`
349> contengan las llamadas a `useQuery` y muestren un esqueleto. Esto se debe a
350> que cuando Next.js carga el componente de servidor en sí, remonta la página
351> entera sí o sí. No se da ningún tipo de VDOM diffing, así que todos los hooks
352> (`useState`) se reiniciarán un poco después de que la petición se complete.
353> Intenté reproducir un caso simple en el que estaba *suplicándole* a Next.js
354> que tan solo *actualizara el DOM existente* y preservara el estado, pero
355> directamente no lo hace. Por suerte, el tiempo que tarda la llamada RSC en
356> blanco es suficientemente corto.
357
358<h3#layout-restrictions>Los layouts tienen restricciones artificiales</h3>
359
360Los layouts pueden obtener datos, pero no pueden observar o alterar la petición
361de ninguna manera. Está hecho así para que Next.js pueda obtener y cachear los
362layouts cada vez que quiera. En cualquier otro framework, los layouts son
363componentes normales sin diferencia de otros componentes en la página.
364
365Obtener los layouts por sí mismos es una idea chula, pero acaba siendo estúpida,
366pues toda obtención de datos debe rehacerse para cada layout. No puedes
367compartir un `QueryClient`, en su lugar debes usar su
368[`fetch` _monkey-patched_][nextjs-fetch] para cachear la misma petición `GET`
369tal y como prometen.
370
371Cuando me pregunta un compañero de trabajo por qué Next.js rechaza código, ya ni
372intento explicar los entresijos, directamente digo *"Es un problema de habilidad
373de Next.js, no te preocupes, que le voy a dar fuego pronto"*. Estas reglas son
374demasiado difíciles para que un desarrollador promedio las entienda.
375
376[nextjs-fetch]: https://nextjs.org/docs/app/api-reference/functions/fetch
377
378<h3#rsc-payload>Te llevas todo el contenido dos veces de todas formas</h3>
379
380A diferencia de la ["arquitectura de islas"][islands], los componentes de
381servidor deben ser hidratados en el frontend para poder emplear `Suspense` y
382mantener el estado de los componentes de cliente. Al hacer navegaciones suaves,
383se obtiene mediante `fetch` la "carga de RSC" (que no es HTML en absoluto).
384Al hacer una recarga fresca de la página, se necesita HTML para
385[pintar por primera vez][first paint], pero la información sobre los componentes
386de cliente y `Suspense` no está en dicho HTML. La solución de React es **enviar
387una segunda copia del _markup_ de la página entera**. Un ejemplo de lo que
388enviaría un servidor de producción de Next.js en un renderizado dinámico de una
389página sería algo así:
390
391[first paint]: https://web.dev/articles/fcp
392
393```html filename="GET /user/clover"
394<!DOCTYPE html>
395<html>
396<head>
397 {etiquetas link y meta}
398</head>
399<body>
400 {renderizado del lado del servidor}
401 <script>
402 // un script de inicialización que inicia `__next_f`
403 // como un arreglo. en cuanto carga React, la función
404 // `.push` se modifica para escribir nuevos fragmentos
405 // al decodificador de RSC directamente. tiene algunos
406 // auxiliares de DOM también
407 (self.__next_f=self.__next_f||[]).push([0])
408 </script>
409 <script>
410 // la carga de RSC para el shell de la aplicación.
411 self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[658993,[\"/_next/st{...}"])
412 </script>
413
414 <!--
415 NO está escrita todavía la etiqueta </body>, ya que hay una
416 frontera de Suspense sin resolver. con el paso del tiempo,
417 se escriben más datos.
418 -->
419 <div class="user-post-list">
420 {renderizado del lado del servidor de una frontera de Suspense}
421 </div>
422 <script>
423 // la carga de RSC para la frontera de Suspense
424 self.__next_f.push([2,"14:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h4\"{...}"])
425 </script>
426
427 <!-- se repiten etiquetas HTML y script hasta que la página acaba -->
428</body>
429</html>
430```
431
432Esta solución **duplica el tamaño de la carga inicial de HTML**. Aún peor, la
433carga de RSC incluye JSON en cadenas de texto de JS, un formato mucho menos
434eficiente que HTML. Aunque parece comprimirse bien con brotli y renderizarse
435rápido en el navegador, esto es un desperdicio. Con el patrón de hidratación,
436los datos se podrían reutilizar localmente, por lo menos, para la interactividad
437y otras páginas.
438
439Incluso en páginas con ínfima interactividad, pagas el precio. Como ejemplo, la
440documentación de Next.js, al cargar su [página de
441inicio](https://nextjs.org/docs), carga una página de alrededor de 750kB (250kB
442de HTML y 500kB de etiquetas script), y el contenido aparece dos veces.
443
444Puedes comprobarlo pulsando <kbd>Cmd</kbd> + <kbd>Opt</kbd> + <kbd>u</kbd> en
445Mac o <kbd>Ctrl</kbd> + <kbd>u</kbd> en otras plataformas y después
446<kbd>Cmd</kbd> / <kbd>Ctrl</kbd> + <kbd>f</kbd> para ubicar cualquier cadena
447en el blog, como "construyendo aplicaciones web full-stack". Aparece dos veces.
448Y **es inevitable**, ya que es una parte fundamental de los componentes de
449servidor de React.
450
451El formato de RSC definitivamente tiene más fallas, pero no tengo ganas de
452ponerme a investigar por qué la cadena `/_next/static/chunks/6192a3719cda7dcc.js`
453aparece 27 veces. ¿Qué diablos? ¿¿¿Dais el ancho de banda por gratis???
454
455[islands]: https://www.patterns.dev/vanilla/islands-architecture/
456
457<h3#turbopack>Turbopack da asco</h3>
458
459Esta sección no es constructiva.
460
461- Turbopack no es ágil
462- Turbopack emite código difícil de depurar (en modo de desarrollo)
463- Turbopack produce mensajes de error malos en muchos casos
464
465Normalmente no le habría dado una sección en el blog a este punto, pero quiero
466mostrar tres ejemplos de verdad, directamente extraídos del proyecto.
467
468El primero es un lugar donde, al refactorizar código para satisfacer los modelos
469de componente de servidor/cliente, hice asíncrono un componente de cliente. Era
470irritante porque no indicaba dónde estaba el error, sólo contenía el stack trace
471del <b class='server'>servidor</b>.
472
473![Next.js error](/file/2025/blog-everyone-hates-nextjs/asyncerror.png)
474
475Otro ejemplo de un error horrible:
476
477![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png)
478
479> Tras arreglar el problema detrás de este segundo error (que ni siquiera recuerdo),
480> el servidor de desarrollo se quedó colgado y tuve que reiniciarlo para que se recuperara.
481
482El último caso es el millón de veces que he puesto un _breakpoint_ (punto de
483interrupción) en el depurador y la variable `hola` se convierte en
484`__TURBOPACK__imported__module__$5b$project$5d2f$client$2f$src$2f$utils$2f$filename$2e$ts__$5b$app$2d$client$5d$__$28$ecmascript$29$__["hola"]`
485y más mierda.
486
487Vale. Todo esto da asco. ¿Qué podemos hacer?
488
489<h2#ditching-nextjs>Dejando atrás Next.js y Vercel en el trabajo</h2>
490
491Hay dos tipos de proyectos web:
492
493- Una página web con contenido mayoritariamente estático.
494- Una aplicación web con componentes mayormente dinámicos e interactivos.
495
496Y Next.js es la herramienta equivocada para ambos. Si quieres una página web
497estática, usa [Astro] o [Fresh]. Para quienes necesiten la potencia de React,
498esta sección trata cómo pasé de estar atado a Next a [TanStack Start], de forma
499incremental y sin baches.
500
501[Astro]: https://astro.build/
502[Fresh]: https://fresh.deno.dev/
503[TanStack Start]: https://tanstack.com/start/latest
504
505Todo empezó con esta configuración de Vite.
506
507```ts filename="vite.config.ts"
508const config = defineConfig(({ mode }) => {
509 const env = loadEnv(mode, process.cwd(), "NEXT_PUBLIC_");
510 return {
511 // Usa el puerto predeterminado de Next.js, 3000
512 server: { port: 3000 },
513 // Usa el prefijo de variables de entorno predeterminado de Next.js, "NEXT_PUBLIC_"
514 define: Object.fromEntries(Object.entries(env).map(
515 ([k, v]) => [`process.env.${k}`, JSON.stringify(v)])),
516 plugins: [
517 viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
518 tailwindcss(),
519 // Para que mis compañeros de trabajo lo entendieran bien, empecé
520 // a portar las rutas en `src/tanstack-routes`; en cuanto acabara,
521 // volvería a cambiarlo a `src/routes`, como estaba por defecto.
522 tanstackStart({
523 router: { routesDirectory: "src/tanstack-routes" },
524 }),
525 viteReact(),
526 ],
527 resolve: {
528 // La clave para la migración incremental: redirigir `next` a otro lugar
529 alias: { next: path.resolve("./src/tanstack-next/") },
530 conditions: ["tanstack"],
531 extensions: [
532 // Permitir que un archivo como `utils/session.tanstack.ts`
533 // sobreescriba a `utils/session.ts` al ser importado.
534 ".tanstack.tsx", ".tanstack.ts",
535 // Extensiones de importación predeterminadas
536 ".mjs", ".js", ".mts", ".ts",
537 ".jsx", ".tsx", ".json",
538 ],
539 },
540 };
541});
542```
543
544Después, me puse a buscar cada uso de una API de Next.js, para o eliminarlo o
545crear un talón (_stub_) para TanStack. Por ejemplo, `src/tanstack-next/link.tsx`
546implementa `next/link`:
547
548```tsx filename="src/tanstack-next/link.tsx"
549import { Link } from "@tanstack/react-router";
550import type { LinkProps } from "next/link";
551
552export default function LinkAdapter({ href, ...rest }: LinkProps) {
553 return <Link {...rest} to={href as unknown as any} />;
554}
555```
556
557> Algunos de estos talones pueden ser extremadamente simples. Al principio, mi
558> implementación de `useRouter` era `return {}`. Más tarde, ya tuve que añadir
559> algunos métodos al objeto. El código no tiene por qué ser limpio, ya que es
560> temporal.
561
562De ahí, el sitio nuevo puede importar casi cada componente de cliente o creando
563talones para las APIs de Next.js que necesite o usando la extensión `.tanstack.ts`
564para reimplementar la lógica archivo por archivo. Poco después, conseguí que
565la página principal del sitio funcionara en TanStack Start e hicimos _merge_.
566
567![Mi PR (solicitud de incorporación de cambios) "nextgate"](/file/2025/blog-everyone-hates-nextjs/pr.png)
568
569> En esta primera PR (solicitud de incorporación de cambios) sólo funcionaba una
570> de nuestras páginas, y conseguí que funcionara con unas mil líneas de código
571> añadido y 40 líneas eliminadas. Algunos parches previos eliminaban los pocos
572> usos de `next/image` y `next/font`.
573
574Lo que quedaba era portar el resto de rutas. Lo único que perdemos al migrar de
575Next.js a cualquier otro framework es el poder usar `await` con funciones de
576obtención de datos en la interfaz de usuario. En la práctica, mover cada ruta
577a una función `loader` esclareció qué era lo que pasaba al renderizar cada
578página desde el servidor (SSR). Para páginas con varias llamadas de obtención de
579datos, estas podían combinarse en una llamada API especial que devolviera todos
580los datos relevantes a ellas.
581
582Para reiterar, en negrita: <strong style='color:var(--secondary)'>El camino de
583migración de componentes de servidor es simplificar tu código &mdash; RSC
584intrínsecamente te lleva por un camino caótico repleto de cosas
585innecesarias</strong>. Casi todas las partes complejas de nuestro sitio se
586volvieron más fáciles de entender para todos nuestros ingenieros. La única
587excepción fue acostumbrar a todos a las nuevas convenciones para el enrutado
588del sistema de archivos. Con suficientes ejemplos, todos lo acabamos pillando.
589
590Con la migración incremental ya operativa, no se rompía el _deployment_ existente
591al añadir código nuevo. TanStack fue apoderándose del código y, con el tiempo,
592fuimos eliminando todos los talones de Next.js y ganamos todas las maravillosas
593[características de seguridad de tipos][type-safety features] que ofrece el
594enrutador de TanStack (TanStack Router). Al final, el sitio rendía más rápido en
595todos los aspectos: el modo de desarrollo, tiempos de carga en producción,
596navegaciones suaves, y todo a un menor coste que nuestro _deployment_ de Next con
597Vercel.
598
599[type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety
600
601No somos los únicos sintiendo el cambio. Aunque intento evitar las redes
602sociales, alguien me envió [los resultados del trabajo de Brian Anglin en
603Superwall][superwall-twitter], mostrando reducciones de CPU increíbles usando
604TanStack Start. También recuerdo el cambio de ChatGPT de Next.js a Remix hace un
605año (conversaciones relevantes: [[1][chatgpt-1]] [[2][chatgpt-2]] [[3][chatgpt-3]]).
606
607[superwall-twitter]: https://twitter.com/BriansAngles/status/1978834116079436242#m
608[chatgpt-1]: https://xcancel.com/ryanflorence/status/1831379475654947233
609[chatgpt-2]: https://old.reddit.com/r/reactjs/comments/1f97zgr/chatgpt_migrates_from_nextjs_to_remix
610[chatgpt-3]: https://old.reddit.com/r/nextjs/comments/1f92jdv/chatgptcom_switched_from_nextjs_to_remix
611
612<h3#next-metadata><code>next/metadata</code> es maravilloso</h3>
613
614Bajo mi punto de vista, esta es una de las pocas APIs buenas que tiene Next.js,
615y fue el único lugar en nuestro código en el que el cambio a TanStack resultó en
616mayor dificultad. En vez de empeorar el código, porté su API de metadatos a una
617función normal y corriente, para que todos la pudieran usar. Solía tener un
618puerto 1:1 en NPM, pero a comienzos del año simplifiqué su API a un archivo
619pequeño y comprensible. En el momento de escribir esta publicación, he añadido
620una API `meta.toTags` compatible con TanStack que puede instalarse desde
621[JSR][lib-jsr] o [NPM][lib-npm], o simplemente la puedes copiar a tu proyecto.
622
623> **aviso**: Por limitaciones de tiempo para escribir este artículo, la librería
624> todavía no está actualizada. Probablemente lo haré ~~para el final de esta
625> semana (24 de octubre)~~ pronto... Por ahora, puedo compartir la versión que
626> uso en el trabajo:
627> [`meta.tanstack.ts`](https://paperclover.net/file/2025/blog-everyone-hates-nextjs/meta.tanstack.ts).
628
629```tsx
630// una vez en tu proyecto
631import * as meta from "@clo/lib/meta";
632
633export const defineHead = meta.toTags.bind(null, {
634 // opciones para todo el sitio
635 base: new URL("https://paperclover.net"),
636 titleTemplate: (title) => [title, "paper clover"]
637 .filter(Boolean).join(' | '),
638 // ...
639});
640
641// para cada página...
642export const Route = createFileRoute("/blog")({
643 head: () =>
644 defineHead({
645 title: "blog de clover", // usando la plantilla `titleTemplate`
646 description: "una gatita maúlla sobre sus opiniones tecnológicas",
647 canonical: "/blog", // yuxtapuesto a `base`
648
649 // Cuando se especifica, configura el embed (embebido)
650 // de Open Graph y Twitter, usando el título y la
651 // descripción de la página por defecto.
652 // La configuración predeterminada está bien,
653 // pero se pueden especificar más opciones.
654 embed: {
655 image: "/img/blog.webp",
656 },
657
658 // Todas las etiquetas meta exóticas se hacen con un fragmento
659 // JSX. No se renderiza React, sólo se itera sobre las etiquetas.
660 // Mi objetivo era cubrir el 99% de los casos comunes.
661 extra: <>
662 <meta name="site-verification" content="waffles" />,
663 </>,
664 }),
665
666 component: Page,
667});
668
669function Page() {
670 ...
671}
672```
673
674Mi versión no se preocupaba con cubrir todas las posibilidades del objeto de
675metadatos de Next.js; usa JSX en línea (_inline_) para llenar ese hueco.
676
677[lib-jsr]: https://jsr.io/@clo/lib
678[lib-npm]: https://npmjs.com/@paperclover/lib
679
680<h3#vercel-og><code>next/og</code> está bien también</h3>
681
682No tengo una opinión fuerte al respecto. Sólo quiero recordar a todos que existe
683el paquete `@vercel/og`.
684
685<h2#experience-feels-like-the-usual>Mi experiencia parece ser la típica</h2>
686
687En la Next.js Conf 2024, todos hablaban maravillas de los componentes de
688servidor. No recuerdo exactamente con quién fue que hablé, pero todos los
689grandes nombres estaban completamente a favor de ellos. Habiendo implementado el
690empaquetador de RSC, yo vi algunos de los problemas en el formato. Ahora, viendo
691que Next 15 "estabilizó" el enrutador de aplicaciones el año pasado, muchas
692compañías están construyendo productos con él, dándose cuenta de estos
693inconvenientes de primera mano.
694
695Llegué tarde al mundo de Next.js, pues empecé en junio con la versión 15. Pero
696todos con los que he hablado están de acuerdo con mis observaciones. Todos con
697los que hablé en la 1.3 Party de Bun estaban de acuerdo conmigo. Incluso gente
698de Vercel me ha dicho que no les gusta cómo es usar Next.js.
699
700Espero que, al estabilizarse TanStack Start, se vuelva el nuevo sustituto de
701Next.js que todos quieran.
702
703<h2#prefer-respectful-tools>Opta por herramientas que te respeten</h2>
704
705El ecosistema de JavaScript es un desastre y ese desastre es por el que la gente
706se burla del desarrollo web. Muchas veces he pensado que el desastre de trabajar
707en la web no tenía solución, pero el desastre eran en verdad las librerías
708comúnmente usadas de las que me rodeaba. Al quitar esa capa, las tecnologías de
709desarrollo web modernas son maravillosas.
710
711Llevo desarrollando esta página web desde cero, sin framework, desde finales de
7122024, implementando sistemas como mi propio [widget de progreso para
713aplicaciones de terminal][progress], [proxy para archivos
714estáticos][file-cache], sistema de compilación incremental, y muchos más
715componentes. Trabajar en este código ha llevado a las mejores sesiones de código
716(en términos de felicidad) en años. Los visitantes de *[paper clover]* se llevan
717una página web de mejor calidad; las minilibrerías que he creado las [extraigo
718para uso público][lib], todos salen ganando.
719
720Este nivel de "desde cero" es demasiado para la mayoría, especialmente en el
721trabajo. Diría que, como mínimo, deberíamos dedicar atención y dinero sólo a
722herramientas de alta calidad que nos respeten. Y Next.js y la empresa detrás de
723ello, Vercel, no caen dentro de ese criterio.
724
725Si usas Next.js y sientes que tu experiencia tampoco te recuerda al respeto,
726reflexiona si tus compañeros de trabajo y tú queréis seguir apoyando su [imperio
727serverless][serverless empire]. El ecosistema de Vite parece bastante decente
728para desarrollar ahora mismo, pero no tengo mucha experiencia todavía en usar
729sus herramientas a gran escala en producción. El [lanzamiento de Vite+ de
730Void0][vite-plus] parece interesante, pero sólo el tiempo dirá si estas
731herramientas, financiadas por capital, nos respetarán (a los usuarios y a los
732desarrolladores) a largo plazo.
733
734La Next.js Conf 2025, en el momento de escribir, es [mañana][next-conf]. En vez
735de comprarme una entrada de $800, decidí destinar ese dinero al [equipo de
736TanStack][tanstack-donate] por [respetar y mejorar el ecosistema de desarrollo
737web][tanstack-ethos].
738
739[paper clover]: https://paperclover.net/
740[lib]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib#readme
741[progress]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib/progress.ts
742[file-cache]: https://git.paperclover.net/clo/sitegen/src/branch/master/src/file-viewer/cache.ts
743
744[next-conf]: https://nextjs.org/conf
745[vite-plus]: https://viteplus.dev/
746[tanstack-ethos]: https://tanstack.com/ethos
747[tanstack-donate]: https://github.com/sponsors/tannerlinsley
748[serverless empire]: https://youtu.be/SCIfWhAheVw
749
750## Lo que aguarda el futuro
751
752Paulatinamente, he estado reemplazando mucho software que me falta al respeto
753con mejores alternativas. Algunos ejemplos son GitHub, Visual Studio Code,
754DaVinci Resolve, Discord, Google Drive/Workspace, entre muchos otros. Pienso
755escribir más en este blog sobre mis proyectos técnicos (la librería de progreso,
756el propósito de mi generador de sitio propio, aprendizajes de mi trabajo
757actual), incluyendo algunos de mis proyectos pasados en Bun (detalles sobre HMR,
758el sistema de reporte de errores y la locura de sistema para empaquetar módulos
759integrados). Si te interesa, por favor suscríbete a la lista de correos:
760
761<a href="mailto:subscribe@paperclover.net?subject=paper%20clover%20mailing%20list&body=I%20would%20like%20to%20be%20subscribed%20to%20the%20following%20mailing%20lists%3A%0A%0A-%20Technical%20Blog%20Posts%20-%20YES%0A-%20Art%20(Original%20Music%2FVideo)%20-%20YES%0A%0A(feel%20free%20to%20write%20whatever%20else%20you%20want)">haz clic aquí para enviar un correo a <code>subscribe@paperclover.net</code>, pidiendo ser añadido a la lista de correos.</a> (llevo esta lista de correos a mano)
762
763[volver al inicio](#top) &mdash; [pregúntame algo sobre este artículo](/q+a)
764
765<br />
766<br />
767<br />
768<br />
769<br />
770<br />
771<footer>
7722025 (c) paper clover
773</footer>