He estado usando Next.js profesionalmente para el desarrollo de aplicaciones
web en mi trabajo y el diseño de su enrutador de aplicación (App Router) y los
componentes de servidor (React Server Components/RSC) me parece
extremadamente frustrante a nivel fundamental. No es por pequeños bugs o porque
la API me parezca confusa, sino por grandes discrepancias en las decisiones de
diseño fundamentales que tomaron Vercel y el equipo de React en su creación.
Cuanto más asisto a eventos de desarrollo web, más veo a gente a quien no le
gusta Next.js, pero que aún así debe usarlo. Al final del artículo, compartiré
cómo escapamos de este infierno, migrando todo nuestro frontend a TanStack
Start sin problemas.
§Un repaso técnico: ¿qué son los componentes de servidor?
El punto clave de RSC es que los componentes se dividen en dos categorías,
componentes de "servidor" y componentes de "cliente". Los componentes de servidor pueden usar ni
useState ni useEffect, pero pueden hacer uso de funciones async y emplear
herramientas de backend, permitiendo por ejemplo llamar directamente a una base
de datos. Los componentes de cliente constituyen el modelo tradicional, en el
que hay código en el backend para generar HTML y en el frontend para administrar
el DOM usando window.document.*.
Ahí va el primer desastre: ¡la nomenclatura! React usa las palabras "servidor" y "cliente" para referirse a cosas muy específicas, ignorando sus definiciones existentes. No habría ningún problema, si no fuera por que ¡los componentes de cliente también pueden correr en el backend! En este artículo, utilizaré los términos "backend" y "frontend" para describir los ámbitos de ejecución en los que existen las aplicaciones web: un proceso de Node.js y un navegador web, respectivamente.
Este modelo de componentes de servidor y de cliente es curioso. Dado que las funciones integradas, como
<Suspense />, se serializan a través de la red, la obtención de datos se puede
modelar de manera trivial con componentes de servidor
asíncronos, y la interfaz de usuario alternativa funciona como si fuera del lado
del cliente.
src/app/[username]/page.tsx
// En este artículo, los componentes de servidor están resaltados en rojo.
export default async function Page({ params }) {
// Los parámetros de la página se pasan como una Promise resuelta.
const { username } = await params;
// Los componentes `UserInfo` y `UserPostList` serán corridos a la vez. En
// cuanto `UserInfo` esté listo, el visitante verá la página con un
// `PostListSkeleton` si la lista de publicaciones no está lista.
return <main>
<UserInfo username={username} />
<Suspense fallback={<PostListSkeleton />}>
<UserPostList username={username} />
</Suspense>
</main>
}
// Evitamos waterfalls teniendo varios componentes evaluados a la vez.
async function UserInfo({ username }) {
const user = await fetchUserInfo(username);
return <>
<h1>{user.displayName}</h1>
{user.bio ? <Markdown content={user.bio} /> : ""}
</>
}
async function UserPostList({ username }) {
const posts = await fetchUserPostList(username);
return /* interfaz de usuario de la lista de publicaciones omitida por brevedad */;
}
El ejemplo anterior no usa nada de JavaScript (sin contar el tamaño del empaquetado
gzipped de 40kB del propio React) para la interfaz de usuario ni para la
obtención de datos — ¡sólo envía el HTML! Por ejemplo, el parser (analizador)
imaginario de Markdown dentro del componente <Markdown /> se queda en el
backend. En aquellos casos en los que se necesita un frontend interactivo,
simplemente se puede crear un componente de cliente
poniéndolo en un archivo empezando con "use client".
src/components/CopyButton.tsx
"use client"; // Este comentario marca el archivo para el empaquetado del lado del cliente.
export function CopyButton({ url }) {
return <>
<span>{url}</span>
<button onClick={() => {
const full = new URL(url, location.href);
navigator.clipboard.writeText(full.href);
// omitiendo todo el manejo de errores, la interfaz de éxito, los estilos...
}}>copy</button>
</>
}
src/app/q+a/Card.tsx
export function Card() {
return <article>
<header>
{/* Hace que el navegador importe el botón de copiar */}
<CopyButton url="/q+a/2506010139" />
</header>
<p>
{/* Procesa el Markdown en el backend */}
<Markdown content=".........." />
</p>
</article>
}
Tras dejar Bun como ingeniero de runtime (donde implementé el empaquetado de
componentes de servidor y una plantilla de
RSC), me uní a una pequeña compañía, trabajando en la línea de fuego:
una aplicación de Next.js con un backend de Hono. Lo siguiente es una versión
simplificada de los problemas con los que me enfrenté tratando de mantener y
desarrollar características nuevas. Como resultado de todos ellos, perdimos
todos el tiempo intentando eludir fallos de diseño o explicándonos por qué algo
que no debería ser un problema para empezar se ha convertido en un obstáculo
inamovible.
La documentación de Next.js respecto a la mutación no menciona las
actualizaciones optimistas, parece ser que no pensaron en
este caso. Los componentes renderizados por el servidor de
React no pueden ser modificados tras ser montados por diseño. Los elementos
que puedan tener cambios deben estar dentro de un componente de cliente, pero no
puede estos componentes no pueden dar lugar a obtención de datos, incluso
durante el renderizado del lado del servidor (SSR) en el backend. Esto deriva en
componentes de servidor incómodamente diminutos que sólo se encargan de obtener
ciertos datos y tienen un componente homólogo de cliente que contiene una
versión prácticamente estática de la página.
src/app/user/[username]/page.tsx
export default async function Page() {
const user = await fetchUserInfo(username);
return <ProfileLayout>
<UserProfile user={user} />
</ProfileLayout>;
}
src/app/user/[username]/UserProfile.tsx
"use client"; // Toca mover el código de cliente a otro archivo!
export function UserProfile({ user: initialUser }) {
// Existen muchas librerías de gestión de estado excelentes;
// para simplificar, usaremos una celda de estado.
const [user, optimisticUpdateUser] = useState(initialUser);
async function onEdit(newUser) {
optimisticUpdateUser(newUser);
const resp = await fetch("...", {
method: 'POST',
body: JSON.stringify(newUser),
... // (encabezados, credenciales, trazado y más)
})
if (!resp.ok) /* ¡recuerda siempre comprobar si se ha dado algún error! */
}
return <main>{/* interfaz de usuario con campos editables... */}</main>:
}
A medida que más partes de la página necesitan interactividad, se vuelve más
lioso mantener las partes estáticas puramente del lado del servidor. En la
aplicación de trabajo, casi cada componente de la interfaz de usuario muestra
datos dinámicos. Un WebSocket sincroniza los datos en tiempo real a
medida que se actualizan (por ejemplo, el estado en línea y el perfil básico de
una tarjeta de usuario). Como estas configuraciones de componentes son más
difíciles de entender y mantener para los ingenieros, casi todas nuestras
páginas están marcadas como "use client" con un page.tsx que define la
obtención de datos necesaria.
Un ejemplo más concreto de cómo queda esto en la práctica con la librería de
obtención de datos que usamos en el trabajo, TanStack Query.
src/queries/users.ts
// Tenemos una función auxiliar `defineQuery` para la seguridad de tipos en el trabajo.
// Los obtenedores de datos son triviales y pueden correr tanto en el backend como en el frontend.
export const queryUserInfo = (username) => ({
queryKey: ['user', username],
queryFn: async ({ ... }) => /* obtener datos */
});
src/app/user/[username]/page.tsx
export default async function Page({ params }) {
const { username } = await params;
// No hay estado global en el servidor de React. Como los layouts
// se ejecutan en paralelo, toca reconstruir el `QueryClient` de
// TanStack varias veces por ruta.
const queryClient = new QueryClient();
await queryClient.ensureQueryData(queryUserInfo(username));
// HydrationBoundary es un componente de cliente que pasa datos
// JSON del servidor de React al componente de cliente.
return <HydrationBoundary state={dehydrate(queryClient)}>
<ClientPage />
</HydrationBoundary>;
}
src/app/user/[username]/ClientPage.tsx
"use client";
export function ClientPage() {
const { username } = useParams();
const { data: user } = useSuspenseQuery(queryUserInfo(username));
// ... algunos hooks
return <main>
{/* ... una página web interactiva */}
</main>;
}
Este ejemplo necesita tres archivos por las reglas del empaquetado de los
componentes de servidor (el componente de cliente necesita "use client" y los
archivos de componentes de servidor no suelen poderse importar en el cliente
debido a las importaciones exclusivas de servidor). En el enrutador de páginas
(Pages Router), podría haberlo hecho todo en un solo archivo por el tree-shaking
que poseen getStaticProps y getServerSideProps.
Como el enrutador de aplicación inicia cada página como componente de servidor,
con áreas pequeñas de interactividad (idealmente), navegar a una nueva página
¡fuerza una petición al servidor de Next.js, independientemente de los datos
que el cliente ya tenga disponibles! Incluso con un archivo loading.tsx, al
abrir /, navegar a /other/ y luego volver a /`, se mostrará el estado de
carga mientras vuelve a obtener los datos de la página de inicio.
Para lo único para lo que esto funciona bien es para el contenido
perfectamente estático, donde las navegaciones instantáneas y la precarga
(prefetching) funcionan genial. Pero las aplicaciones web no son estáticas,
tienen mucho contenido dinámico. Haber iniciado sesión afecta a la página de
inicio, cosa que es irritante porque el cliente literalmente ya tiene todo lo
que necesita para mostrar la página instantáneamente. Ni siquiera han cambiado
las cookies.
nota: Probándolo más en un proyecto en blanco, he observado casos en los
que el código de frontend de Next precarga rutas sin ningún contenido
real. En el ejemplo de hello world, era una carga de 1.8kB de RSC que
apuntaba a 2 fragmentos de JS diferentes 4 veces distintas. Esto es un desperdicio
puro de nuestro ancho de banda y egreso, especialmente considerando que toda
esta información se vuelve a obtener cuando de verdad hago clic en el enlace.
1:"$Sreact.fragment"
2:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
3:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
4:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"ViewportBoundary"]
6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"MetadataBoundary"]
7:"$Sreact.suspense"
0:{"b":"TdwnOXsfOJapNex_HjHGt","f":[["children","other",["other",{"children":["__PAGE__",{}]}],["other",["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":null},[["$","div","l",{"children":"loading..."}],[],[]],false],["$","$1","h",{"children":[null,["$","$1","KCFxAJdIDH3BlYXAHsbcVv",{"children":[["$","$L4",null,{"children":"$L5"}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],["$","$L6","KCFxAJdIDH3BlYXAHsbcVm",{"children":["$","div",null,{"hidden":true,"children":["$","$7",null,{"fallback":null,"children":"$L8"}]}]}]]}],false]],"S":false}
5:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
9:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"IconMark"]
8:[["$","title","0",{"children":"Create Next App"}],["$","meta","1",{"name":"description","content":"Generated by create next app"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0b3bf435.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L9","3",{}]]
Revisándolo, me di cuenta de que en realidad hay algo de contenido: el estado
de carga. ¿Lo ves?
["$","div","l",{"children":"loading..."}]
Sigue siendo un desperdicio grande, ya que todos estos datos se vuelven a
emitir en el RSC de la propia página.
Parece ser que la solución a esto es staleTime, pero está
marcado como experimental y "no recomendado para producción". Es una vergüenza
el hecho de que esto sea una opción que no está por defecto y que parece haber
sido una idea de último momento. Incluso usándola, no es posible hacer que
varias páginas que hacen referencia a los mismos datos los compartan.
Un ejemplo de un estado de carga irrepresentable con el enrutador de aplicación
es el de ciertas páginas, como una página de propuestas (issues) en un proyecto
de git, al hacer clic en un nombre de usuario para ir a su página de perfil. Con
loading.tsx, la página entera es un esqueleto, pero al modelar estas consultas
con TanStack Query es posible mostrar el nombre de usuario y avatar
instantáneamente mientras cargan la biografía del usuario y sus repositorios.
Los componentes de servidor son incompatibles con esta tipo de navegación porque
los datos sólo están disponibles en componentes renderizados, con lo cual deben
ser obtenidos una vez más.
En nuestro sitio Next.js, tenemos esta línea de código en nuestros obtenedores
de datos en los componentes de servidor para hacer que las navegaciones suaves
vayan más rápido, saltándose la fase de obtención de datos por completo.
src/util/tanstack-query-helpers.server.ts
export function serverSidePrefetchQueries(queries) {
if ((await headers()).get("next-url")) {
// Se trata de una navegación suave. Nos SALTAMOS la precarga para
// aumentar la velocidad. Puede que el cliente ya tenga estos datos y,
// de lo contrario, tienen el estado de carga. Idealmente, no existiría
// esta petición -- el lado de cliente ya tiene casi TODO el código ya
// que la aplicación está escrita principalmente usando componentes de
// cliente. Un fallo de diseño de parte del enrutador de aplicación, la
// verdad.
return;
}
// ... lógica de precarga de datos ...
}
Aparte, loading.tsx debería contener las llamadas a useQuery para que,
mientras se realiza la petición para el RSC vacío, se obtengan los datos si de
verdad se necesitan. De hecho, el estado loading.tsx puede ser directamente el
componente de cliente y se mostrará la página de cliente.
En el trabajo, simplemente hacemos que nuestros archivos loading.tsx
contengan las llamadas a useQuery y muestren un esqueleto. Esto se debe a
que cuando Next.js carga el componente de servidor en sí, remonta la página
entera sí o sí. No se da ningún tipo de VDOM diffing, así que todos los hooks
(useState) se reiniciarán un poco después de que la petición se complete.
Intenté reproducir un caso simple en el que estaba suplicándole a Next.js
que tan solo actualizara el DOM existente y preservara el estado, pero
directamente no lo hace. Por suerte, el tiempo que tarda la llamada RSC en
blanco es suficientemente corto.
Los layouts 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
fetchmonkey-patched para cachear la misma petición GET
tal y como prometen.
Cuando me pregunta un compañero de trabajo por qué Next.js rechaza código, ya ni
intento explicar los entresijos, directamente digo "Es un problema de habilidad
de Next.js, no te preocupes, que le voy a dar fuego pronto". Estas reglas son
demasiado difíciles para que un desarrollador promedio las entienda.
§Te llevas todo el contenido dos veces de todas formas
A diferencia de la "arquitectura de islas", los componentes de
servidor deben ser hidratados en el frontend para poder emplear Suspense y
mantener el estado de los componentes de cliente. Al hacer navegaciones suaves,
se obtiene mediante fetch la "carga de RSC" (que no es HTML en absoluto).
Al hacer una recarga fresca de la página, se necesita HTML para
pintar por primera vez, pero la información sobre los componentes
de cliente y Suspense no está en dicho HTML. La solución de React es enviar
una segunda copia del markup de la página entera. Un ejemplo de lo que
enviaría un servidor de producción de Next.js en un renderizado dinámico de una
página sería algo así:
GET /user/clover
<!DOCTYPE html>
<html>
<head>
{etiquetas link y meta}
</head>
<body>
{renderizado del lado del servidor}
<script>
// un script de inicialización que inicia `__next_f`
// como un arreglo. en cuanto carga React, la función
// `.push` se modifica para escribir nuevos fragmentos
// al decodificador de RSC directamente. tiene algunos
// auxiliares de DOM también
(self.__next_f=self.__next_f||[]).push([0])
</script>
<script>
// la carga de RSC para el shell de la aplicación.
self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[658993,[\"/_next/st{...}"])
</script>
<!--
NO está escrita todavía la etiqueta </body>, ya que hay una
frontera de Suspense sin resolver. con el paso del tiempo,
se escriben más datos.
-->
<div class="user-post-list">
{renderizado del lado del servidor de una frontera de Suspense}
</div>
<script>
// la carga de RSC para la frontera de Suspense
self.__next_f.push([2,"14:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h4\"{...}"])
</script>
<!-- se repiten etiquetas HTML y script hasta que la página acaba -->
</body>
</html>
Esta solución duplica el tamaño de la carga inicial de HTML. Aún peor, la
carga de RSC incluye JSON en cadenas de texto de JS, un formato mucho menos
eficiente que HTML. Aunque parece comprimirse bien con brotli y renderizarse
rápido en el navegador, esto es un desperdicio. Con el patrón de hidratación,
los datos se podrían reutilizar localmente, por lo menos, para la interactividad
y otras páginas.
Incluso en páginas con ínfima interactividad, pagas el precio. Como ejemplo, la
documentación de Next.js, al cargar su página de
inicio, carga una página de alrededor de 750kB (250kB
de HTML y 500kB de etiquetas script), y el contenido aparece dos veces.
Puedes comprobarlo pulsando Cmd + Opt + u en
Mac o Ctrl + u en otras plataformas y después
Cmd / Ctrl + f para ubicar cualquier cadena
en el blog, como "construyendo aplicaciones web full-stack". Aparece dos veces.
Y es inevitable, ya que es una parte fundamental de los componentes de
servidor de React.
El formato de RSC definitivamente tiene más fallas, pero no tengo ganas de
ponerme a investigar por qué la cadena /_next/static/chunks/6192a3719cda7dcc.js
aparece 27 veces. ¿Qué diablos? ¿¿¿Dais el ancho de banda por gratis???
Turbopack 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.
Otro ejemplo de un error horrible:
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.
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.
Todo empezó con esta configuración de Vite.
vite.config.ts
const config = defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "NEXT_PUBLIC_");
return {
// Usa el puerto predeterminado de Next.js, 3000
server: { port: 3000 },
// Usa el prefijo de variables de entorno predeterminado de Next.js, "NEXT_PUBLIC_"
define: Object.fromEntries(Object.entries(env).map(
([k, v]) => [`process.env.${k}`, JSON.stringify(v)])),
plugins: [
viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
tailwindcss(),
// Para que mis compañeros de trabajo lo entendieran bien, empecé
// a portar las rutas en `src/tanstack-routes`; en cuanto acabara,
// volvería a cambiarlo a `src/routes`, como estaba por defecto.
tanstackStart({
router: { routesDirectory: "src/tanstack-routes" },
}),
viteReact(),
],
resolve: {
// La clave para la migración incremental: redirigir `next` a otro lugar
alias: { next: path.resolve("./src/tanstack-next/") },
conditions: ["tanstack"],
extensions: [
// Permitir que un archivo como `utils/session.tanstack.ts`
// sobreescriba a `utils/session.ts` al ser importado.
".tanstack.tsx", ".tanstack.ts",
// Extensiones de importación predeterminadas
".mjs", ".js", ".mts", ".ts",
".jsx", ".tsx", ".json",
],
},
};
});
Después, me puse a buscar cada uso de una API de Next.js, para o eliminarlo o
crear un talón (stub) para TanStack. Por ejemplo, src/tanstack-next/link.tsx
implementa next/link:
src/tanstack-next/link.tsx
import { Link } from "@tanstack/react-router";
import type { LinkProps } from "next/link";
export default function LinkAdapter({ href, ...rest }: LinkProps) {
return <Link {...rest} to={href as unknown as any} />;
}
Algunos de estos talones pueden ser extremadamente simples. Al principio, mi
implementación de useRouter era return {}. Más tarde, ya tuve que añadir
algunos métodos al objeto. El código no tiene por qué ser limpio, ya que es
temporal.
De ahí, el sitio nuevo puede importar casi cada componente de cliente o creando
talones para las APIs de Next.js que necesite o usando la extensión .tanstack.ts
para reimplementar la lógica archivo por archivo. Poco después, conseguí que
la página principal del sitio funcionara en TanStack Start e hicimos merge.
En esta primera PR (solicitud de incorporación de cambios) sólo funcionaba una
de nuestras páginas, y conseguí que funcionara con unas mil líneas de código
añadido y 40 líneas eliminadas. Algunos parches previos eliminaban los pocos
usos de next/image y next/font.
Lo que quedaba era portar el resto de rutas. Lo único que perdemos al migrar de
Next.js a cualquier otro framework es el poder usar await con funciones de
obtención de datos en la interfaz de usuario. En la práctica, mover cada ruta
a una función loader esclareció qué era lo que pasaba al renderizar cada
página desde el servidor (SSR). Para páginas con varias llamadas de obtención de
datos, estas podían combinarse en una llamada API especial que devolviera todos
los datos relevantes a ellas.
Para reiterar, en negrita: El camino de
migración de componentes de servidor es simplificar tu código — RSC
intrínsecamente te lleva por un camino caótico repleto de cosas
innecesarias. Casi todas las partes complejas de nuestro sitio se
volvieron más fáciles de entender para todos nuestros ingenieros. La única
excepción fue acostumbrar a todos a las nuevas convenciones para el enrutado
del sistema de archivos. Con suficientes ejemplos, todos lo acabamos pillando.
Con la migración incremental ya operativa, no se rompía el deployment existente
al añadir código nuevo. TanStack fue apoderándose del código y, con el tiempo,
fuimos eliminando todos los talones de Next.js y ganamos todas las maravillosas
características de seguridad de tipos que ofrece el
enrutador de TanStack (TanStack Router). Al final, el sitio rendía más rápido en
todos los aspectos: el modo de desarrollo, tiempos de carga en producción,
navegaciones suaves, y todo a un menor coste que nuestro deployment de Next con
Vercel.
No somos los únicos sintiendo el cambio. Aunque intento evitar las redes
sociales, alguien me envió los resultados del trabajo de Brian Anglin en
Superwall, mostrando reducciones de CPU increíbles usando
TanStack Start. También recuerdo el cambio de ChatGPT de Next.js a Remix hace un
año (conversaciones relevantes: [1] [2] [3]).
Bajo mi punto de vista, esta es una de las pocas APIs buenas que tiene Next.js,
y fue el único lugar en nuestro código en el que el cambio a TanStack resultó en
mayor dificultad. En vez de empeorar el código, porté su API de metadatos a una
función normal y corriente, para que todos la pudieran usar. Solía tener un
puerto 1:1 en NPM, pero a comienzos del año simplifiqué su API a un archivo
pequeño y comprensible. En el momento de escribir esta publicación, he añadido
una API meta.toTags compatible con TanStack que puede instalarse desde
JSR o NPM, o simplemente la puedes copiar a tu proyecto.
aviso: Por limitaciones de tiempo para escribir este artículo, la librería
todavía no está actualizada. Probablemente lo haré ~~para el final de esta
semana (24 de octubre)~~ pronto... Por ahora, puedo compartir la versión que
uso en el trabajo:
meta.tanstack.ts.
// una vez en tu proyecto
import * as meta from "@clo/lib/meta.ts";
export const defineHead = meta.toTags.bind(null, {
// opciones para todo el sitio
base: new URL("https://paperclover.net"),
titleTemplate: (title) => [title, "paper clover"]
.filter(Boolean).join(' | '),
// ...
});
// para cada página...
export const Route = createFileRoute("/blog")({
head: () =>
defineHead({
title: "blog de clover", // usando la plantilla `titleTemplate`
description: "una gatita maúlla sobre sus opiniones tecnológicas",
canonical: "/blog", // yuxtapuesto a `base`
// Cuando se especifica, configura el embed (embebido)
// de Open Graph y Twitter, usando el título y la
// descripción de la página por defecto.
// La configuración predeterminada está bien,
// pero se pueden especificar más opciones.
embed: {
image: "/img/blog.webp",
},
// Todas las etiquetas meta exóticas se hacen con un fragmento
// JSX. No se renderiza React, sólo se itera sobre las etiquetas.
// Mi objetivo era cubrir el 99% de los casos comunes.
extra: <>
<meta name="site-verification" content="waffles" />,
</>,
}),
component: Page,
});
function Page() {
...
}
Mi versión no se preocupaba con cubrir todas las posibilidades del objeto de
metadatos de Next.js; usa JSX en línea (inline) para llenar ese hueco.
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.
El ecosistema de JavaScript es un desastre y ese desastre es por el que la gente
se burla del desarrollo web. Muchas veces he pensado que el desastre de trabajar
en la web no tenía solución, pero el desastre eran en verdad las librerías
comúnmente usadas de las que me rodeaba. Al quitar esa capa, las tecnologías de
desarrollo web modernas son maravillosas.
Llevo desarrollando esta página web desde cero, sin framework, desde finales de
2024, implementando sistemas como mi propio widget de progreso para
aplicaciones de terminal, proxy para archivos
estáticos, sistema de compilación incremental, y muchos más
componentes. Trabajar en este código ha llevado a las mejores sesiones de código
(en términos de felicidad) en años. Los visitantes de paper clover se llevan
una página web de mejor calidad; las minilibrerías que he creado las extraigo
para uso público, todos salen ganando.
Este nivel de "desde cero" es demasiado para la mayoría, especialmente en el
trabajo. Diría que, como mínimo, deberíamos dedicar atención y dinero sólo a
herramientas de alta calidad que nos respeten. Y Next.js y la empresa detrás de
ello, Vercel, no caen dentro de ese criterio.
Si usas Next.js y sientes que tu experiencia tampoco te recuerda al respeto,
reflexiona si tus compañeros de trabajo y tú queréis seguir apoyando su imperio
serverless. El ecosistema de Vite parece bastante decente
para desarrollar ahora mismo, pero no tengo mucha experiencia todavía en usar
sus herramientas a gran escala en producción. El lanzamiento de Vite+ de
Void0 parece interesante, pero sólo el tiempo dirá si estas
herramientas, financiadas por capital, nos respetarán (a los usuarios y a los
desarrolladores) a largo plazo.
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: