authorgravatar for trubiso@users.noreply.github.comTrubiso <trubiso@users.noreply.github.com> 2026-01-14 00:56:09-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-14 01:08:18-08:00
log8b330ca47d47e63d0c107691ecf75236fd112ba1
tree5f68465ca010c4fb92fd501e6c8fa8238d5d6665
parent18181e597db8b1d2a90e8676958e8fe5ce544fda
signature Commit is signed but in an unrecognized format.

feat: spanish translation for next.js blog post

Co-Authored-By: clover caruso <git@paperclover.net>

4 files changed, 881 insertions(+), 0 deletions(-)

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