Tag: React

  • Fetching de Datos en Paralelo en Next.js con Suspense

    Fetching de Datos en Paralelo en Next.js con Suspense

    Hace unos meses audité el dashboard de un SaaS construido con el App Router de Next.js. El usuario iniciaba sesión y la pantalla tardaba 4,2 segundos en mostrar el primer píxel interactivo.

    El equipo pensaba que el cuello de botella estaba en los índices de PostgreSQL o en la memoria de la máquina en Vercel.

    Abrí el archivo page.tsx del dashboard. Había cuatro llamadas con await consecutivas:

    // ❌ El clásico waterfall en Server Components
    const user = await getUser();
    const stats = await getStats(user.id);
    const notifications = await getNotifications(user.id);
    const recommendations = await getExternalRecommendations();
    

    Cada petición esperaba a que la anterior terminara: 400ms + 1.200ms + 600ms + 2.000ms. Un waterfall secuencial de 4,2 segundos. Y peor aún: si el microservicio de recomendaciones caía con un error 504, toda la página devolvía un error 500 al cliente.

    Next.js te ofrece React Server Components por defecto, pero si no estructuras el fetching de datos en paralelo en Next.js, conviertes tu servidor en una fila india de bloqueos innecesarios.

    Aquí te muestro la arquitectura en tres capas para paralelizar llamadas, tolerar caídas de microservicios y hacer streaming instantáneo hacia el navegador.


    1. El antipatrón del Waterfall secuencial

    Cuando colocas llamadas asíncronas consecutivas en el cuerpo de una función de Server Component, la ejecución en Node.js se detiene en cada línea.

    TIEMPO (ms)  0ms       400ms                1600ms          2200ms                    4200ms
                 ├─────────┼────────────────────┼───────────────┼─────────────────────────┤
    Llamada 1:   [getUser]
    Llamada 2:             [getStats]
    Llamada 3:                                  [getNotifs]
    Llamada 4:                                                  [getRecommendations]
                                                                                          ▲
                                                                               Primer render (4.2s)
    

    Las peticiones no arrancan a la vez; arrancan en cascada. Salvo que una llamada requiera obligatoriamente el resultado de la anterior para construir su consulta, ejecutar esto en serie es desperdiciar los hilos de red del servidor.


    2. Nivel 1: fetching de datos en paralelo con Promise.all

    Si necesitas varios bloques de datos indispensables para renderizar la vista y ninguno depende de otro, la primera optimización es disparar todas las promesas al mismo tiempo con Promise.all:

    // app/dashboard/page.tsx
    interface DashboardData {
      user: User;
      stats: UserStats;
      notifications: Notification[];
    }
    
    export default async function DashboardPage() {
      // Inicia todas las promesas en paralelo
      const [user, stats, notifications] = await Promise.all([
        getUser(),
        getStats(),
        getNotifications(),
      ]);
    
      return (
        <main className="p-6">
          <UserProfile user={user} />
          <StatsOverview stats={stats} />
          <NotificationList items={notifications} />
        </main>
      );
    }
    

    La ganancia:

    El tiempo total de espera ya no es la suma de todas las llamadas, sino el tiempo de la más lenta. Si la más lenta tarda 1.200ms, la página resuelve en 1.200ms en lugar de 2.200ms.

    TIEMPO (ms)  0ms             1200ms
                 ├───────────────┤
    getUser:     [==== 400ms ====]
    getStats:    [======== 1200ms =======]
    getNotifs:   [====== 600ms ======]
                                 ▲
                      Resuelve en paralelo (1.2s)
    

    La trampa de Promise.all:

    Promise.all tiene comportamiento de rechazo rápido (fail-fast). Si dos promesas resuelven con éxito pero una falla, toda la llamada lanza una excepción. Úsalo exclusivamente para datos que son 100% obligatorios para la vista.


    3. Nivel 2: Promise.allSettled para tolerancia a fallos

    ¿Qué ocurre cuando una página incluye datos secundarios, como recomendaciones de productos, widgets del clima o analíticas de terceros?

    No tiene sentido romper el perfil del usuario porque una API externa esté caída. Para peticiones secundarias o no bloqueantes, la solución nativa es Promise.allSettled:

    // app/dashboard/page.tsx
    export default async function DashboardPage() {
      const [profileResult, recommendationsResult] = await Promise.allSettled([
        getUserProfile(),
        getThirdPartyRecommendations(),
      ]);
    
      // Si el perfil falla, cortamos porque es crítico
      if (profileResult.status === "rejected") {
        throw new Error("No se pudo cargar el perfil del usuario.");
      }
    
      const profile = profileResult.value;
    
      // Si las recomendaciones fallan, degradamos elegantemente sin romper la UI
      const recommendations =
        recommendationsResult.status === "fulfilled"
          ? recommendationsResult.value
          : [];
    
      return (
        <section>
          <UserProfile user={profile} />
          {recommendations.length > 0 ? (
            <RecommendationCarousel items={recommendations} />
          ) : (
            <p className="text-sm text-gray-500">Recomendaciones no disponibles hoy.</p>
          )}
        </section>
      );
    }
    

    Promise.allSettled garantiza que Node.js esperará a que todas las promesas finalicen, devolviendo un objeto con { status: 'fulfilled', value } o { status: 'rejected', reason }. Tu interfaz resiste caídas parciales sin tirar el servidor.


    4. Nivel 3: React <Suspense> y Streaming para pulverizar el TTFB

    Incluso con Promise.all, si un componente tarda 2,5 segundos, el usuario mirará una pantalla en blanco durante 2,5 segundos antes de ver el primer byte HTML.

    Con React Suspense y Streaming en Next.js, desacoplas la carga de la página del componente más lento.

    La regla de oro: mueve el await dentro del componente que realmente consume los datos.

    // app/dashboard/page.tsx
    import { Suspense } from "react";
    import { UserHeader } from "./components/UserHeader";
    import { HeavyAnalyticsWidget } from "./components/HeavyAnalyticsWidget";
    import { SkeletonWidget } from "./components/SkeletonWidget";
    
    export default function DashboardPage() {
      return (
        <div className="space-y-6">
          {/* Carga inmediata (rápido) */}
          <Suspense fallback={<p>Cargando cabecera...</p>}>
            <UserHeader />
          </Suspense>
    
          {/* Widget pesado: no bloquea el resto de la página */}
          <Suspense fallback={<SkeletonWidget />}>
            <HeavyAnalyticsWidget />
          </Suspense>
        </div>
      );
    }
    
    // app/dashboard/components/HeavyAnalyticsWidget.tsx
    // Este Server Component hace su propio fetching asíncrono
    export async function HeavyAnalyticsWidget() {
      const data = await getHeavyMetrics(); // Tarda 2.5s
      return <MetricsChart data={data} />;
    }
    

    Qué experimenta el usuario:

    1. En 80 milisegundos, el servidor envía la estructura HTML principal, el menú, la cabecera y el skeleton del gráfico.
    2. La página es interactiva de inmediato.
    3. A los 2,5 segundos, Next.js envía por streaming el fragmento HTML del gráfico y React lo reemplaza en el DOM sin recargar la página.

    Si este patrón te suena a fricción con Server Components mal migrados, cubrimos los fallos más comunes en Errores comunes al migrar a React Server Components en producción.

    Esta mentalidad de arquitectura modular y control de estados asíncronos es exactamente lo que desarrollamos en el curso Construye con IA: De la Idea al Producto con Claude y Specs, donde conectamos interfaces modernas en Next.js con servicios backend de alta concurrencia.


    Comparativa: ¿Cuándo usar cada técnica?

    Escenario Técnica recomendada Beneficio clave
    Múltiples datos obligatorios para el layout principal Promise.all Máxima velocidad paralela (tiempo = el de la llamada más lenta)
    Widgets externos o servicios propensos a timeouts Promise.allSettled Resiliencia y degradación elegante
    Componentes lentos o dashboards con múltiples secciones React <Suspense> + Streaming TTFB mínimo y UX instantánea
    Datos con dependencias en cadena (A depende de B) await secuencial estricto Integridad en la cadena de datos

    Arquitectura de fetching de datos en paralelo en producción

    El patrón profesional más sólido en aplicaciones Next.js de gran escala combina los tres enfoques:

    1. El layout principal y la vista estructural no bloquean con await globales innecesarios.
    2. Cada bloque funcional vive en su propio Server Component envuelto en <Suspense>.
    3. Dentro de cada bloque funcional, si se requieren múltiples recursos, se utiliza Promise.all (si son obligatorios) o Promise.allSettled (si toleran fallo).

    Si además necesitas exprimir memoria y builds en producción, ya cubrimos ese terreno en Optimización extrema de rendimiento y consumo de memoria en Next.js 16.

    Para profundizar en optimizaciones avanzadas de memoria, caching y patrones de Server Components en producción, en Dominicode Labs revisamos arquitecturas reales y analizamos benchmarks de rendimiento semana a semana.


    Preguntas frecuentes

    ¿Promise.all en Next.js Server Components ejecuta en el cliente o en el servidor?

    En Server Components (archivos sin 'use client'), Promise.all se ejecuta íntegramente en el entorno de Node.js o Edge del servidor antes de generar o transmitir el HTML al cliente.

    ¿Qué diferencia hay entre Promise.all y Promise.allSettled?

    Promise.all rechaza inmediatamente si cualquiera de las promesas falla (comportamiento todo o nada), mientras que Promise.allSettled espera a que todas concluyan y entrega el estado individual (fulfilled o rejected) de cada una.

    ¿Suspense sustituye por completo a Promise.all?

    No. Se complementan. <Suspense> gestiona el streaming y la interfaz de carga progresiva entre diferentes componentes, mientras que Promise.all gestiona la concurrencia de datos dentro de un mismo componente.

    ¿El uso de Suspense afecta al SEO en Google?

    No. Los rastreadores web modernos de Google esperan la resolución del stream de Server Components y reciben el HTML final renderizado con el contenido completo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Next.js 16.3: el 90% menos de memoria es real, pero no es tuyo

    Next.js 16.3: el 90% menos de memoria es real, pero no es tuyo

    El portátil empezó a hacer ese ruido. El del ventilador que ya no refrigera, solo pide ayuda.

    Dos horas con next dev abierto, saltando entre rutas de un checkout. 14 GB de RAM. Un servidor de desarrollo comiéndose catorce gigas.

    Maté el proceso. Lo levanté. A los diez minutos iba otra vez por seis y subiendo.

    Ese ha sido el peaje de trabajar en local durante años: cuanto más rato llevas, peor va todo, y el arreglo es reiniciar.

    Next.js 16.3 llegó estable el 3 de agosto de 2026 apuntando justo ahí. El titular que circula desde entonces: «90% menos memoria y builds 5,5× más rápidos».

    Las dos cifras son ciertas. Y las dos son el mejor caso medido por Vercel en sus propias aplicaciones.

    La release es buena de verdad y no necesita ese titular. Lo mejor de 16.3 no es el número grande: es cuánto llega activado por defecto, sin que toques una línea de tu código.

    Qué trae Next.js 16.3 de un vistazo

    Novedad ¿Por defecto? Qué aporta
    Memory eviction en Turbopack Hasta 90% menos RAM en next dev (mejor caso medido)
    Caché en disco en next build Compilación de Turbopack 1,4× a 5,5× más rápida
    SSR con streams nativos de Node Hasta 22% más peticiones bajo carga
    Agrupado de prefetches pequeños Menos peticiones por navegación
    Type checking con TypeScript 7 Solo subir la dependencia Compilador nativo en next build
    Docs versionadas en AGENTS.md El agente lee la doc de tu versión instalada
    React Compiler en Rust No — experimental 34% más rápido en frío, 46% en caliente
    Cache Components y Partial Prefetching No — opt-in Navegación instantánea

    De dónde sale el 90% menos de memoria en Turbopack

    El memory eviction es la capacidad de Turbopack de liberar de la RAM las partes del grafo de módulos que no está usando y recuperarlas del disco cuando vuelven a hacer falta. Eso es lo nuevo de 16.3.

    Funciona junto con la caché en disco para desarrollo, que llegó en 16.1. Por eso las dos van de la mano: sin caché en disco, evictar sería volver a compilar desde cero.

    Estas son las mediciones oficiales, tomadas después de compilar 50 rutas:

    Aplicación Antes Con 16.3 Reducción
    vercel.com (dashboard) 21,5 GB 2 GB ~90%
    nextjs.org 4.600 MB 840 MB ~82%

    Fíjate en la aplicación grande: partir de 21,5 GB solo es posible en una máquina de 32 o 64 GB. La mayoría de proyectos no llegan ahí ni queriendo.

    Y esto es lo que dice el propio equipo de Turbopack en su post de la release, y que casi nunca sobrevive al resumen:

    No existe un único porcentaje de reducción aplicable a todas las aplicaciones. Los resultados individuales dependen del tamaño del grafo de rutas, de cuánto se haya recorrido durante la sesión de desarrollo y de cuánto tiempo llevara la sesión ejecutándose.

    Traducido a tu día a día: si tu proyecto tiene 12 rutas y reinicias el servidor cada media hora, no vas a ver un 90%. Vas a ver una mejora modesta, porque nunca llegaste a acumular la basura que el eviction limpia.

    El 90% lo notan los monorepos con cientos de rutas y las sesiones de ocho horas sin reiniciar. Que, siendo justos, es exactamente donde dolía.

    Si algo se rompe raro, tienes la salida:

    // next.config.ts
    import type { NextConfig } from 'next'
    
    const nextConfig: NextConfig = {
      experimental: {
        // el valor por defecto es 'full'
        turbopackMemoryEviction: false,
      },
    }
    
    export default nextConfig
    

    Por qué el next build 5,5× más rápido es el mejor de tres casos medidos

    El 5,5× no es la mejora media de la release: es la mejor de las tres aplicaciones que Vercel midió.

    La caché en disco que aceleraba next dev desde 16.1 ahora funciona también en next build. Y en la versión estable viene activada por defecto — ojo si leíste el post del preview de junio, donde todavía era el flag opt-in turbopackFileSystemCacheForBuild. Cambió al estabilizar.

    Estos son los tiempos de compilación de Turbopack dentro de next build, de frío a con caché:

    Aplicación Build en frío Con caché Mejora
    nextjs.org 21 s 9,2 s ~2,3×
    vercel.com/home 66 s 46 s ~1,4×
    vercel.com/geist 30 s 5,5 s ~5,5×

    El 5,5× existe. Es la última fila. También existe el 1,4×, que es la aplicación más parecida a un proyecto real con integraciones, y es la que menos se cita.

    El rango honesto de esta release es 1,4× a 5,5×, y dónde caigas tú depende de cuánto de tu grafo cambie entre build y build. Si tocas un archivo compartido que arrastra media aplicación, la caché te sirve de poco. Si tocas una página hoja, te sirve muchísimo.

    Dos detalles antes de que alguien te enseñe la tabla en una reunión.

    La cifra es el tiempo de compilación de Turbopack, no el next build completo: sigues teniendo type checking, generación de páginas estáticas y el resto del pipeline por delante.

    Y la caché no existe en el primer build. Necesitas una ejecución previa. En local eso pasa solo. En CI, no.

    En CI la caché no aparece por arte de magia

    Cada job arranca en un contenedor limpio. Si no persistes nada, siempre estás midiendo el build en frío y esta mejora no la ves jamás.

    Lo que hay que persistir es .next/cache, que es donde Turbopack escribe su caché de build. Esta es la configuración que da la documentación oficial de CI build caching para GitHub Actions:

    - uses: actions/cache@v4
      with:
        path: |
          ~/.npm
          ${{ github.workspace }}/.next/cache
        # Genera caché nueva cuando cambian dependencias o fuentes
        key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
        # Si cambió el código pero no las dependencias, reconstruye desde una caché previa
        restore-keys: |
          ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
    

    restore-keys es la línea que casi todo el mundo se deja. Sin ella solo recuperas la caché cuando la clave coincide exacta, y como la clave incluye el hash de tus fuentes, eso no pasa nunca en un commit nuevo: siempre medirías builds en frío.

    Y no caches .next entero. Ahí vive también la salida del build, que next build regenera igualmente, así que solo consigues subir y bajar cientos de megas por job y comerte antes la cuota de caché del repositorio — que expulsa entradas viejas cuando se llena. Acabas perdiendo justo la caché que querías conservar.

    El React Compiler en Rust es experimental, y la letra pequeña importa

    Han reescrito el React Compiler en Rust y lo han integrado en Turbopack. Va detrás de dos flags:

    // next.config.ts
    const nextConfig: NextConfig = {
      reactCompiler: true,
      experimental: {
        turbopackRustReactCompiler: true,
      },
    }
    

    Contra la aplicación de v0 midieron un 34% más rápido en frío y un 46% en caliente. Dos precisiones antes de que lo actives.

    La métrica es el tiempo desde que lanzas next dev hasta que la página está lista, no «tiempos de build de página». Es arranque de desarrollo, no producción.

    Y el post oficial avisa: esas ganancias asumen que has abandonado Babel por completo. Si sigues ejecutando Babel para otras transformaciones, el compilador en Rust ayuda, pero la ganancia es menor.

    Si todavía arrastras un .babelrc para i18n o para decoradores, el 46% no es tuyo. La ganancia real no es Rust: es salir de Babel. Rust solo hace que salir de Babel merezca todavía más la pena.

    Es experimental. Yo lo activaría en una rama, mediría y decidiría. No en el pipeline del viernes.

    Lo que mejora en Next.js 16.3 sin que hagas absolutamente nada

    Tres mejoras no piden ni un flag ni una línea de código. Es la parte que más me gusta de la release, y la menos vistosa.

    Type checking con TypeScript 7. next build ya soporta el compilador nativo y solo tienes que subir la dependencia con pnpm add -D typescript@^7. Sobre por qué esto cambia tanto los tiempos escribí en TypeScript 7 y el compilador en Go.

    SSR más rápido. Han sustituido las web streams por streams nativos de Node en la capa de render del App Router. Resultado: hasta un 22% más de peticiones bajo carga, cero cambios en tu código, menos overhead por request en la capa que ya usas si trabajas con React Server Components en producción.

    Documentación versionada para agentes de IA. next dev escribe y mantiene un bloque en tu AGENTS.md que apunta a los docs del node_modules del propio proyecto. Tu agente deja de inventarse APIs de la versión equivocada porque lee la documentación de la versión que tienes instalada. Vercel ha retirado sus Skills anteriores: para esto ya no hacen falta.

    Y esto importa más de lo que parece. Cuando un agente te genera código Next.js que no compila, muchas veces no es el modelo: es que aprendió de tutoriales de tres versiones atrás. Anclar el contexto a la versión instalada es la misma disciplina que aplico en Construye con IA: el agente no necesita más inteligencia, necesita mejor contexto.

    Y una cuarta que también llega sola: los prefetch por debajo de cierto tamaño se agrupan, así que tu app hace menos peticiones sin que cambies nada.

    Lo demás que trae 16.3 son APIs nuevas que sí tienes que escribir tú: catchError para error boundaries que ya no interfieren con notFound ni redirect, import.meta.glob al estilo Vite (solo con Turbopack) y root params con import { lang } from 'next/root-params' para dejar de pasar el idioma por props. Reutilizar assets estáticos inmutables entre despliegues también es opt-in, no automático.

    La otra mitad de la release

    Todo lo de arriba llega solo con actualizar. La otra mitad de 16.3 no: hay que activarla a mano y decidir dónde.

    Hablo de Cache Components, Partial Prefetching, el Navigation Inspector y el helper instant() de Playwright. Se activa con dos flags:

    // next.config.ts
    const nextConfig: NextConfig = {
      cacheComponents: true,
      partialPrefetching: true,
    }
    

    Lo cubrí cuando la versión estaba en preview, en cómo conseguir navegaciones instantáneas con Cache Components. Ese es el siguiente paso.

    Qué hacer hoy

    Actualizar es un minor sin cambios de API:

    npm install next@latest
    

    Pero antes, haz lo que casi nadie hace: mide.

    Anota cuánta RAM consume tu next dev tras una hora de trabajo normal y cuánto tarda tu next build en CI. Dos números en una nota. Actualiza, trabaja una semana y vuelve a mirarlos.

    El debate no es si el 90% es real — lo es, en el dashboard de Vercel. El debate es cuánto es en tu proyecto. Y esa cifra no la tiene el blog oficial: la tienes tú, y solo si la mediste antes.

    Escribir lo que esperas antes de ejecutarlo y contrastarlo después es lo mismo que defiendo en el libro de Spec-Driven Development: sirve igual para una feature que para actualizar un framework. Y si quieres contrastar números con gente que está actualizando esta misma semana, esas conversaciones están en Dominicode Labs.

    Preguntas frecuentes

    ¿De verdad Next.js 16.3 usa un 90% menos de memoria?

    En el mejor caso medido, sí: el dashboard de vercel.com pasó de 21,5 GB a 2 GB tras compilar 50 rutas. En nextjs.org la reducción fue del 82%, de 4.600 MB a 840 MB. El equipo de Turbopack advierte de que no existe un porcentaje único aplicable a todas las aplicaciones, porque depende del tamaño del grafo de rutas, de cuánto se recorra durante la sesión y de cuánto tiempo lleve el servidor levantado. Proyectos pequeños con reinicios frecuentes verán mejoras mucho menores.

    ¿Tengo que cambiar código para aprovechar Next.js 16.3?

    No para la mayor parte. El memory eviction, la caché en disco en next build, los streams nativos de Node en SSR y el agrupado de prefetches vienen activados por defecto. TypeScript 7 requiere únicamente subir la dependencia. Solo son opt-in el React Compiler en Rust, que además es experimental, y las features de navegación instantánea como Cache Components.

    ¿Actualizar a Next.js 16.3 rompe algo?

    Es una versión minor y no trae cambios de API que obliguen a tocar tu código. Lo que sí cambia es el comportamiento en tiempo de ejecución, porque el memory eviction y la caché de build llegan activados por defecto. Si tras actualizar ves recompilaciones inesperadas o rarezas en el HMR, el escape es experimental.turbopackMemoryEviction: false, y luego reportarlo.

    ¿El build 5,5× más rápido aplica también al primer build?

    No. La cifra compara un build en frío contra uno posterior que reutiliza la caché en disco, así que necesitas una ejecución previa. El rango real medido por Vercel va de 1,4× en vercel.com/home a 5,5× en vercel.com/geist, y corresponde al tiempo de compilación de Turbopack, no al next build completo con type checking y generación de páginas.

    ¿Cómo aprovecho la caché de build en CI?

    Persistiendo .next/cache entre ejecuciones, que es donde Turbopack guarda su caché de build. En GitHub Actions se hace con actions/cache apuntando a ~/.npm y a ${{ github.workspace }}/.next/cache, con una key que incluya el hash del lockfile y de tus fuentes, y restore-keys con el prefijo del lockfile para poder reconstruir desde una caché previa. No caches .next entero: el resto del directorio lo regenera next build de todas formas y solo te come cuota.

    ¿Y si mi proyecto sigue compilando con webpack?

    Las dos mejoras del titular son de Turbopack: el memory eviction y la caché en disco para next build no existen fuera de él. Lo que sí obtienes sin depender del bundler son los streams nativos de Node en SSR y el type checking con TypeScript 7, porque ocurren en la capa de render y en el paso de tipos, no en el empaquetado. import.meta.glob tampoco funciona fuera de Turbopack.

    ¿Merece la pena activar el React Compiler en Rust?

    Depende de si sigues usando Babel. Medido contra la aplicación de v0 es un 34% más rápido en frío y un 46% en caliente —la métrica es el tiempo desde next dev hasta tener la página lista, no un build de producción—, pero el post oficial aclara que esas ganancias asumen haber abandonado Babel por completo; si lo mantienes para otras transformaciones, la ganancia es menor. Sigue siendo experimental: actívalo en una rama y mide antes de meterlo en tu pipeline principal.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Tests unitarios lentos: el número de Vitest que casi nadie mira

    Tests unitarios lentos: el número de Vitest que casi nadie mira

    Nuestro job de tests en CI tardaba doce minutos clavados. Setecientos siete ficheros, cuatro mil cuatrocientos tests. Nadie lo cuestionaba: una suite grande tarda, y punto.

    Hoy lo hemos dejado en seis minutos y quince segundos. Sin borrar un solo test, sin runners más caros, sin paralelizar nada. Solo cambiando qué entorno arranca cada fichero.

    Y lo interesante no es el 48 % que nos ahorramos. Es que llevábamos meses con tests unitarios lentos mirando el número equivocado.

    El número equivocado es el total. El total te dice que tienes un problema, pero no te dice dónde se va el tiempo. Y sin el dónde, optimizar es tirar cosas a la pared: cambias el entorno, subes los threads, añades runners, y a veces sale bien y a veces sale peor y nunca sabes por qué.

    Vitest te da el dónde al final de cada ejecución. Lo tienes impreso en tu terminal ahora mismo.


    Resumen rápido

    • El total del Duration te dice que tienes tests unitarios lentos, no dónde se va el tiempo. El desglose sí.
    • environment es la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. Compáralo contra tests, nunca contra Duration.
    • En nuestra suite: 803,6 s de environment contra 156 s de tests. Cinco veces más en montar el escenario que en ejecutarlo.
    • La causa: environment: 'jsdom' global para 707 ficheros, de los que solo 208 tocan el DOM.
    • El fix: dos proyectos de Vitest, la extensión decide el entorno. .test.ts a node, .test.tsx a DOM. 12m → 6m 15s en CI.
    • Shardear no arregla esto: algo más de la mitad del tiempo es coste fijo de arranque, y cada shard lo vuelve a pagar entero.

    Tests unitarios lentos: el desglose que Vitest imprime y nadie lee

    Debajo del Duration hay un paréntesis con seis campos: transform, setup, collect, tests, environment y prepare.

    En nuestro caso, los dos que importan salían así:

    Duration  106.31s (transform …, setup …, collect …, tests 156.00s, environment 803.60s, prepare …)
    

    Recorto los campos que no vienen al caso. Fíjate en la contradicción aparente: el run entero duró 106 segundos, pero dice que gastó 803 en environment.

    No es un bug. environment es la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. La suite corre en dieciséis workers y cada uno monta su propio entorno por fichero. La cifra que ves es la suma de todos ellos, así que puede ser más de siete veces mayor que el reloj de pared.

    La regla de lectura del desglose de Vitest es comparar acumulado contra acumulado: environment contra tests, nunca contra Duration. Duration es wall-clock; los otros dos son tiempos sumados entre ficheros y workers.

    Y ahí el número deja de ser abstracto: 803,6 segundos montando el escenario contra 156 ejecutando los tests. Cinco veces más en preparar que en actuar.

    Cuando environment multiplica varias veces a tests, el problema no son los tests lentos: es el arranque del entorno.


    707 ficheros arrancando un navegador, 208 usándolo

    El origen estaba en una línea del config: environment: 'jsdom', global, para los 707 ficheros.

    Conté los que tocaban el DOM de verdad. Eran 208. Los 499 restantes —parsers, cálculo de precios, mapeo de rutas de API, validadores— montaban un navegador falso entero para no usarlo jamás.

    Ese es el gasto que estábamos pagando cinco veces sobre el trabajo real. No era un problema de rendimiento del emulador. Era que la mitad larga de la suite no necesitaba emulador ninguno.

    La solución fue partir la suite en dos proyectos de Vitest con una regla que cabe en una frase: la extensión decide el entorno.

    El config, con Vitest 4.1.10 (julio de 2026). La clave test.projects sustituyó a workspace en Vitest 3.2, así que en versiones anteriores esto no aplica:

    // vitest.config.ts
    import { defineConfig } from 'vitest/config'
    import react from '@vitejs/plugin-react'
    
    export default defineConfig({
      test: {
        projects: [
          {
            // Lógica pura: node pelado. Sin plugins, sin setup, sin DOM.
            test: {
              name: 'unit',
              environment: 'node',
              include: ['src/**/*.test.ts'],
            },
          },
          {
            // Componentes: DOM emulado + testing-library.
            plugins: [react()],
            test: {
              name: 'dom',
              environment: 'jsdom',
              include: ['src/**/*.test.tsx'],
              setupFiles: ['./src/test/setup-dom.ts'],
            },
          },
        ],
      },
    })
    

    Elegir la extensión como criterio no es cosmético. Con globs por carpeta o por sufijo (*.spec.ts y *.component.spec.ts) te toca mantener un exclude, porque el primer patrón se traga los ficheros del segundo y esos tests se ejecutan dos veces, una de ellas en el entorno equivocado y fallando por un motivo que parece un bug de tu código.

    .test.ts y .test.tsx no se solapan nunca. Cero exclude, cero ambigüedad, y una regla que un compañero nuevo entiende sin preguntar: si tu test importa JSX, es .tsx y tiene DOM.

    Si trabajas en Angular la idea es idéntica desde que Vitest es el runner por defecto. Cambian los globs, no el razonamiento.

    Después dimos el segundo paso: cambiar una palabra en el proyecto dom, de jsdom a happy-dom. En un A/B aislado sobre esos 208 ficheros, 48,9 s → 34,9 s. Unos catorce segundos. Útil, pero un orden de magnitud por debajo de lo que dio separar los entornos.

    La comparativa completa entre los dos emuladores, con benchmark y las APIs que le faltan a cada uno, la tengo aparte en el post sobre happy-dom o jsdom.

    El resultado de las dos cosas juntas:

    Ámbito Antes Después Δ
    Job Test en CI 12m 00s 6m 15s −48 %
    Suite local (16 cores) 106,3 s 46,0 s −57 %
    environment (acumulado entre ficheros) 803,6 s 137 s −83 %
    Ficheros / tests 707 / 4.400 707 / 4.400 sin cambios

    Mismos tests. Mismas aserciones. Misma cobertura.


    El efecto secundario: dos tests que llevaban meses mintiendo

    Al cambiar el entorno, dos tests empezaron a fallar.

    Y tenían razón.

    El patrón era este, y lo he visto en todos los proyectos en los que he entrado:

    vi.spyOn(global, 'fetch')
      .mockResolvedValueOnce(ok(productos))
      .mockResolvedValueOnce(ok(stock))
    

    Parece un mock. No lo es.

    vi.spyOn(global, 'fetch') sin implementación envuelve la función original y sigue llamándola. Lo único que intercepta son las respuestas que has encolado con mockResolvedValueOnce. Y esa cola se agota: la primera llamada recibe productos, la segunda stock, y la tercera sale a la red de verdad.

    Nuestro componente hacía tres llamadas.

    Llevaba meses pidiendo datos a localhost:3000 desde el runner de CI. jsdom se lo tragaba en silencio y el test seguía en verde. Con happy-dom la petición real quedó a la vista, y ahí aparecieron los 401.

    El arreglo son cuatro líneas, y es la clase de cosa que debería estar en el setup de cualquier suite:

    // setup-dom.ts
    import { beforeEach, vi } from 'vitest'
    
    beforeEach(() => {
      vi.spyOn(global, 'fetch').mockRejectedValue(new Error('fetch sin mockear'))
    })
    

    Un default que revienta. Si un test necesita una respuesta, la encola encima; si se le olvida una llamada, el test falla con un mensaje que dice exactamente qué pasó, en vez de irse a internet a buscar suerte.

    Añade también restoreMocks: true en el config: mockRejectedValue en un beforeEach no vacía la cola de ...Once que haya dejado el test anterior, y esa cola sobrante es una fuga entre tests igual de silenciosa que la que acabas de tapar.

    Yo esto ya no lo discuto: un test que llega a la red no es un test unitario, es una apuesta. Es lento, es flaky, y depende del firewall del runner. Diseñar los mocks para que el hueco falle ruidosamente en vez de degradar en silencio es la mitad del trabajo de testear bien, y es la parte que más tiempo dedico a explicar en el curso de Testing en Angular.

    Nadie planea encontrar estos bugs. Aparecen cuando tocas los cimientos.


    ¿Merece la pena shardear los tests unitarios? Los números dijeron que no

    Nos dio un 27 % a cambio de cuatro runners, cuando el cálculo ingenuo prometía un 60 %. El motivo es que en unitarios la mayor parte del tiempo es arranque compartido, y repartirlo no lo divide: lo multiplica. Así llegamos ahí.

    Semanas antes habíamos partido la suite de E2E en shards concurrentes y el wall-clock se había desplomado. Fue de esas victorias que te dejan con ganas de repetir.

    Así que la pregunta era obvia: si funcionó con E2E, ¿por qué no con los unitarios?

    Los números decían que sí. De los 375 segundos del job, solo 31 eran setup —checkout, pnpm install, build de las librerías internas—, un 8 %. Con un overhead fijo tan bajo, repartir en tres debería habernos dejado en torno a los dos minutos y medio. Una mejora del orden del 60 %.

    Abrimos el PR, lo lanzamos, y esto es lo que salió:

    Job Tiempo
    Test (1) 4m 19s
    Test (2) 4m 32s ← wall-clock
    Test (3) 3m 57s
    Test (otros paquetes) 1m 38s

    375 s → 272 s. Un 27 %, a cambio de cuatro runners en vez de uno.

    Volvimos al desglose, que es lo que había que haber hecho antes de escribir el PR:

    Ámbito Ficheros Tiempo de tests
    Job completo 707 333 s
    Un shard 236 226 s

    Léelo despacio. Un tercio de los ficheros tarda el 68 % de lo que tardan todos.

    Si ajustas una recta T(n) = F + n·v con esos dos puntos, sale un coste fijo F de unos 172 segundos y una pendiente de 0,23 segundos por fichero. Traducido: de los 333 segundos, algo más de la mitad es peaje que pagas antes de ejecutar un solo test. Solo unos 160 dependen de cuántos ficheros tengas.

    Y aquí toca ser honesto con el método: dos puntos y dos incógnitas significa que la recta pasa por ambos por construcción. Es aritmética, no un perfilado. Te da el orden de magnitud del reparto entre lo fijo y lo variable, que es justo lo que necesitas para decidir, pero no lo cites como si fuera una medida.

    Ese coste fijo es transform más importación del grafo de dependencias, en frío, sin caché de Vite en el runner. Y cada shard lo paga entero, otra vez, desde cero.

    Con 3 shards pagas ese peaje tres veces. Con 6, seis. No hace falta el modelo para verlo: el shard más rápido de los tres, con 236 ficheros en vez de 707, todavía tardó 3m 57s. Por muchos runners que enchufes, el suelo se queda en unos cuatro minutos.

    La lección de E2E no transfería, y visto desde aquí es evidente. En Playwright cada test es trabajo independiente de navegador: repartir divide de verdad. En unitarios, la mayor parte del tiempo es arranque compartido, y repartir trabajo compartido no lo divide, lo multiplica.

    El sharding no era la palanca. La palanca era eliminar los ~172 segundos de coste fijo que cada shard vuelve a pagar entero.


    El PR sigue abierto, y creo que así está bien

    El PR #36 no está mergeado ni cerrado. Un 27 % por 4x runners es un trade flojo, y las tres opciones siguen sobre la mesa:

    • Cerrarlo. Los cien segundos no compensan cuadruplicar el consumo de CI ni la complejidad de un job matricial.
    • Bajarlo a 2 shards. Menos ganancia, la mitad de coste, y sospecho que el punto donde la curva todavía compensa.
    • Aparcarlo y atacar el coste fijo. Cachear node_modules/.vite entre runs, si esa caché existe en tu setup, porque hoy se reconstruye en frío cada vez. Si funciona, mejora los tres escenarios a la vez, incluido el de un solo runner.

    La tercera es la que tiene mejor pinta, y precisamente por eso no quiero decidirla con la misma prisa con la que abrimos el PR. Primero medir el arranque en caliente, después decidir.


    Qué hacer hoy si tienes tests unitarios lentos

    1. Lanza tus tests y mira el paréntesis del final. Solo eso.
    2. Compara environment con tests. Los dos son sumas acumuladas entre ficheros, así que la división tiene sentido. Si environment es el doble de tests, ya sabes dónde está tu problema, y no es donde llevas semanas buscándolo.
    3. Cuenta cuántos ficheros importan JSX o tocan document de verdad. En nuestro caso eran 208 de 707. En el tuyo probablemente sea una proporción parecida, porque un catálogo, un carrito o un dashboard tienen mucha más lógica que pintura.
    4. Separa por extensión. .test.ts a node, .test.tsx a DOM. Veinte minutos de trabajo, cero riesgo, ningún test tocado.

    La tesis de todo esto no es "usa happy-dom" ni "shardea tus tests". Es que medir el coste correcto —no el tiempo total, sino en qué se va— convierte una optimización a ciegas en un cambio de config de veinte líneas. El mismo desglose que nos quitó seis minutos nos evitó después tirar cuatro runners a un problema que no era de paralelismo.

    Y que de vez en cuando, al levantar los cimientos, encuentras un test que llevaba meses saliendo a internet sin que nadie se enterara.

    Si quieres ver este tipo de decisiones tomadas sobre proyectos reales, con los runs y los números delante en vez de con opiniones, es lo que hacemos en Dominicode Labs.


    Preguntas frecuentes sobre tests unitarios lentos

    ¿Por qué mis tests unitarios son lentos si cada test tarda milisegundos?

    Casi siempre porque el tiempo no se va en ejecutar los tests, sino en preparar el entorno de cada fichero. En nuestra suite, el desglose de Vitest daba 803,6 segundos acumulados en environment frente a 156 en tests: cinco veces más en montar el escenario que en actuar. Mientras esa proporción esté desequilibrada, optimizar aserciones o subir el número de threads no te va a dar nada: estarías acelerando la parte pequeña.

    ¿Qué significa environment en el resumen de Vitest?

    Es el tiempo dedicado a instanciar el entorno de test (jsdom, happy-dom o node) para cada fichero, sumado entre todos los workers. Es tiempo acumulado entre ficheros y workers, no wall-clock, y por eso puede ser mucho mayor que el Duration total: nosotros teníamos 803,6 segundos de environment en un run de 106,3 segundos corriendo sobre dieciséis workers. La comparación que tiene sentido es environment contra tests, porque ambas cifras están acumuladas de la misma forma.

    ¿Cómo separo los tests que necesitan DOM de los que no en Vitest?

    Con test.projects en vitest.config.ts: un proyecto con environment: 'node' para la lógica pura y otro con DOM emulado, plugin del framework y setupFiles para los tests de componente. Lo que mejor nos ha funcionado es decidir por extensión, .test.ts contra .test.tsx, porque son globs que no se solapan y no necesitas exclude. Si separas por carpeta o por sufijo compuesto, un mismo fichero puede caer en los dos proyectos y ejecutarse dos veces, una de ellas en el entorno equivocado.

    ¿Merece la pena shardear los tests unitarios en CI?

    Depende de qué proporción de tu tiempo sea coste fijo de arranque, y hay que medirlo antes de abrir el PR. En nuestro caso, tres shards dieron un 27 % de mejora a cambio de cuatro runners, cuando el cálculo ingenuo prometía un 60 %. El motivo es que cada shard vuelve a pagar entero el transform y la importación del grafo de dependencias, así que ese coste no se reparte, se multiplica. Con E2E la historia es distinta porque cada test es trabajo independiente de navegador y repartir sí divide.

    ¿Por qué vi.spyOn(global, 'fetch') no mockea mis llamadas?

    Porque spyOn sin implementación envuelve la función original y sigue llamándola. Solo intercepta las respuestas que hayas encolado con mockResolvedValueOnce, y esa cola se agota: en cuanto tu código hace una llamada más de las que encolaste, esa petición sale a la red de verdad. El arreglo es poner siempre un default que falle, con vi.spyOn(global, 'fetch').mockRejectedValue(new Error('fetch sin mockear')) en el setup, y encolar las respuestas concretas encima en cada test.

    ¿Esto aplica igual en Angular?

    Sí, y desde Angular 21 y 22 aún más, porque Vitest pasó a ser el runner por defecto y el entorno DOM dejó de ser una decisión implícita del builder de Karma. Cambian los globs, que serán .spec.ts con algún criterio propio para distinguir tests de componente de tests de servicio, pero el diagnóstico es idéntico: mira el desglose de environment, cuenta cuántos specs necesitan document y manda el resto a node.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Visor de PDF en Next.js con Apryse WebViewer: guía real

    Visor de PDF en Next.js con Apryse WebViewer: guía real

    Un cliente me escribió con lo que parecía un encargo de dos semanas.

    «Necesitamos que el usuario abra su contrato dentro de la plataforma, lo anote, tache los datos sensibles y lo firme. Sin descargar nada, sin salir de la app.»

    Dos semanas. Claro.

    Lo que estaba pidiendo era un visor de PDF en Next.js con capa de anotaciones persistentes, redacción de verdad —no un rectángulo negro pintado encima, sino borrar el texto del documento— y firma. Es decir: un producto entero metido dentro de una ruta de la aplicación.

    Lo he visto intentar a mano tres veces. Las tres acabaron igual: seis meses después seguían peleando con fuentes embebidas y zoom en móvil.


    Resumen: visor de PDF en Next.js en 5 puntos

    • Un visor de PDF en Next.js es un componente de cliente que renderiza documentos dentro de tu aplicación, sin descargarlos ni delegar en el visor nativo del navegador. Requiere dos cosas que no son obvias: cargar la librería solo en el navegador y servir sus assets estáticos desde public/.
    • Construir a mano anotaciones, redacción y firma sobre PDF es un pozo sin fondo: el formato tiene 30 años de casos borde.
    • Apryse WebViewer (@pdftron/webviewer) te da ese flujo completo en el navegador, sin backend de por medio.
    • Es un SDK comercial con trial gratuito. Los paquetes de entrada arrancan en $1.500 según su web de precios —consultado en julio de 2026—, y el precio final es a medida.
    • Si solo necesitas mostrar un PDF, no lo uses. pdf.js o react-pdf te resuelven eso gratis.

    Por qué "solo un visor de PDF" nunca es solo un visor

    Un PDF no es una imagen con texto: es un contenedor con tipografías embebidas, capas, formularios AcroForm, XFA, firmas criptográficas, anotaciones con su propio modelo de datos y treinta años de decisiones heredadas. Parece simple solo porque lo abres todos los días y funciona.

    Renderizar la primera página con pdf.js te lleva una tarde. El problema empieza después.

    Que la anotación quede anclada al párrafo correcto al hacer zoom. Que la redacción elimine el texto del stream y no solo lo tape —si lo tapas, cualquiera lo copia con Ctrl+C y tienes un incidente de datos—. Que la firma se incruste sin romper la validez del documento. Y que todo eso funcione igual en Safari iOS.

    Ese es el trabajo real. Y no es trabajo de dos semanas: es trabajo de un equipo dedicado durante trimestres.

    Antes de meter una pieza así en tu aplicación, escribe qué necesitas exactamente. Suena obvio y casi nadie lo hace. En cómo aplico Spec-Driven Development antes de escribir código explico el proceso —y lo tienes entero en el libro de Spec-Driven Development—. Esta es justo la decisión donde una especificación de una página te evita elegir mal una licencia comercial.


    Qué es Apryse WebViewer y qué te ahorra

    Apryse WebViewer es un SDK comercial que monta un visor y editor de PDF en React dentro de tu aplicación, ejecutándose entero en el navegador. No necesitas un servicio de conversión detrás.

    Lo que trae de fábrica:

    • Anotación completa: resaltados, notas, dibujo, formas, comentarios.
    • Redacción real, que elimina el contenido del documento.
    • Edición de texto sobre el PDF.
    • Fill & sign para formularios y firma.
    • Búsqueda dentro del documento.
    • Cambio programático del documento cargado.
    • Soporte de más de 100 formatos: Office, imágenes, CAD. No solo PDF.

    Ese último punto suele cerrar la decisión. Cuando el cliente añade «ah, y también suben Word y planos», ya no evalúas un visor: evalúas si escribes tu propio pipeline de conversión.


    Cómo montar un visor de PDF en Next.js paso a paso

    La guía oficial de Apryse para Next.js cubre la instalación y es correcta. Lo que sigue añade lo que no te cuenta: la limpieza al desmontar, el fallo silencioso cuando path está mal y en qué casos no deberías estar leyendo este tutorial.

    1. Instalar el paquete

    npm i @pdftron/webviewer@^12
    

    Fijar la mayor te evita que un npm update te cambie la API por debajo. Este tutorial está escrito sobre la rama 12 con Next.js 16 y App Router.

    2. Copiar los assets estáticos

    Este es el paso que más gente se salta y luego pasa una tarde mirando 404 en la pestaña de red. WebViewer necesita sus propios archivos servidos estáticamente: workers, fuentes, recursos de UI.

    npx --yes cpy-cli "node_modules/@pdftron/webviewer/public/**/*" public/lib/webviewer
    

    Guárdalo como script de postinstall. Si no lo haces, funcionará en tu máquina y fallará en el primer despliegue limpio de CI:

    {
      "scripts": {
        "postinstall": "npx --yes cpy-cli \"node_modules/@pdftron/webviewer/public/**/*\" public/lib/webviewer"
      }
    }
    

    3. El componente, siempre en cliente (y el error "window is not defined")

    WebViewer toca window y el DOM en el momento de inicializarse. Si Next.js intenta renderizarlo en el servidor, revienta con el clásico ReferenceError: window is not defined.

    Aquí está el matiz que atasca a casi todo el mundo: marcar el componente con 'use client' no basta si el import es estático. Esa directiva define dónde se hidrata el componente, no impide que el módulo se evalúe al construir el bundle del servidor.

    La solución es importar el módulo dinámicamente dentro del useEffect, para que solo se resuelva en el navegador después del montaje.

    'use client'
    import { useEffect, useRef } from 'react'
    
    export default function PdfViewer() {
      const viewer = useRef(null)
    
      useEffect(() => {
        const container = viewer.current
        let instance = null
        let unmounted = false
    
        const destroy = () => {
          instance?.UI?.dispose?.()
          instance = null
          if (container) container.innerHTML = ''
        }
    
        import('@pdftron/webviewer')
          .then(({ default: WebViewer }) =>
            WebViewer(
              {
                path: '/lib/webviewer',
                licenseKey: process.env.NEXT_PUBLIC_APRYSE_LICENSE_KEY,
                initialDoc: 'https://apryse.s3.amazonaws.com/public/files/samples/WebviewerDemoDoc.pdf',
              },
              container,
            ),
          )
          .then((i) => {
            instance = i
            if (unmounted) return destroy()
            i.Core.documentViewer.addEventListener('documentLoaded', () => {
              // el documento ya está en pantalla: engancha aquí tu lógica
            })
          })
          .catch((error) => {
            console.error('WebViewer no arrancó. Revisa la opción `path`:', error)
          })
    
        return () => {
          unmounted = true
          destroy()
        }
      }, [])
    
      return <div ref={viewer} style={{ height: '100dvh' }} />
    }
    

    Cuatro detalles que conviene entender, no copiar:

    • path apunta exactamente a donde copiaste los assets en el paso 2. Si cambias la carpeta, cambia esto.
    • El array de dependencias vacío no te salva de StrictMode. En desarrollo React monta, desmonta y vuelve a montar: el efecto corre dos veces y, sin función de limpieza, acabas con dos visores peleando por el mismo div. Por eso el return del useEffect llama a UI.dispose() y vacía el contenedor. Es la parte que casi ningún tutorial escribe.
    • La bandera unmounted cubre el otro orden posible: que el usuario navegue a otra ruta antes de que resuelva el import() dinámico. Sin ella te quedas un iframe y unos workers WASM vivos en memoria.
    • La promesa resuelve con un instance que expone instance.Core —donde vive documentViewer— e instance.UI. Ese objeto es tu mando a distancia.

    Y sí, el .catch() importa: si path está mal, sin él la promesa se rechaza en silencio y te quedas mirando un div en blanco sin una sola pista en consola.

    Si necesitas la API completa, añade fullAPI: true a las opciones.

    La clave va en variable de entorno para no hardcodearla en el repo:

    NEXT_PUBLIC_APRYSE_LICENSE_KEY=tu_clave_de_trial
    

    Ojo con la etiqueta: cualquier variable NEXT_PUBLIC_ viaja al bundle del navegador, así que esto no la convierte en un secreto. En WebViewer la licencia es de cliente y va ligada a tu dominio, así que es correcto y esperado; pero si algún día metes aquí una credencial de verdad, ese secreto vive en tu backend, no en una NEXT_PUBLIC_.


    Controlar el visor desde tu propia interfaz

    Casi nadie quiere la UI del SDK tal cual: quieres tus botones, con tu marca, en tu layout.

    El patrón es guardar la instancia en estado o en una ref y llamar a sus métodos desde tus componentes. El visor deja de ser una caja negra y pasa a ser un motor que tú comandas.

    'use client'
    import { useEffect, useRef, useState } from 'react'
    
    export default function PdfWorkspace() {
      const viewer = useRef(null)
      const [instance, setInstance] = useState(null)
    
      useEffect(() => {
        const container = viewer.current
        let current = null
        let unmounted = false
    
        const destroy = () => {
          current?.UI?.dispose?.()
          current = null
          setInstance(null)
          if (container) container.innerHTML = ''
        }
    
        import('@pdftron/webviewer')
          .then(({ default: WebViewer }) =>
            WebViewer(
              {
                path: '/lib/webviewer',
                licenseKey: process.env.NEXT_PUBLIC_APRYSE_LICENSE_KEY,
                fullAPI: true,
              },
              container,
            ),
          )
          .then((i) => {
            current = i
            if (unmounted) return destroy()
            setInstance(i)
          })
          .catch((error) => {
            console.error('WebViewer no arrancó:', error)
          })
    
        return () => {
          unmounted = true
          destroy()
        }
      }, [])
    
      return (
        <>
          <button
            disabled={!instance}
            onClick={() => {
              if (!instance) return
              const { documentViewer } = instance.Core
              // llama aquí al método que necesites sobre el documento
            }}
          >
            Acción propia
          </button>
          <div ref={viewer} style={{ height: '100dvh' }} />
        </>
      )
    }
    

    Un aviso de rendimiento: este bundle es grande. No lo cargues en el layout raíz ni en una ruta que la gente visita de paso. Aíslalo en su propia ruta y deja que el router precargue lo demás — hablé de esto al analizar las navegaciones instantáneas de Next.js 16.3, y aquí la diferencia entre hacerlo bien y mal se nota en segundos, no en milisegundos.


    La parte incómoda: cuánto cuesta Apryse WebViewer

    Apryse WebViewer no es gratis: su web de precios indica paquetes de entrada desde $1.500, y el precio final es a medida. Esa es la cifra, y aquí es donde la mayoría de tutoriales se callan.

    El importe depende de las features que actives, del volumen de documentos y de si la solución es cliente o servidor. No hay tarifa pública por tramos: hay que pedir presupuesto.

    Antes de pagar puedes probarlo. Apryse ofrece un trial gratuito, y su documentación de instalación te pide obtener una trial key en el portal de desarrolladores como paso previo. No te fíes de las condiciones que leas en un blog —incluido este—: mira el portal, que es donde cambian.

    Vas a encontrar otras cifras circulando por foros y comparativas. Ninguna sale de Apryse. Ignóralas y pide presupuesto: es la única cifra que vale para tu caso.


    ¿Cuándo NO usar Apryse?

    Si lo único que necesitas es mostrar un PDF en modo lectura, esto es un cañón para matar una mosca.

    Para eso tienes pdf.js de Mozilla, o react-pdf —construido encima— si quieres la integración con componentes ya resuelta. Son gratis, open source, maduros y te resuelven ese caso entero. Meter un SDK comercial ahí es quemar presupuesto y añadir peso al bundle sin ganar nada.

    Esta es la comparativa que a mí me habría ahorrado dos días de evaluación:

    Necesidad pdf.js react-pdf Apryse WebViewer
    Renderizar, paginar, zoom
    Búsqueda en el documento ⚠️ manual
    Componentes React listos
    Anotaciones persistentes
    Redacción real (borra del stream)
    Edición de texto sobre el PDF
    Fill & sign / firma
    Office, imágenes, CAD (100+ formatos)
    Licencia Apache 2.0 MIT Comercial
    Coste Gratis Gratis Desde $1.500, a medida

    Léela en diagonal y verás el patrón: las tres primeras filas son un empate, y todo lo demás es una columna sola. Si tu requisito vive en las tres primeras filas, ya tienes tu respuesta y es gratis.

    Apryse gana en un escenario concreto: cuando el documento es parte del flujo de negocio. Cuando el usuario tiene que anotar, redactar, rellenar, firmar o editar, y ese flujo es lo que el cliente está pagando.

    Y ahí el cálculo no es "SDK caro contra librería gratis". Es esto: cuántos meses de ingeniería cuesta construir y mantener redacción, anotaciones y firma bien hechas, contra el precio de licenciarlo.

    Cuando lo planteas así, la respuesta suele ser evidente. El coste no es el SDK. Es el tiempo que no gastas.


    Qué hacer hoy

    Instala el trial, copia los assets, monta el componente de arriba y ábrelo con un PDF real de tu cliente. No el de ejemplo: uno feo, escaneado, de 80 páginas.

    En veinte minutos sabrás si esto resuelve tu problema o si te sobra con react-pdf. Esa decisión, tomada con el visor delante y no leyendo comparativas, vale más que cualquier post.

    Y si lo que quieres es integrar piezas grandes como esta sin perder tres días leyendo documentación, ese es exactamente el flujo que enseño en el curso Construye con IA: especificar primero, delegar la integración después. La metodología completa está en el libro de Spec-Driven Development, y si prefieres hacerlo acompañado, en Dominicode Labs trabajamos integraciones como esta sobre proyectos reales.


    Preguntas frecuentes

    ¿Apryse WebViewer es gratis?

    No. Es un SDK comercial. Sí ofrece un trial gratuito para validar si encaja con tu caso: su documentación de instalación te pide obtener una trial key en el portal de desarrolladores antes de empezar. Para producción, su web de precios indica paquetes de entrada desde $1.500 (consultado en julio de 2026), con precio final a medida según las features que actives, el volumen de documentos y si el despliegue es en cliente o en servidor. Las condiciones exactas del trial las marca su portal, no los blogs.

    ¿Se puede usar Apryse WebViewer con el App Router de Next.js?

    Sí, con dos condiciones. El componente que lo monta debe llevar la directiva 'use client' y el import del paquete tiene que ser dinámico dentro del useEffect, no estático en la cabecera del archivo. Así el bundle del servidor nunca evalúa el SDK. Además tienes que copiar los assets estáticos del paquete a public/lib/webviewer y apuntar la opción path a esa ruta.

    ¿Por qué me da el error "window is not defined" al integrar el visor?

    Porque Next.js está intentando ejecutar el SDK durante el renderizado en servidor, donde no existe el objeto window. Marcar el componente como cliente no basta si el import es estático: el módulo se evalúa igualmente al construir. La solución es cargarlo con import('@pdftron/webviewer') dentro del useEffect, de forma que solo se resuelva en el navegador después del montaje.

    ¿Qué alternativa gratuita hay a Apryse?

    pdf.js de Mozilla y react-pdf, que está construido encima. Ambos son open source y cubren perfectamente la visualización de documentos: renderizado, paginación, zoom y búsqueda básica. Donde no llegan es en el flujo completo de trabajo con documentos —redacción que borra contenido de verdad, edición de texto, fill & sign, anotaciones persistentes con su modelo de datos—. Si tu requisito es leer, usa las gratuitas. Si tu requisito es operar sobre el documento, compara con Apryse.

    ¿Sirve solo para PDF?

    No. WebViewer soporta más de 100 formatos, incluidos documentos de Office, imágenes y archivos CAD, y los renderiza en el mismo visor sin necesidad de un servicio de conversión en el servidor. Suele ser el factor decisivo cuando los usuarios suben lo que tienen a mano y no un PDF bien generado.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • De callbacks a Signals: la reactividad real del frontend

    De callbacks a Signals: la reactividad real del frontend

    Un excliente me escribió hace años, angustiado. Su carrito de compras mostraba 3 artículos en el header, pero el checkout decía que había 5. Los clientes se quejaban en soporte y algunos abandonaban la compra.

    Revisé el código. Puro estilo jQuery: DOM manipulado a mano, evento por evento. Un event listener actualizaba el contador del header. Otro, completamente separado, actualizaba el resumen del checkout. Nadie los había conectado entre sí — y ahí estaba el problema real: cero programación reactiva, cero garantía de que el estado y la interfaz dijeran la misma verdad.

    Cuando alguien hacía clic dos veces seguidas y rápido, un listener terminaba antes que el otro. El total quedaba repartido entre cuatro variables sueltas, cada una con su propia versión de la verdad. Pasé tres horas arreglando algo que debería haberme tomado diez minutos. No porque el bug fuera complejo — porque nada en el código garantizaba que la interfaz reflejara el estado real.

    Llevamos veinte años resolviendo ese mismo problema con herramientas distintas. Primero fueron callbacks manuales sobre el DOM. Luego llegó el Virtual DOM. Ahora, señales. Cada era resolvió lo que la anterior no pudo — y entender por qué importa más que memorizar sintaxis nueva cada dos años.


    Era 1: callbacks manuales y el DOM que se te olvida sincronizar

    En los tiempos de jQuery — y del DOM vanilla antes de eso — la única forma de reaccionar a un evento era escucharlo y mutar el DOM a mano. Tú decidías qué elemento tocar, cuándo y con qué valor.

    Toma el ejemplo clásico: un contador de carrito con tres elementos que dependen del mismo dato.

    let count = 0;
    
    const counterEl = document.querySelector('#counter');
    const totalEl = document.querySelector('#total');
    const shippingMsgEl = document.querySelector('#shipping-msg');
    
    document.querySelector('#add-btn').addEventListener('click', () => {
      count++;
      counterEl.textContent = count;
      totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
      shippingMsgEl.textContent = count >= 5
        ? '¡Envío gratis!'
        : `Añade ${5 - count} más para envío gratis`;
    });
    
    document.querySelector('#remove-btn').addEventListener('click', () => {
      count = Math.max(0, count - 1);
      counterEl.textContent = count;
      totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
      // shippingMsgEl no se actualiza aquí. Nadie lo notó en code review.
    });
    

    Mira el comentario en la última línea. Ese es, casi literal, el bug que revisé en el carrito de mi excliente.

    No es un error de sintaxis — el código compila, pasa QA si nadie prueba el camino de "quitar un producto cuando ya tenías envío gratis". El bug vive en la cabeza del developer: hay que acordarse de tocar los tres elementos en cada handler que mueva ese estado.

    La ventaja de este modelo es real: control total, cero abstracciones, cero curva de aprendizaje. Para un widget aislado — un acordeón, un modal, un tooltip — sigue siendo la opción correcta hoy mismo.

    El problema aparece en cuanto el estado deja de ser trivial:

    • Cada elemento dependiente necesita su propia línea de sincronización, repetida en cada handler que toque ese estado.
    • El estado vive disperso: a veces en el DOM (el.textContent), a veces en variables sueltas, a veces en atributos data-*.
    • Los listeners no se limpian solos. En una SPA que monta y desmonta vistas, cada addEventListener sin su removeEventListener es un memory leak esperando a pasar factura.

    Esto nunca fue un problema de jQuery. Fue un problema de arquitectura: nada en el modelo te obligaba a centralizar el estado ni a declarar sus dependencias. Cada developer inventaba su propia disciplina — y la disciplina, a escala de equipo, no escala.

    Era 2: Virtual DOM y el modelo declarativo

    React cambió la pregunta. En lugar de "¿qué elemento del DOM tengo que tocar?", pasó a ser "¿cómo se ve la UI dado este estado?". Tú describes el resultado final; el framework decide cómo llegar ahí.

    function Counter() {
      const [count, setCount] = useState(0);
      const total = (count * 19.99).toFixed(2);
      const shippingMsg = count >= 5
        ? '¡Envío gratis!'
        : `Añade ${5 - count} más para envío gratis`;
    
      return (
        <div>
          <p>{count}</p>
          <p>${total}</p>
          <p>{shippingMsg}</p>
          <button onClick={() => setCount(c => c + 1)}>Añadir</button>
          <button onClick={() => setCount(c => Math.max(0, c - 1))}>Quitar</button>
        </div>
      );
    }
    

    El bug del carrito es estructuralmente imposible aquí. total y shippingMsg se calculan en la misma función, a partir del mismo count, cada vez que el componente se ejecuta. No hay "actualizar" — hay "recalcular todo desde cero", así que no hay forma de que uno se sincronice y el otro se olvide.

    Ahí está la clave del Virtual DOM. React no toca el DOM real en cada cambio. Construye un árbol en memoria — objetos JavaScript planos que describen cómo debería verse la UI — y lo compara contra el árbol anterior. Ese proceso se llama reconciliation, y el algoritmo de comparación es el diffing: detecta qué nodos cambiaron, cuáles se reutilizan, y calcula el mínimo de operaciones para que el DOM real refleje el nuevo árbol. Solo entonces toca el DOM — y solo donde hace falta.

    Es un modelo declarativo y predecible. Pero el coste real no es gratis, y es lo que casi nadie menciona en los tutoriales de introducción: cada cambio de estado re-ejecuta la función completa del componente y, por defecto, la de sus hijos.

    En un árbol de cuarenta componentes anidados, un solo tecleo puede disparar cuarenta re-renders y cuarenta diffs — la mayoría comparando nodos que ni siquiera cambiaron.

    La respuesta del ecosistema fue la memoization: memo(), useMemo(), useCallback(). Son parches necesarios para un problema que el propio modelo introduce: no sabes qué cambió hasta que recalculas y comparas. Memoizar es responsabilidad manual otra vez — la misma que el Virtual DOM prometía eliminar, solo que movida un nivel más arriba en el árbol.

    Era 3: reactividad fina — el grafo en vez del árbol

    Los signals no comparan nada. No hay árbol virtual, no hay diffing, no hay re-render de una función completa. Un signal es una caja que guarda un valor y sabe, con precisión, quién depende de él.

    import { Component, signal, computed, effect } from '@angular/core';
    
    @Component({
      selector: 'app-cart-counter',
      template: `
        <p>{{ count() }}</p>
        <p>${{ total() }}</p>
        <p>{{ shippingMsg() }}</p>
        <button (click)="count.set(count() + 1)">Añadir</button>
        <button (click)="count.set(count() - 1)">Quitar</button>
      `,
    })
    export class CartCounterComponent {
      count = signal(0);
    
      total = computed(() => (this.count() * 19.99).toFixed(2));
    
      shippingMsg = computed(() =>
        this.count() >= 5
          ? '¡Envío gratis!'
          : `Añade ${5 - this.count()} más para envío gratis`
      );
    
      constructor() {
        effect(() => {
          console.log(`Carrito: ${this.count()} items — $${this.total()}`);
        });
      }
    }
    

    Cuando count cambia, Angular no re-ejecuta el componente entero ni reconstruye ningún árbol para comparar. total y shippingMsg ya saben que dependen de count — lo registraron la primera vez que se ejecutaron, al construirse el grafo reactivo. Angular actualiza exactamente el nodo del DOM ligado a cada binding. Nada más se mueve.

    Esto es reactividad fina (fine-grained reactivity): la granularidad de la actualización no es el componente, ni el subárbol — es el binding individual. Angular v22 lleva esto hasta el final siendo zoneless por defecto: ya no depende de Zone.js interceptando cada setTimeout o evento del navegador para saber cuándo revisar cambios. El grafo de signals es la única fuente de verdad sobre qué actualizar y cuándo.

    Angular no inventó este modelo — lo adoptó y lo llevó a producción a escala. Solid.js lo demostró primero, sin Virtual DOM desde el diseño inicial. Svelte llega a un resultado parecido compilando la reactividad en tiempo de build. Los tres coinciden en el mismo diagnóstico: comparar árboles es trabajo evitable si sabes de antemano quién depende de quién.

    Si quieres ver cada primitiva documentada en detalle, la guía oficial de Angular Signals cubre signal(), computed() y effect() con más profundidad de la que cabe en un post.

    Si quieres ver cómo se construye ese grafo de dependencias paso a paso — incluyendo los casos raros donde un effect() se dispara más veces de las que esperas — lo cubrí a fondo en el post sobre el grafo reactivo de Angular Signals.

    En el curso de Angular Moderno construimos este modelo mental desde cero, con proyectos reales donde pasar de Zone.js a zoneless cambia decisiones de arquitectura, no solo de sintaxis.

    Los tres paradigmas, uno al lado del otro

    Modelo mental Cómo detecta cambios Granularidad de la actualización Coste computacional Dónde brilla
    Callbacks (jQuery / DOM imperativo) Tú mutas el DOM a mano, evento por evento No detecta nada — el developer decide cuándo actualizar La que tú programes, elemento por elemento Bajo por operación, alto en mantenimiento y bugs de sincronización Widgets aislados, prototipos, páginas sin estado compartido
    Virtual DOM (React) La UI es una función pura del estado Diffing — compara árbol virtual anterior vs. nuevo Por componente/subárbol, tras re-ejecutar y comparar Re-ejecuta la función de render completa y diffea en cada cambio Apps con estado complejo, equipos grandes, ecosistema maduro
    Signals (Angular, Solid, Svelte) Grafo de dependencias reactivas Suscripción directa — el signal sabe quién lo consume El binding o nodo exacto del DOM que depende del valor Solo se ejecuta lo que realmente cambió UI de alta frecuencia de actualización, listas grandes, apps sensibles a rendimiento

    Por qué la reactividad fina no es una moda

    Cada era resolvió el cuello de botella real de la anterior — no la anterior en abstracto, la anterior en producción.

    Los callbacks resolvieron "cómo reacciono a un evento del usuario". Fue suficiente mientras la UI tenía poco estado compartido. Dejó de serlo en cuanto una sola acción tenía que actualizar cinco sitios distintos de la pantalla.

    El Virtual DOM resolvió "cómo mantengo la UI declarativa sin perder la cordura sincronizando elementos a mano". A cambio, aceptó un coste: recalcular y comparar árboles que, la mayoría de las veces, apenas habían cambiado.

    Signals resuelve el cuello de botella que el Virtual DOM introdujo: cómo evitar recalcular y comparar lo que ya sabías que no había cambiado. No es una versión "más rápida" de React. Es una respuesta distinta a la misma pregunta de fondo: ¿qué es lo mínimo que tengo que actualizar para que la UI diga la verdad?

    Esto no significa que el Virtual DOM esté acabado, ni que debas reescribir tu app de React mañana.

    Significa que si estás arrancando un proyecto hoy, entender este modelo ya no es opcional — es la diferencia entre construir sobre un patrón que resuelve el problema en su raíz o sobre uno que lo parchea con memoization.

    Esta decisión de arquitectura — dónde vive el estado, cómo fluye, qué parte del sistema es responsable de mantenerlo sincronizado con la UI — es exactamente el tipo de decisión que trato en el post sobre Clean Architecture para frontend con IA: la reactividad que elijas no es un detalle de implementación, es una decisión que carga con consecuencias durante años.

    Si vas a construir con signals en producción, en algún momento necesitarás verificar que esos computed() y effect() se comportan como esperas bajo distintos escenarios — eso es justo lo que trabajamos con casos reales en el curso de Testing en Angular con Jest y Testing Library.

    Y si quieres discutir esto con otros developers que están tomando las mismas decisiones ahora mismo, en Dominicode Labs es donde pasa esa conversación cada semana.

    Preguntas frecuentes sobre programación reactiva en el frontend

    ¿Qué es la programación reactiva?

    Es el paradigma en el que la interfaz se actualiza automáticamente cuando cambia el estado del que depende, sin que el desarrollador tenga que sincronizarla a mano evento por evento. Los tres modelos de este post — callbacks, Virtual DOM y signals — son formas distintas de resolver ese mismo problema, con más o menos reactividad real incorporada al framework.

    ¿El Virtual DOM está muerto?

    No. Sigue siendo el modelo dominante en producción — React tiene el ecosistema, el talento disponible y millones de líneas de código funcionando con él hoy. Lo que cambió es que ya no es la única opción seria para UI compleja: Signals, Solid.js y Svelte demuestran que el diffing es una solución al problema, no la única posible.

    ¿Los Signals reemplazan a React?

    No en el sentido de que React vaya a desaparecer. Angular con Signals, Solid.js y Svelte son alternativas con un modelo distinto, no reemplazos del ecosistema React. Sí es cierto que la presión competitiva ya empujó a React hacia herramientas como React Compiler, que intenta automatizar la memoization que antes hacías a mano.

    ¿Qué es la reactividad fina (fine-grained reactivity)?

    Es un modelo donde cada pieza de estado (signal) mantiene una lista explícita de quién depende de ella — otros signals derivados (computed) o efectos secundarios (effect). Cuando el valor cambia, solo se re-ejecuta lo que está suscrito a ese valor específico, sin comparar árboles ni recalcular lo que no depende de ese dato.

    ¿Angular usa Virtual DOM?

    No, y nunca lo usó. Angular usaba Zone.js y un mecanismo de change detection basado en recorrer el árbol de componentes buscando cambios. Con Signals y el modo zoneless, por defecto desde Angular v22, Angular elimina también ese recorrido: el grafo de signals le dice exactamente qué actualizar, sin Zone.js y sin diffing.

    ¿Debo migrar mi app de React a Signals?

    No si tu app funciona bien y el equipo domina React. La reactividad fina brilla en escenarios concretos: dashboards con actualizaciones muy frecuentes, listas grandes, apps donde el rendimiento de render es un cuello de botella medido, no sospechado. Si estás empezando un proyecto nuevo, sí vale la pena evaluar Angular v22 con Signals como opción seria.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Next.js 16.3 Instant Navigations: cero esperas al navegar

    Next.js 16.3 Instant Navigations: cero esperas al navegar

    Llevaba semanas con una queja recurrente de un cliente. Su app en Next.js con App Router se sentía lenta. No el server, no la base de datos — las navegaciones. Hacías clic en un enlace y durante un segundo entero no pasaba nada. Literalmente nada. Next.js 16.3 Instant Navigations es la respuesta directa a ese problema.

    En un modelo server-driven, cada navegación implica un roundtrip de red. El cliente espera, el servidor procesa, responde, aparece la página. En una SPA ese segundo no existe — el cliente muestra una shell inmediata mientras los datos llegan. Next.js había apostado por el servidor, pero el coste en percepción de velocidad era real.

    Nota de versión: Next.js 16.3 está actualmente en preview (instalable con npm install next@preview). Las APIs que describo aquí son las publicadas el 25 de junio de 2026 en el blog oficial de Next.js. Pueden cambiar antes del release estable.


    El problema que 16.3 viene a resolver

    En Next.js clásico con Server Components, el flujo de navegación era este:

    1. El usuario hace clic en un enlace.
    2. El navegador no hace nada visible.
    3. El servidor procesa la ruta, genera el HTML, responde.
    4. La página aparece.

    Para apps orientadas a contenido — un blog, un periódico — esto funciona. Para dashboards, herramientas internas, apps tipo SaaS, ese segundo de nada destruye la experiencia.

    Las SPAs resuelven esto de otra forma: descargan el código de cada ruta de antemano y muestran una shell inmediata mientras los datos llegan. Sensación instantánea. Next.js tenía el prefetching, pero lo hacía a nivel de link individual, lo que generaba decenas de peticiones al servidor cada vez que el usuario hacía scroll por una lista de enlaces.

    16.3 cambia los dos flancos del problema.


    Cómo funciona Instant Navigations

    Paso 1: habilitar Cache Components

    Todo empieza con un flag en next.config.ts:

    // next.config.ts
    import type { NextConfig } from 'next';
    
    const nextConfig: NextConfig = {
      cacheComponents: true,
    };
    
    export default nextConfig;
    

    Este flag activa el modelo de Cache Components — el nuevo paradigma de Next.js donde el caching es explícito con 'use cache' en lugar de implícito y confuso como en versiones anteriores. Con él habilitado, Next.js puede generar un "shell" para cada ruta: la parte de la UI que puede renderizarse sin esperar al servidor.

    Paso 2: elegir el modo de cada ruta — Stream, Cache o Block

    Con Cache Components activo, cuando una ruta hace await a datos del servidor, Next.js en desarrollo te muestra un panel llamado Instant Insights. Este panel detecta qué rutas están bloqueando la navegación y te da tres opciones:

    Stream con <Suspense>

    La ruta muestra inmediatamente una shell con estados de carga, y los datos se van incluyendo por streaming conforme llegan:

    // app/products/[id]/page.tsx
    import { Suspense } from 'react';
    import { ProductDetail } from './product-detail';
    import { ProductSkeleton } from './product-skeleton';
    
    export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
      const { id } = await params;
      return (
        <div>
          <h1>Producto</h1>
          <Suspense fallback={<ProductSkeleton />}>
            <ProductDetail id={id} />
          </Suspense>
        </div>
      );
    }
    

    La navegación es inmediata. El usuario ve la shell con el skeleton. Los datos llegan después. Sensación de SPA.

    Cache con 'use cache'

    Si la ruta depende de datos que se pueden cachear, marcas la función con 'use cache' y Next.js sirve el resultado cacheado de forma instantánea en navegaciones posteriores:

    // app/dashboard/analytics/page.tsx
    import { unstable_cacheLife as cacheLife } from 'next/cache';
    import { Suspense } from 'react';
    
    async function AnalyticsSection() {
      'use cache';
      cacheLife('minutes'); // TTL de caché explícito
      const data = await fetchAnalytics();
      return <Chart data={data} />;
    }
    
    export default function AnalyticsPage() {
      return (
        <Suspense fallback={<AnalyticsSkeleton />}>
          <AnalyticsSection />
        </Suspense>
      );
    }
    

    El usuario ve el contenido cacheado de forma inmediata. Si el cache está fresco, la experiencia es idéntica a una SPA.

    Block: cuando quieres que la navegación espere al servidor

    Hay casos donde no quieres mostrar una shell. Un blog no debería mostrar un spinner donde va el artículo — o muestras el artículo o no navegas. Para esos casos, exportas instant = false:

    // app/blog/[slug]/page.tsx
    export const instant = false; // Esta ruta bloquea hasta tener respuesta del servidor
    
    export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
      const { slug } = await params;
      const post = await getPost(slug);
      return <Article post={post} />;
    }
    

    Next.js deja de reportar esta ruta como problema de rendimiento. Has decidido conscientemente que prefieres la espera a mostrar una UI incompleta. La diferencia es que ahora es una decisión explícita, no un comportamiento por defecto que no entiendes.


    Partial Prefetching: prefetchear smarter, no harder

    El segundo gran cambio es cómo Next.js hace prefetching.

    En 16.2, si tenías una lista de veinte enlaces a /chat/[id], Next.js enviaba veinte peticiones de prefetch al servidor — una por link visible en el viewport. Ineficiente y costoso.

    En 16.3, con Partial Prefetching habilitado, Next.js prefetchea un shell por ruta, no por link. Veinte links a /chat/[id] generan exactamente una petición: la del shell de /chat/[id]. Ese shell se cachea en el cliente durante toda la sesión.

    Para habilitarlo:

    // next.config.ts
    import type { NextConfig } from 'next';
    
    const nextConfig: NextConfig = {
      cacheComponents: true,
      partialPrefetching: true,
    };
    
    export default nextConfig;
    

    Prefetching por link cuando necesitas más

    El Partial Prefetching es conservador por diseño — solo prefetchea el shell. Si quieres que un link concreto prefetchee también contenido específico, añades prefetch={true} al componente <Link>:

    // Una lista donde quieres que el header del chat se vea instantáneamente
    export function ChatList({ chats }: { chats: Chat[] }) {
      return (
        <ul>
          {chats.map(chat => (
            <li key={chat.id}>
              {/* Prefetch completo para este link */}
              <Link href={`/chat/${chat.id}`} prefetch={true}>
                {chat.title}
              </Link>
            </li>
          ))}
        </ul>
      );
    }
    

    Y si quieres que el prefetch incluya contenido dinámico de request-time (no solo build-time), lo permites explícitamente en la ruta:

    // app/chat/[id]/page.tsx
    export const prefetch = 'allow-runtime';
    

    La ventaja respecto al comportamiento anterior: ya no es todo o nada. Tienes granularidad real.


    Navigation Inspector: ve el shell antes de que el usuario llegue

    El Navigation Inspector es una herramienta de las Next.js DevTools que pausa cualquier navegación en el momento exacto del shell — antes de que lleguen los datos del servidor — mostrando visualmente qué partes de la ruta son instantáneas y cuáles requieren una petición de red.

    En la práctica: haces clic en un enlace, el inspector lo detiene en el shell, ves el mapa completo de tu ruta. Cuando haces clic en "Resume", la navegación completa. Especialmente útil para identificar componentes que bloquean la navegación porque hacen await sin <Suspense> ni 'use cache'.


    Testing: el helper instant() para Playwright

    Para que las mejoras de rendimiento no retrocedan con refactorizaciones futuras, Next.js 16.3 incluye un test helper para Playwright:

    // tests/navigation.spec.ts
    import { expect, test } from '@playwright/test';
    import { instant } from '@next/playwright';
    
    test('el header del producto aparece sin esperar al servidor', async ({ page }) => {
      await page.goto('/products/shoes');
    
      // Todo lo que esté dentro de este bloque debe ser visible SIN red
      await instant(page, async () => {
        await page.click('a[href="/products/hats"]');
        await expect(page.locator('h1')).toContainText('Baseball Cap');
        await expect(page.getByText('Checking inventory...')).toBeVisible();
      });
    
      // Esto sí puede esperar al servidor
      await expect(page.getByText('12 in stock')).toBeVisible();
    });
    

    instant() es el equivalente a un test de performance integrado en tu suite de e2e. Si un refactor convierte una ruta Stream en una ruta bloqueante, el test falla. Sin sorpresas en producción.

    Si ya tienes tests de Angular y quieres aplicar la misma mentalidad a tus proyectos — testear comportamiento, no implementación — el curso de Testing en Angular te da esa base de forma sólida con Jest y Testing Library.


    Comparativa: antes vs. después

    Aspecto Next.js 16.2 Next.js 16.3
    Comportamiento por defecto Bloquea hasta respuesta del servidor Stream o Cache para navegación inmediata
    Prefetching 1 petición por link en viewport 1 shell por ruta, reutilizado entre links
    Control por ruta No hay export const instant = false para rutas bloqueantes
    Herramienta de diagnóstico Ninguna Instant Insights + Navigation Inspector
    Testing de regresiones Manual instant() helper para Playwright
    Configuración Implícita y confusa Explícita con cacheComponents y partialPrefetching

    Cómo empezar hoy mismo

    Para probar Instant Navigations en un proyecto existente:

    1. Instala el preview:

      npm install next@preview
      
    2. Habilita los flags en next.config.ts:

      const nextConfig: NextConfig = {
        cacheComponents: true,
        partialPrefetching: true,
      };
      
    3. Arranca el servidor de desarrollo. Verás el panel Instant Insights con las rutas que están bloqueando la navegación.

    4. Identifica, decide y verifica. Para cada ruta bloqueante, elige Stream, Cache o Block. El Navigation Inspector confirma que el shell funciona antes de ir a producción.

    El equipo de Vercel lo validó en v0 — su propia app — antes del release. Los tiempos de navegación bajaron significativamente en las rutas que adoptaron el nuevo modelo.

    Si quieres ver cómo se integra este tipo de arquitectura con IA y streaming en tiempo real, en Dominicode Labs estamos construyendo proyectos que combinan Next.js con streaming de LLMs — exactamente el tipo de apps donde Instant Navigations marca la diferencia más visible. Para entender la Claude API con TypeScript antes de integrarla, este crash course es el punto de partida.


    Casos de uso donde esto cambia más

    Dashboards con datos en tiempo real. Cada cambio de sección era un segundo de espera. Con Stream + Suspense, el layout del dashboard aparece inmediatamente y los datos llegan después.

    Apps de chat o mensajería. Con Partial Prefetching, navegar entre conversaciones — aunque sean decenas — genera una sola petición de prefetch por ruta, no una por cada enlace visible.

    E-commerce. Las páginas de producto pueden mostrar la estructura (imagen placeholder, nombre, botón "Añadir al carrito") de forma instantánea mientras el inventario y el precio se cargan.

    Herramientas internas con muchas secciones. El menú lateral con 30 links ya no genera 30 peticiones de prefetch al cargar la página.

    Si trabajas con Astro para partes estáticas de tu sitio y Next.js para las dinámicas, el análisis de Astro v7 te ayuda a decidir qué encaja en cada capa.


    FAQ

    ¿Instant Navigations funciona con el Pages Router o solo con App Router?

    Solo con App Router. Cache Components y el modelo de shells requieren Server Components, que no existen en Pages Router. Si aún tienes un proyecto en Pages Router, esta es una razón más para evaluar la migración.

    ¿cacheComponents: true cambia el comportamiento de caching de mis datos?

    Sí, de forma intencional. El nuevo modelo hace el caching explícito: nada se cachea por defecto a menos que uses 'use cache'. Si venías de fetch con opciones implícitas de cache, tendrás que revisar tu estrategia de datos. Es un cambio de paradigma, no solo un flag de navegación.

    ¿El Partial Prefetching incrementa el coste de servidor?

    Al contrario. En 16.2, con veinte links en pantalla tenías veinte peticiones de prefetch. En 16.3, con Partial Prefetching, tienes una petición por ruta distinta. En un escenario real con listas de items que apuntan al mismo route pattern, la reducción de peticiones puede ser del 90%.

    ¿Puedo usar <Link prefetch={true}> para todo y obtener el comportamiento anterior?

    Técnicamente sí, pero estarías ignorando el punto. El comportamiento anterior era ineficiente. <Link prefetch={true}> existe para casos específicos donde necesitas prefetchear más que el shell en un link concreto — no como reemplazo global del viejo modelo.

    ¿Cuándo sale el release estable de Next.js 16.3?

    No hay fecha oficial confirmada. El equipo indica que están resolviendo issues conocidos (algunos casos con Safari en Instant Insights, y rutas bloqueantes que no se reportan correctamente con Partial Prefetching activo). La recomendación es probar el preview en proyectos de desarrollo, no en producción.

    ¿Puedo escribir tests con instant() antes del release estable?

    Sí. El paquete @next/playwright ya incluye el helper y es seguro usarlo en tu suite de e2e. Si el release estable cambia el comportamiento, los tests te lo dirán antes de que llegue a producción.


    Next.js 16.3 no reinventa el framework. Lo que hace es cerrar la brecha más molesta que tenían los Server Components: la sensación de lentitud al navegar. Stream, Cache y Block son tres palabras, pero detrás hay un modelo de pensamiento claro sobre qué parte de tu UI puede ser instantánea y cuál no.

    La clave no está en activar los flags y esperar magia. Está en recorrer tus rutas con el Navigation Inspector, entender qué está bloqueando, y tomar la decisión correcta para cada una.

    Si tu próximo proyecto combina Next.js con agentes de IA o herramientas de Claude Code, en el curso Construye con IA vemos exactamente cómo estructurar apps que necesitan streaming, caché inteligente y navegación fluida desde el primer día.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Cómo funcionan los Signals en Angular 22 y React 19

    Cómo funcionan los Signals en Angular 22 y React 19

    Signals en Angular 22 y React 19: el nuevo modelo de reactividad

    Tiempo estimado de lectura: 4 min

    Ideas clave

    • Reactividad de grano fino actualiza solo los nodos del DOM que dependen de un valor.
    • Angular 22 introduce Signals explícitos, elimina Zone.js y ofrece formularios sincronizados basados en Signals.
    • React 19 apuesta por optimizaciones vía compilador y el hook use() en lugar de un primitivo signal.
    • Elegir entre ambos depende de control/depurabilidad (Angular) vs. fricción y compatibilidad con código existente (React).

    Tabla de contenidos

    Signals en Angular 22 y React 19: el nuevo modelo de reactividad es la discusión que está redefiniendo cómo pensamos la UI: menos trozos de árbol reevaluados, más actualizaciones puntuales y menos sorpresas en producción. Si tu equipo decide entre control explícito o automatización por compilador, este artículo te da criterios prácticos y ejemplos reales para elegir con criterio.

    Resumen rápido (lectores con prisa)

    Fine-grained reactivity actualiza solo dependencias directas. Angular 22 introduce Signals (signal(), computed(), effect()) y formulas sin Zone.js. React 19 usa el React Compiler para inferir memoización y añade use() para leer Promises/recursos en render. Ambos mejoran escalabilidad; Angular es explícito y más trazable, React reduce fricción de adopción.

    Signals en Angular 22 y React 19: el nuevo modelo de reactividad (explicación rápida)

    La reactividad de grano fino significa actualizar únicamente el nodo del DOM que depende de un valor concreto. Angular 22 lo hace declarando Signals (signal(), computed(), effect()), eliminando Zone.js y ofreciendo formularios basados en Signals. React 19 opta por no añadir un primitivo signal; en su lugar usa el React Compiler para inferir memoización y añade el hook use() para leer Promises/recursos en render. Documentación oficial Angular. Blog oficial React 19.

    ¿Por qué importa la reactividad de grano fino?

    Los problemas reales aparecen en aplicaciones con alta densidad de datos:

    • Dashboards financieros con cientos de celdas que actualizan simultáneamente.
    • Formularios complejos con validaciones cruzadas y campos dependientes.
    • UIs que requieren latencia mínima y CPU predecible en clientes de bajo rendimiento.

    La solución tradicional (Virtual DOM diffs o Zone.js) escala mal: consumes CPU revisando cosas que no cambiaron. Fine-grained reactivity evita ese trabajo inútil.

    Angular 22: Zoneless, Signals y formularios sincronizados

    Angular reescribió su motor de detección. Resultado práctico:

    • Renderizado Zoneless: sin interceptar microtasks; si no cambia un Signal, no hay re-render.
    • Signals explícitos: control total sobre qué es reactivo y cuándo muta.
    • Signal-based Forms: lectura síncrona del estado del formulario, menos RxJS, menos suscripciones que se filtran.

    Ejemplo Angular

    import { signal } from '@angular/core';
    
    const count = signal(0);
    count.set(count() + 1); // solo actualiza los lectores de `count`

    Para convivir con código existente, Angular ofrece utilidades toSignal() / toObservable(), facilitando migraciones incrementales. Guía.

    Ventajas concretas: trazabilidad, depuración directa (sabes qué mutó), rendimiento determinista. Coste: curva de aprendizaje y refactor en bases de código grandes.

    React 19: Reactividad inferida vía compilador y hook use()

    React evita imponer nuevos primitivos. Estrategia:

    • React Compiler: analiza en build y genera memos/mecanismos de actualización automáticos.
    • use(): permite consumir Promises o recursos directamente en render, funcionando con <Suspense> para carga declarativa.
    • Server Actions / useActionState: reduce boilerplate del ciclo formulario → servidor → feedback.

    Ejemplo React

    function Product({ id }) {
      const product = use(fetchProduct(id)); // Suspense maneja loading
      return <div>{product.name}</div>;
    }

    Ventajas: baja fricción de adopción; equipo no reescribe mentalmente la app. Coste: optimizaciones «invisibles» por el compilador que pueden complicar diagnóstico fino; depuración menos directa que en Angular.

    React Suspense referencia

    Comparativa práctica (qué esperar en producción)

    • Performance pura: ambos escalan mucho mejor que modelos antiguos. Angular da mayor predictibilidad por su modelo explícito; React consigue grandes ganancias sin romper DX.
    • Depuración: Angular facilita trazar el origen del update; en React necesitas entender qué transformó el compilador.
    • Migración: Angular exige trabajo incremental (conversión de formularios y algunos patrones de RxJS). React permite migración más suave, porque el compilador optimiza el código existente.
    • Formularios complejos: Angular gana por tipado y sincronía; React compensa con Server Actions para patrones CRUD.

    Recomendaciones prácticas para equipos

    1. Haz un piloto con módulos concretos. No migres todo de golpe.
    2. Para UIs de alta densidad de datos, prioriza Angular 22 si necesitas control y trazabilidad estricta.
    3. Si tu stack ya es Next.js / SSR y quieres mejorar rendimiento sin reeducar al equipo, React 19 es opción pragmática.
    4. Añade pruebas de rendimiento (microbenchmarks) y observabilidad: mide renders por segundo, tamaño de paint y memoria.
    5. Documenta patrones: en Angular, establece cómo y cuándo crear Signals; en React, especifica cómo instrumentar y auditar transformaciones del compilador.

    Conclusión práctica

    Signals en Angular 22 y React 19 solucionan el mismo problema con filosofías distintas: Angular te da el control explícito; React te lo facilita automáticamente. No hay «mejor» universal: hay mejor para tu equipo. Si quieres predictibilidad y depurabilidad en sistemas críticos, apuesta por Angular 22. Si prefieres un camino de menor fricción y eres heavy-SSR, React 19 acelera el time-to-market. Dominar fin-grained reactivity es ahora requisito, no lujo.

    FAQ

    Respuesta: La reactividad de grano fino actualiza solo los nodos del DOM que dependen de un valor específico, en lugar de reevaluar grandes porciones del árbol. Reduce trabajo innecesario de CPU y mejora latencia en UIs densas.

    Respuesta: Angular 22 elimina la dependencia de Zone.js y usa Signals declarativos. En lugar de interceptar microtasks para detectar cambios, los Signals notifican solo a sus lectores cuando cambian, proporcionando renders deterministas.

    Respuesta: No: React 19 no introduce un primitivo signal. Usa el React Compiler para inferir memoización y optimizaciones, y añade use() para consumo de Promises/recursos en render.

    Respuesta: Los Signal-based Forms permiten lectura síncrona del estado del formulario, reducen la necesidad de RxJS y evitan suscripciones filtradas. Mejoran trazabilidad y simplifican validaciones dependientes.

    Respuesta: Haz un piloto. Si necesitas control y trazabilidad estricta para sistemas críticos, Angular 22 es preferible. Si buscas mínima fricción y tu stack ya usa SSR/Next.js, React 19 reduce fricción de adopción.

    Respuesta: Parcialmente. Angular ofrece utilidades como toSignal() / toObservable() para migraciones incrementales, pero adaptar formularios y patrones RxJS puede requerir refactor. React 19 suele permitir migración más suave gracias al compilador.

  • Errores comunes al migrar a React Server Components en producción

    Errores comunes al migrar a React Server Components en producción

    React Server Components en producción: errores que nadie te cuenta

    Tiempo estimado de lectura: 5 min

    • Fronteras claras: mezclar datos pesados del servidor con Client Components provoca serialización y payloads enormes.
    • No convertir todo a client: usar “use client” globalmente anula los beneficios de RSC y regresa a una SPA pesada.
    • Latencia y caching: llamadas secuenciales y caché agresiva generan TTFB alto y fugas de datos entre usuarios.
    • Audita dependencias: muchas librerías no están preparadas para ejecución en server; lazy-load o wrappers client son necesarios.

    Introducción

    React Server Components en producción: errores que nadie te cuenta. Lo digo sin rodeos: los tutoriales y demos no te preparan para operarlos en tráfico real. En ese salto es donde aparecen fugas de datos, payloads monstruosos y cuellos de botella invisibles que desarman la promesa de “menos JS, mejor rendimiento”.

    Este artículo enumera los fallos concretos que verás en proyectos reales, aporta soluciones técnicas y define cuándo NO migrar a RSC. Incluye referencias y enlaces oficiales para que puedas profundizar: Suspense y caching en Next.js.

    Resumen rápido (lectores con prisa)

    Qué es: Patrón que permite renderizar parte de la UI en el servidor y enviar un árbol serializado al cliente.

    Cuándo usarlo: cuando puedas controlar la frontera server/client, minimizar datos pasados al cliente y beneficiarte de menos JS inicial.

    Por qué importa: mejora rendimiento y seguridad si se adopta con disciplina en serialización, caché y orquestación de datos.

    Cómo funciona: Server Components pueden acceder a recursos de servidor; Client Components se hidratan en cliente y deben recibir solo datos mínimos.

    1. React Server Components en producción: la frontera que rompe todo

    El error raíz es conceptual: tratar la frontera server/client como una línea estética en lugar de una decisión arquitectónica. Un Server Component puede acceder a la BD y luego pasar objetos enormes como props a un Client Component. Eso obliga a React a serializar todo en el HTML/JSON de respuesta. Resultado: la reducción del bundle se convierte en megabytes de payload.

    Ejemplo típico (malo)

    • Server Component hace SELECT * FROM orders WHERE user_id = ? y pasa todos los registros a <OrdersTable use client />.
    • El navegador recibe un payload serializado de decenas de MB.

    Solución: procesar, paginar y resumir en el servidor. Pasa al cliente solo el minimum viable (IDs, count, primeros N items) y provee endpoints client-side para cargar la página de datos al interactuar.

    2. El pánico del “use client” y la regresión a SPA

    Cuando algo falla (proveedores, librerías de UI, hooks), el atajo más común es colocar "use client" en el layout. Eso convierte todo el árbol en Client Components y anula el beneficio de RSC: vuelves a una SPA grande, con mayor complejidad y sin reducción de JS.

    Patrón correcto:

    • Mantén providers y estado en componentes hoja que realmente necesitan interactividad.
    • Diseña la composición para que los Client Components reciban props mínimos y, si requieren datos pesados, llamen a endpoints específicos (fetch desde cliente) o utilicen streaming.

    3. Waterfalls invisibles: el backend secuencial que mata TTFB

    Código like-this en Server Component:

    const user = await getUser(id);
    const prefs = await getPrefs(user.configId);
    const orders = await getOrders(user.id);
    

    Eso es secuencial: suma latencias. Aunque ocurre en servidor, el usuario espera. Paraleleza con Promise.all cuando no hay dependencia, y usa Suspense para streaming progresivo cuando sí hay dependencias parciales.

    Patrón secuencial y solución

    • Identifica llamadas independientes y ejecútalas en paralelo.
    • Usa streaming y Suspense para mostrar partes de la vista cuando están listas.
    • Mide TTFB en staging bajo carga para detectar waterfalls invisibles.

    4. Caché agresiva = fuga de datos entre usuarios

    Next.js y otros frameworks aplican caching por defecto en render server. Si renderizas una ruta con datos privados y no marcas la petición como dinámica, puedes cachear la vista de un usuario y servirla a otro. Es real y está pasando en producción.

    Contramedidas:

    • Para datos privados usa { cache: 'no-store' } en fetch o llama a APIs que leen cookies()/headers() (esto fuerza render dinámico en Next.js).
    • Revisa la documentación de caché de Next.js: caching en Next.js.
    • Considera políticas CDN más conservadoras para rutas autenticadas.

    5. Integraciones de terceros que no están listas para server execution

    Muchas librerías npm asumen un entorno DOM. Al ejecutar en server, aparecen errores en build o comportamiento inesperado. Resultado: el equipo marca "use client" masivo y pierde las ventajas. Revisa dependencias: algunas requieren reemplazo o lazy-loading estricto.

    Táctica práctica:

    • Audita las dependencias con npm ls y pruebas de build en CI que marquen dónde fallan.
    • Si una librería solo se usa en un widget, envuélvela en un Client Component lazy-loaded.

    Cuándo NO usar React Server Components

    No migres a RSC si tu producto encaja en alguno de estos casos:

    • Aplicaciones offline-first o PWAs que deben funcionar sin servidor.
    • Interfaces de hiper-interactividad: editores gráficos, juegos, vídeo en tiempo real o UIs con WebSockets a alta frecuencia.
    • Bases de código legacy sin presupuesto de reescritura: migrar Redux heavy/class components = reescritura, no refactor.

    Checklist práctico antes de migrar a producción

    1. Delimita claramente boundaries: quién corre en server y qué mínima data pasa al cliente.
    2. Añade tests de integración que simulen carga y validen payloads.
    3. Forza políticas de cache por ruta (privada vs pública).
    4. Instrumenta logs de tamaño de respuesta y tokenización/serialización.
    5. Adopta streaming/Suspense para vistas complejas; usa Promise.all para llamadas paralelas.
    6. Audita dependencias y evita convertir el layout en client por comodidad.

    Conclusión: RSC exige disciplina, no solo adopción

    React Server Components entregan ventajas claras (menos JS inicial, mayor seguridad para secretos, mejor SEO). Pero funcionan en producción solo si el equipo re-aprende backend: serialización, caching, latencia y orquestación de datos. La migración exitosa no es técnica aislada; es un cambio de modelo mental: pasar de “componentes” a “árboles de dependencias de red”. Si no estás dispuesto a trazar fronteras con rigor, no migres: estarás complicando tu arquitectura sin ganar sus beneficios.

    FAQ

    ¿Qué es exactamente un React Server Component?

    Un React Server Component se renderiza en el servidor y puede acceder a recursos del backend. No se hidrata en el cliente como un Client Component y se envía serializado al navegador.

    ¿Cuándo debo evitar migrar a RSC?

    Evita migrar si necesitas soporte offline completo, tienes UIs de hiper-interactividad (editores, juegos, video en tiempo real) o una base de código legacy sin presupuesto para reescritura.

    ¿Cómo evito pasar payloads gigantes al cliente?

    No pases objetos completos como props. Resumir, paginar y enviar solo lo mínimo necesario (IDs, count, primeros N items). Usa endpoints client-side para cargar datos adicionales bajo demanda.

    ¿Qué problemas de caché debo vigilar en Next.js?

    Cuidado con el render estático por defecto: rutas con datos privados pueden quedar cacheadas. Para datos privados usa { cache: 'no-store' } en fetch o APIs que lean cookies()/headers() para forzar render dinámico.

    ¿Cómo detectar y arreglar waterfalls en Server Components?

    Mide TTFB en staging bajo carga, revisa llamadas secuenciales en Server Components y paraleliza con Promise.all cuando sea posible. Usa streaming y Suspense para render progresivo.

    ¿Qué hago con librerías que fallan en server?

    Audita dependencias con npm ls, añade pruebas de build en CI y envuelve las librerías problemáticas en Client Components lazy-loaded o busca alternativas compatibles con server execution.

  • Mejorando rendimiento y SEO al migrar de Angular a Next.js 16

    Mejorando rendimiento y SEO al migrar de Angular a Next.js 16

    De Angular a Next.js 16: lo que aprendí migrando un proyecto real

    Tiempo estimado de lectura: 4 min

    • Rendimiento y SEO fueron el motor: migramos por Core Web Vitals malos, TTFB lento y bundle que penalizaba conversión móvil.
    • Server-first cambia la mentalidad: Next.js 16 y React Server Components mueven carga y lógica al servidor, reduciendo JavaScript en cliente.
    • Menos boilerplate para mutaciones: Server Actions permiten llamar funciones server-side desde formularios sin endpoints REST intermedios.
    • Fricciones reales: cache, alcance de “use client” y observabilidad requieren disciplina adicional en producción.

    De Angular a Next.js 16: lo que aprendí migrando un proyecto real empezó como un problema de negocio: Core Web Vitals malos, TTFB lento y un bundle que penalizaba conversión móvil. La migración no fue una moda técnica; fue una necesidad para reducir fricción de usuario y mejorar SEO técnico. Esto marcó cada decisión técnica que tomamos.

    Resumen rápido (lectores con prisa)

    Qué es: Next.js 16 (App Router) usa React Server Components para renderizar HTML en servidor y enviar JavaScript mínimo al cliente.

    Cuándo usarlo: cuando SEO, Core Web Vitals o TTFB afectan métricas de negocio y necesitas ejecutar lógica sensible en servidor.

    Por qué importa: reduce bundle inicial, mejora TTFB y simplifica flujos de datos server-side.

    Cómo funciona (resumen): renderizado server-side con funciones asíncronas para fetch/ORM, Server Actions para llamadas server desde forms y control explícito de caché y revalidación.

    De Angular a Next.js 16: por qué no es solo “aprender otra sintaxis”

    Angular es un framework opinado para SPAs: inyección de dependencias, RxJS y templates declarativos. Next.js 16 (App Router) invierte ese paradigma con React Server Components (RSC): renderizado en servidor, HTML entregado al cliente y JavaScript mínimo para interactividad. Documentación oficial Next.js.

    La diferencia no es menor: pasas de pensar “qué corre en el cliente” a “qué debe correr en el servidor”. Ese cambio impacta performance, seguridad y la forma en que estructuras estado y dependencias.

    Tres lecciones técnicas que cambiaron nuestro código

    1) RxJS se queda fuera del camino principal

    En Angular, RxJS orquesta peticiones, eventos y sincronizaciones. Eso ofrece control fino (cancelaciones, operadores), pero añade complejidad de mantenimiento (unsubscribe, memory leaks).

    En Next.js 16, Server Components son funciones async: await fetch() o llamadas al ORM desde el servidor. La simplicidad reduce boilerplate y evita parpadeos de carga en el cliente. Ejemplo real: reemplazar múltiples subscriptions por una única llamada asíncrona en el server simplificó la lógica y redujo errores de sincronización.

    Nota práctica: para cancelaciones del lado del cliente hay que usar explícitamente AbortController; la ergonomía de RxJS no existe por defecto.

    2) La inyección de dependencias se reimagina

    El contenedor DI de Angular es una comodidad arquitectónica (services providedIn: 'root'). React/Next no tienen un DI integrado. Las alternativas que adoptamos:

    • Instancias únicas exportadas desde módulos ES6 (clientes DB, SDKs).
    • React Context solo para estado UI que vive en cliente (tema, sesión).
    • Props/Composición para inyección explícita en componentes que dependen de servicios.

    Resultado: más explicitud y trazabilidad, pero más disciplina para no propagar dependencias globales por accidente.

    3) Server Actions: menos endpoints, menos boilerplate

    Migrar formularios del flujo Angular (form → HttpClient → endpoint REST → backend) a Server Actions colapsó la cadena. En Next.js 16 puedes llamar funciones en el servidor directamente desde el form:

    export async function updateUser(formData: FormData) {
      'use server';
      const name = formData.get('name') as string;
      await db.user.update({ where: { id: session.userId }, data: { name } });
      revalidatePath('/profile');
    }

    El beneficio es claro: menos endpoints internos y menos código repetitivo. El riesgo: mezclar lógica de negocio en componentes si no separamos responsabilidades adecuadamente. Docs de Server Actions

    Fricciones reales que te van a doler en producción

    • Cache y freshness: Next.js App Router tiene capas de caché (memoization, data cache, route cache). Sin revalidate o cache: 'no-store' puedes servir datos obsoletos. Leer.
    • “use client” propagate cost: marcar un componente como cliente arrastra su subárbol y puede romper los beneficios del SSR si importas librerías pesadas.
    • Observabilidad de comportamiento: la frontera servidor/cliente exige testing más exhaustivo (end-to-end + integración server actions) y pipelines de CI que validen rendimiento.
    • Seguridad y surface area: Server Actions facilitan lógica server-side, pero exigen revisar permisos y sanitización con más rigor.

    Criterio práctico para Tech Leads: ¿vale la pena migrar?

    No migres por moda. Migra si:

    • Tu producto es público y SEO o Core Web Vitals impactan conversiones (ver métricas en web.dev/vitals).
    • El bundle inicial y TTFB están bloqueando métricas de negocio.
    • Necesitas ejecutar lógica sensible en servidor para reducir exposición o proteger IP.

    Mantén Angular si:

    • Es un dashboard interno con poca necesidad SEO.
    • El equipo domina RxJS y la arquitectura actual es sostenible.
    • El coste de migración supera el beneficio económico esperado.

    Conclusión

    La migración De Angular a Next.js 16: lo que aprendí migrando un proyecto real fue menos una reescritura técnica y más una reorganización de responsabilidades: qué corre en el servidor, cómo se inyectan dependencias y cómo se gestionan mutaciones. Next.js 16 ofrece ganancias reales en rendimiento y simplicidad operativa, pero exige disciplina (caché, límites use client, separación de responsabilidades). Si tu negocio lo justifica, la inversión devuelve rendimiento y una arquitectura más alineada con un futuro server-first. Si no, Angular sigue siendo una opción sólida y productiva.

    FAQ

    Respuesta: Migramos porque Core Web Vitals malos, TTFB lento y un bundle grande estaban afectando conversión móvil y SEO. La migración fue una decisión de negocio para reducir fricción de usuario y mejorar SEO técnico.

    Respuesta: No hay un reemplazo directo. En Next.js 16 se usa programación asíncrona en Server Components (await fetch(), llamadas al ORM) y, para cancelaciones cliente, AbortController. La lógica de orquestación que RxJS ofrecía suele simplificarse en el servidor o con patrones de composición en cliente.

    Respuesta: Server Actions son funciones que se ejecutan en el servidor y se pueden invocar desde formularios en el cliente. Reducen la necesidad de endpoints REST intermedios. Requieren separar responsabilidades para no mezclar lógica de negocio en componentes. Más detalles.

    Respuesta: Los riesgos principales son caché y freshness (servir datos obsoletos sin revalidate), el coste de marcar componentes como cliente que arrastran subárboles pesados y la necesidad de mayor observabilidad y testing para la frontera servidor/cliente.

    Respuesta: No conviene migrar si el proyecto es un dashboard interno con poca necesidad SEO, si el equipo domina la arquitectura actual o si el coste de migración supera el beneficio económico esperado.

    Respuesta: Usar instancias únicas exportadas desde módulos ES6 (clientes DB, SDKs), React Context para estado UI cliente y props/composición para inyección explícita en componentes. Esto aporta trazabilidad a costa de disciplina para evitar dependencias globales indeseadas.

  • Aprende a tipar correctamente props, hooks y contextos en TypeScript y React

    Aprende a tipar correctamente props, hooks y contextos en TypeScript y React

    TypeScript + React: cómo tipar correctamente props, hooks y contextos

    Tiempo estimado de lectura: 4 min

    • Tipado explícito evita errores silenciosos: evita atajos como as any y prefiere contratos claros.
    • Props y refs: evita React.FC, usa referencias DOM con null inicial y valores mutables con valor inicial.
    • Contextos seguros: inicializa con null y expón hooks que hagan fail-fast si se usan fuera del provider.
    • Handlers y hooks: aprovecha los tipos de React (ChangeEvent, FormEvent) y deja que TS infiera cuando sea seguro.

    ¿Quieres dejar de parchear bugs con as any y que tu base de código deje de tener sorpresas en producción? Bien. Esto es lo que realmente necesitas saber sobre TypeScript + React: cómo tipar correctamente props, hooks y contextos. No es teoría. Son patrones que evitan errores silenciosos, mejoran el autocompletado y hacen que el código sea mantenible cuando el equipo crece.

    Resumen rápido (lectores con prisa)

    Tipar React con TypeScript reduce errores en producción y mejora DX. Evita React.FC, inicializa contextos con null y valida con hooks, usa refs con null para DOM y valores iniciales para mutables, y aprovecha los tipos sintéticos de eventos de React.

    Evita React.FC: tipa los parámetros explícitamente

    React.FC fue útil en tutoriales, pero introduce problemas: children implícitos, genéricos torpes y ruido. Tipar la función es más claro y explícito.

    interface ButtonProps {
      label: string;
      onClick: () => void;
      variant?: 'primary' | 'secondary';
      children?: React.ReactNode;
    }
    
    export function Button({ label, onClick, variant = 'primary', children }: ButtonProps) {
      return <button className={`btn-${variant}`} onClick={onClick}>{children ?? label}</button>;
    }
    

    React.ReactNode cubre todo lo que necesitas para children. Punto.

    useState: deja que TS infiera cuando pueda, explícito cuando haga falta

    Si el estado empieza con un primitivo, no especifiques el tipo. Si empieza vacío y luego será un objeto, usa una unión con null.

    interface User { id: string; email: string; }
    
    const [count, setCount] = useState(0);            // OK, inferido
    const [user, setUser] = useState<User | null>(null); // OK, explícito
    

    ¿Por qué? Porque evitarás tener que castear más adelante y te proteges contra undefined al acceder a propiedades.

    useRef: dos usos, dos reglas

    useRef sirve para referencias DOM y para valores mutables que no disparan re-render. Los tipos cambian según el valor inicial.

    • DOM refs: inicializa con null y maneja optional chaining.
    • Valores mutables: inicializa con el valor y muta .current.
    const inputRef = useRef<HTMLInputElement | null>(null);
    const renderCount = useRef(0);
    
    inputRef.current?.focus();
    renderCount.current += 1;
    

    No uses as para saltarte el null check. Esa falsedad te estallará en runtime.

    Eventos del DOM: tipa cada handler

    No uses any. React expone tipos sintéticos bien definidos. Úsalos y disfruta del autocompletado.

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      console.log(e.target.value);
    };
    
    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault();
    };
    

    Esto evita errores tontos como leer propiedades inexistentes.

    useContext: seguro, explícito y con fail-fast

    No hagas createContext({} as ThemeContext). Ese as silencia al compilador y deja el error para producción.

    Patrón correcto: contexto con null y hook personalizado que comprueba la presencia del provider.

    interface ThemeContextType { theme: 'light'|'dark'; toggle: () => void; }
    const ThemeContext = createContext<ThemeContextType | null>(null);
    
    export function useTheme() {
      const ctx = useContext(ThemeContext);
      if (!ctx) throw new Error('useTheme debe usarse dentro de ThemeProvider');
      return ctx;
    }
    

    Fail-fast: si alguien usa el hook fuera del provider, fallas rápido y el stack trace dice dónde.

    forwardRef: firma invertida, atención al tipo genérico

    La firma de forwardRef es contraintuitiva: el primer genérico es el tipo de ref, el segundo las props.

    interface InputProps { label: string }
    
    export const CustomInput = forwardRef<HTMLInputElement, InputProps>(({ label, ...props }, ref) => (
      <label>
        {label}
        <input ref={ref} {...props} />
      </label>
    ));
    CustomInput.displayName = 'CustomInput';
    

    Siempre define displayName para facilitar debugging en React DevTools.

    Tips prácticos que cambian proyectos

    • Exporta type con export type cuando sean solo contratos. Eso deja claro que no hay runtime.
    • No uses as para “callar” al compilador. Es un atajo que se vuelve deuda.
    • Si necesitas tipos genéricos en componentes, tipa explícitamente props y evita React.FC.
    • Para APIs y carga asíncrona, combina Zod (o similar) con z.infer si necesitas validación runtime y tipos derivados.

    Checklist rápido antes de push

    • ¿Contextos inicializados con null y validados por hooks? ✔
    • ¿useRef con null para DOM y con valor inicial para mutables? ✔
    • ¿Handlers con React.ChangeEvent / FormEvent? ✔
    • ¿No hay as any salvo casos documentados? ✔

    Cierra con criterio

    Tipar React no es un ejercicio académico. Es la forma más barata de prevenir fallos en producción y mejorar el DX de tu equipo. Haz estas tres cosas hoy:

    1. Revisa contextos: elimina as y añade hooks defensivos.
    2. Estándariza useRef y useState según lo explicado.
    3. Añade displayName a los componentes con forwardRef.

    Aplica esto en tu repo. Si algo rompe después, sabrás exactamente por qué. Esto no acaba aquí. Hay más patrones (componentes polimórficos, inferencia con generics, overloads en hooks) que merecen otra nota.

    FAQ

    ¿Por qué evitar React.FC?

    Porque introduce children implícitos, dificulta genéricos y añade ruido. Tipar explícitamente los parámetros es más claro y evita sorpresas.

    ¿Cuándo especificar el tipo en useState?

    No lo especifiques si el estado inicia con un primitivo (deja que TS infiera). Si el estado inicia vacío y luego será un objeto, usa una unión con null (por ejemplo User | null).

    ¿Cómo tipar correctamente useRef para DOM?

    Inicializa la ref con null y usa el tipo del elemento: useRef<HTMLInputElement | null>(null). Accede con optional chaining (inputRef.current?.focus()).

    ¿Qué hacer si alguien usa un context fuera del provider?

    Exponer un hook que haga fail-fast: si el contexto es null, lanzar un error claro (por ejemplo throw new Error('useTheme debe usarse dentro de ThemeProvider')).

    ¿Es aceptable usar as en alguna situación?

    Evita as salvo casos documentados y justificables. Usarlo para “callar” al compilador oculta problemas que aparecerán en runtime.

    ¿Cómo mejorar la validación de APIs y mantener tipos?

    Combina validación runtime con librerías como Zod y usa z.infer para derivar tipos TypeScript a partir de los esquemas de validación.