Category: JavaScript

  • 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.

  • Optimización extrema de rendimiento y consumo de memoria en Next.js 16

    Optimización extrema de rendimiento y consumo de memoria en Next.js 16

    Hace unos meses recibí una llamada de emergencia de un equipo que acababa de desplegar su aplicación de comercio electrónico construida sobre Next.js. El servidor Node.js en producción colapsaba cada 4 horas con el temible mensaje FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory.

    Su solución temporal era programar un reinicio automático del contenedor Docker cada 3 horas. Un parche espantoso para disimular un problema de arquitectura grave.

    El equipo culpaba a Node.js y a los servidores de Vercel. Pero al auditar el perfil de memoria, descubrimos que los desarrolladores estaban reteniendo objetos gigantescos en la caché de Server Components y desbordando la memoria durante la hidratación de datos.

    Next.js 16 introduce avances masivos en la gestión de memoria y compilación, pero si no entiendes cómo funciona su motor bajo el capó, es ridículamente fácil introducir memory leaks en producción.

    El espejismo de los Server Components sin estado

    Existe el mito de que los React Server Components (RSC) son inmunes a las fugas de memoria porque se ejecutan en el servidor y solo envían HTML/JSON al cliente.

    La realidad es que en el servidor, cada petición HTTP mantiene en memoria el árbol de renderizado del componente hasta que se completa la respuesta. Si dentro de un Server Component:

    • Suscribes escuchadores de eventos globales que no se destruyen.
    • Almacenas buffers de imágenes o respuestas API masivas en variables fuera de la función del componente.
    • Abres conexiones de base de datos dentro del render sin un pool reutilizable.

    Estás acumulando megabytes de basura retenida en la memoria Heap de Node.js en cada petición de usuario.

    Como ya explicamos en nuestro análisis detallado sobre la reducción de memoria en builds de Next.js, separar la memoria del compilador de la memoria en tiempo de ejecución es el primer paso para diagnosticar estos fallos.

    3 Estrategias para Optimizar Next.js 16 en Producción

    1. Gestión Inteligente de Caché de Datos (unstable_cache & PPR)

    En Next.js 16, la caché de peticiones debe configurarse explícitamente utilizando etiquetas de revalidación (revalidateTag) en lugar de almacenar respuestas masivas en memoria global:

    import { unstable_cache } from 'next/cache';
    
    export const getProductoDestacado = unstable_cache(
      async (id: string) => {
        // Consulta limpia a la base de datos
        return await db.producto.findUnique({ where: { id } });
      },
      ['producto-destacado-key'],
      {
        revalidate: 3600, // Revalida cada hora en segundo plano
        tags: ['productos']
      }
    );
    

    2. Configurar Límites de Memoria en Turbopack y Node.js

    Para evitar que el proceso de build agote la RAM de tu servidor de integración continua (CI/CD) o contenedor de producción, configura los flags de memoria de forma estricta en tu package.json:

    {
      "scripts": {
        "dev": "next dev --turbo",
        "build": "NODE_OPTIONS='--max-old-space-size=4096' next build"
      }
    }
    

    3. Evitar el "Waterfall" en Renderizado Asíncrono

    Uno de los fallos de rendimiento más comunes en Server Components es ejecutar peticiones await secuenciales cuando podrían resolverse en paralelo:

    // ❌ MAL: Peticiones en cascada (waterfall), triplica el tiempo de respuesta y retención en memoria
    const usuario = await getUsuario(id);
    const pedidos = await getPedidos(id);
    const metricas = await getMetricas(id);
    
    // ✅ BIEN: Ejecución en paralelo con Promise.all
    const [usuario, pedidos, metricas] = await Promise.all([
      getUsuario(id),
      getPedidos(id),
      getMetricas(id)
    ]);
    

    Al aplicar programación defensiva en TypeScript, garantizas que cualquier fallo dentro de Promise.all sea capturado sin dejar promesas colgadas en el event loop.

    Monitoreo y Diagnóstico de Memoria

    Para auditar el consumo real de tu aplicación en desarrollo o staging:

    1. Ejecuta el servidor con el inspector habilitado: node --inspect node_modules/.bin/next start.
    2. Abre Chrome DevTools (chrome://inspect) y toma una instantánea del Heap (Heap Snapshot).
    3. Filtra por clases retenidas (Closure, System / Context) para identificar qué Server Components no están siendo liberados por el recolector de basura (Garbage Collector).

    Como destacamos en nuestras guías de graph engineering, mapear las dependencias entre módulos es la forma más limpia de aislar fugas de memoria.


    Optimizar el rendimiento en Next.js 16 no requiere magia; requiere disciplina en la gestión de datos asíncronos y una configuración adecuada de los límites de memoria.

    Si quieres dominar el desarrollo fullstack moderno con Next.js y arquitecturas de alto rendimiento, descubre los Cursos de Dominicode. Y si buscas resolver desafíos complejos de producción en comunidad con otros desarrolladores senior, te esperamos en Dominicode Labs.

    Preguntas frecuentes

    ¿Por qué mi build de Next.js se queda congelado consumiendo 100% de CPU?

    Suele deberse a la importación masiva de módulos con dependencias circulares o al procesamiento de imágenes gigantescas durante la generación estática (SSG). Limitar el número de páginas pre-renderizadas en build mediante generateStaticParams dinámico soluciona el problema.

    ¿Qué diferencia hay entre revalidatePath y revalidateTag?

    revalidatePath purga toda la caché asociada a una URL específica. revalidateTag es mucho más eficiente porque purga de forma quirúrgica solo los datos que comparten una etiqueta concreta en todo el proyecto, sin invalidar otras secciones de la página.

    ¿Cómo afecta el uso de middleware al rendimiento en Next.js?

    El Middleware se ejecuta en el Edge Runtime antes de cada petición. Si realizas llamadas pesadas a APIs o consultas directas a bases de datos dentro del middleware, añadirás latencia a todas las rutas de tu aplicación. Mantén el middleware ultraligero (solo para redirecciones y lectura de headers/cookies).

    ¿Es recomendable usar next/image para todas las imágenes?

    Sí. El componente next/image optimiza automáticamente el formato (WebP/AVIF), ajusta las dimensiones según la pantalla del cliente y evita desplazamientos de diseño (Cumulative Layout Shift – CLS), reduciendo drásticamente la carga de memoria en el navegador.


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

  • Construyendo sitios web ultrarrápidos con Astro y Server Islands: Cero JS por defecto

    Construyendo sitios web ultrarrápidos con Astro y Server Islands: Cero JS por defecto

    Hace unos meses analicé la landing page de un cliente que ofrecía un producto SaaS. Habían construido la web utilizando un marco de trabajo de aplicación de página única (SPA) completo.

    Para renderizar un titular estático, una lista de precios y tres testimonios de clientes, el navegador del usuario tenía que descargar, descompilar y ejecutar 480 KB de JavaScript. En conexiones móviles 4G, el tiempo hasta que la página se volvía interactiva (Time to Interactive) superaba los 5.5 segundos. El resultado en Google Lighthouse era un doloroso 44/100.

    Perdían el 30% de los visitantes antes de que la página terminara de cargar.

    Al refactorizar el sitio hacia Astro y aprovechar la nueva funcionalidad de Server Islands, redujimos el bundle de JavaScript cliente para la estructura estática a 0 KB, logrando una puntuación de 100/100 en Core Web Vitals en el primer intento.

    La paradoja de enviar JavaScript para renderizar HTML

    Durante la última década, la industria del desarrollo web cometió un error colectivo: asumir que cualquier sitio web moderno debía empaquetarse dentro de una aplicación de React o Angular que se ejecuta íntegramente en el navegador del usuario.

    El resultado ha sido la degradación del rendimiento web:

    • El navegador descarga megabytes de JavaScript para crear nodos de DOM que podrían haber sido enviados directamente como HTML estático.
    • La CPU del dispositivo móvil se satura ejecutando hidratación de estado.
    • Los motores de búsqueda e intenciones de búsqueda sufren retardos de indexación.

    Astro invirtió este modelo con su filosofía "Zero JavaScript by default" (Cero JavaScript por defecto). Astro renderiza todo el componente a HTML estático en el servidor y solo envía JavaScript al cliente si especificas explícitamente una isla interactiva (Islands Architecture).

    ¿Qué son las Server Islands en Astro?

    La arquitectura de islas tradicional permitía incrustar componentes interactivos cliente (React, Vue, Svelte) dentro de una página estática usando directivas como client:load o client:visible.

    Sin embargo, las Server Islands introducen un avance superior: permiten posponer la renderización de un componente dinámico de servidor sin bloquear la carga estática inicial de la página.

    ┌─────────────────────────────────────────────────────────┐
    │ HTML Estático enviado de inmediato (TTFB ultra bajo)     │
    │ ┌─────────────────────────────────────────────────────┐ │
    │ │ Hero Section + Menú + Testimonios (HTML Puro)       │ │
    │ └─────────────────────────────────────────────────────┘ │
    │ ┌─────────────────────────────────────────────────────┐ │
    │ │ <AvatarUsuario server:defer /> (Server Island)      │ │
    │ │  └─► Renderiza fallback estático instantáneo        │ │
    │ │  └─► Se sustituye en segundo plano por HTML del srv  │ │
    │ └─────────────────────────────────────────────────────┘ │
    └─────────────────────────────────────────────────────────┘
    

    Ejemplo de uso de Server Island en Astro

    Imagina un blog de alta velocidad donde la mayor parte del contenido es estático, pero deseas mostrar el avatar personalizado del usuario autenticado en la barra superior.

    ---
    // src/pages/posts/[slug].astro
    import Layout from '../layouts/Layout.astro';
    import AvatarUsuario from '../components/AvatarUsuario.astro';
    import ContenidoPost from '../components/ContenidoPost.astro';
    
    const { slug } = Astro.params;
    ---
    
    <Layout title="Post de Blog Ultrarrápido">
      <header style="display: flex; justify-content: space-between;">
        <Logo />
        <!-- La Server Island no bloquea la carga de la página estática -->
        <AvatarUsuario server:defer>
          <!-- Fallback mientras el servidor procesa la sesión -->
          <div slot="fallback" class="avatar-skeleton"></div>
        </AvatarUsuario>
      </header>
    
      <main>
        <ContenidoPost slug={slug} />
      </main>
    </Layout>
    

    Al cargar la página:

    1. El servidor entrega HTML puro súper rápido (la estructura completa del artículo y la plantilla).
    2. El cliente ve la página cargada de forma instantánea con el skeleton del avatar.
    3. Astro ejecuta en segundo plano el componente <AvatarUsuario /> en el servidor y reemplaza el fallback con el HTML dinámico parseado sin necesidad de descargar una pesada librería cliente.

    Como vimos al comparar el consumo en tiempo de compilación con Next.js y Turbopack, utilizar la arquitectura correcta para cada tipo de proyecto es la decisión de rendimiento más rentable.

    Tipado Defensivo y Colecciones de Contenido

    Astro integra Content Collections, un sistema basado en Zod que valida en tiempo de compilación que todos tus archivos Markdown o MDX cumplan exactamente con la estructura de tipos definida.

    // src/content/config.ts
    import { defineCollection, z } from 'astro:content';
    
    const postsCollection = defineCollection({
      type: 'content',
      schema: z.object({
        title: z.string(),
        description: z.string().max(160),
        pubDate: z.date(),
        author: z.string().default('Bezael Pérez'),
        tags: z.array(z.string()),
      }),
    });
    
    export const collections = { posts: postsCollection };
    

    Al aplicar programación defensiva en TypeScript, garantizas que ningún artículo con metadatos defectuosos rompa la generación estática de tu sitio web.

    Además, mantener aisladas las dependencias de tus componentes siguiendo principios de graph engineering permite reutilizar componentes de React o Vue dentro de Astro de manera impecable.


    Astro y sus Server Islands representan la convergencia perfecta entre la velocidad extrema del HTML estático y la flexibilidad de la web dinámica moderna.

    Si quieres dominar el desarrollo web moderno, optimización de rendimiento y arquitectura frontend, explora los Cursos de Dominicode. Y si buscas construir sitios web y productos de alto impacto junto a desarrolladores senior, súmate a Dominicode Labs.

    Preguntas frecuentes

    ¿En qué se diferencia una Server Island de un Server Component de React?

    Los Server Components de React requieren que toda la aplicación comparta el modelo de hidratación y empaquetado de React. Las Server Islands de Astro son agnósticas al framework: puedes usar componentes en Astro puro, React, Vue, Svelte o Solid, y se reemplazan de forma asíncrona mediante un fragmento de HTML ligero sin cargar el runtime del framework si no es necesario.

    ¿Puedo seguir usando componentes interactivos de React en Astro?

    Sí. Puedes importar cualquier componente de React, Vue o Svelte en Astro. Para habilitar la interactividad cliente en un componente específico, solo añades la directiva de hidratación correspondiente, como client:visible (se hidrata solo cuando el usuario hace scroll hasta él) o client:idle (se hidrata cuando el navegador está inactivo).

    ¿Server Islands requiere una plataforma de despliegue en servidor (SSR)?

    Para que las Server Islands funcionen procesando peticiones dinámicas en segundo plano, tu proyecto Astro debe desplegarse con un adaptador SSR (Server-Side Rendering) en plataformas como Vercel, Netlify, Cloudflare Workers o un contenedor Docker con Node.js/Bun.

    ¿Astro es adecuado para aplicaciones web complejas con paneles de administración?

    Astro es imbatible para sitios web centrados en contenido, blogs, e-commerce, documentación y landing pages. Para paneles de administración interactivos con estado denso en cliente (dashboards complejos), combinar Astro para las páginas públicas con un framework como Next.js, Angular o React para el panel privado es una excelente estrategia de arquitectura.


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

  • Gestión de estado global sin dolor combinando Zod y Signals en aplicaciones modernas

    Gestión de estado global sin dolor combinando Zod y Signals en aplicaciones modernas

    Hace un par de años audité una aplicación enterprise en React y TypeScript que utilizaba Redux Toolkit. Para gestionar el estado de 6 pantallas principales, el equipo había tenido que escribir más de 3.500 líneas de código entre actions, reducers, selectors y Middlewares de Thunk.

    Lo grave no era la cantidad de archivos. Lo grave era que cuando el backend cambiaba un campo opcional de la API sin avisar, el store de Redux aceptaba el objeto corrupto y la aplicación explotaba páginas más tarde con el temible Cannot read properties of undefined.

    Habían creado un sistema complejo que no ofrecía ninguna protección real en tiempo de ejecución.

    La combinación de Zod (validación de esquemas) y Signals (reactividad de grano fino) se ha convertido en el estándar moderno para eliminar el dolor de la gestión de estado global en aplicaciones frontend.

    El problema de las librerías de estado tradicionales

    Durante años creímos que para gestionar el estado de una aplicación web necesitábamos un contenedor monolítico global con patrones de inmutabilidad estrictos.

    Ese enfoque sufría tres defectos estructurales:

    1. Verbosidad extrema: Escribir decenas de funciones de selección y mutación para actualizar una simple propiedad de usuario.
    2. Re-renderizados innecesarios: Si un componente escuchaba un objeto de estado global grande, cualquier cambio menor provocaba el re-renderizado del árbol de UI completo.
    3. Ceguera en la frontera API: Asumir que la respuesta del backend coincide al 100% con los tipos de TypeScript sin validar los datos entrantes.

    Como destacamos en nuestro artículo sobre programación defensiva en TypeScript, las interfaces de TypeScript desaparecen al transpilar, por lo que confiar solo en tipos en tiempo de compilación es una trampa.

    La Arquitectura Zod + Signals

    La solución moderna consiste en aplicar la validación de esquemas en la frontera de entrada (HTTP) y gestionar la reactividad atómica mediante Signals (disponibles de forma nativa en Angular, Preact, SolidJS o mediante librerías ultraligeras como @preact/signals en React).

    ┌─────────────────────────────────────────────────────────┐
    │ Respuesta API HTTP (JSON sin confiar)                   │
    │  └─► Validacion en tiempo de ejecucion con Zod Schema   │
    │     ┌───────────────────────────────────────────────────┐
    │     │ Estado Reactivo Atómico (Signals)                │
    │     │  └─► signal(), computed()                         │
    │     └───────────────────────────────────────────────────┘
    │  ┌──────────────────────────────────────────────────────┐
    │  │ Componentes de UI (Actualización Quirúrgica)         │
    │  └──────────────────────────────────────────────────────┘
    └─────────────────────────────────────────────────────────┘
    

    1. Definición del Esquema Zod y Tipado Automático

    import { z } from 'zod';
    
    // 1. Esquema con validación estricta en tiempo de ejecución
    export const UserStateSchema = z.object({
      id: z.string().uuid(),
      email: z.string().email(),
      nombre: z.string().min(2),
      rol: z.enum(['ADMIN', 'USER', 'GUEST']),
      preferencias: z.object({
        tema: z.enum(['light', 'dark']).default('dark'),
      }),
    });
    
    // Inferir el tipo de TypeScript automáticamente
    export type UserState = z.infer<typeof UserStateSchema>;
    

    2. Store Reactivo basado en Signals

    import { signal, computed } from '@preact/signals-react';
    import { UserStateSchema, UserState } from './user.schema';
    
    // State atómico inicial
    export const usuarioSignal = signal<UserState | null>(null);
    export const estaAutenticadoSignal = computed(() => usuarioSignal.value !== null);
    export const esAdminSignal = computed(() => usuarioSignal.value?.rol === 'ADMIN');
    
    // Acción de actualización con validación Zod defensiva
    export function setUsuarioConValidacion(rawData: unknown) {
      const parseResult = UserStateSchema.safeParse(rawData);
    
      if (!parseResult.success) {
        console.error('Payload de API inválido:', parseResult.error.format());
        // Se evita corromper el estado global con datos inválidos
        return false;
      }
    
      // Se asigna únicamente si la validación es 100% exitosa
      usuarioSignal.value = parseResult.data;
      return true;
    }
    

    Beneficios en Aplicaciones de Producción

    1. Re-renderizados quirúrgicos: Al consumir esAdminSignal en un botón de administración, solo ese botón se re-evalúa cuando el rol cambia. El resto de la UI permanece intacta sin necesidad de memoizaciones manuales (useMemo, React.memo).
    2. Cero corrupción de estado: Si la API devuelve un campo mal formateado, Zod detiene la propagación en la frontera HTTP antes de que afecte a la reactividad de la aplicación.
    3. Escalabilidad de código: Eliminas más del 70% del boilerplate de Redux/MobX, creando un código limpio que tanto los desarrolladores como los asistentes de IA pueden refactorizar sin riesgo.

    Al estructurar los módulos de estado siguiendo los principios de graph engineering, consigues una separación clara entre la lógica de datos y los componentes de presentación.

    Y si estás desarrollando en Angular, ten en cuenta el constante ciclo de releases de Angular donde los Signals y los Signal Forms se han integrado como el estándar nativo del framework.


    Simplificar la gestión de estado combinando la solidez de Zod con la velocidad de los Signals permite construir interfaces mantenibles, reactivas y blindadas ante fallos de producción.

    Si quieres dominar el desarrollo frontend moderno y las mejores prácticas de arquitectura con TypeScript, explora los Cursos de Dominicode. Y si quieres construir aplicaciones reales junto a otros desarrolladores senior, te esperamos en Dominicode Labs.

    Preguntas frecuentes

    ¿Puedo usar Zod con otras librerías de estado como Zustand o Pinia?

    Sí. Zod es una librería de validación agnóstica al framework. Puedes usar ZodSchema.parse() dentro de las acciones de Zustand, Pinia, Redux o cualquier otra librería para validar los datos antes de guardarlos en el store.

    ¿Qué diferencia hay entre la reactividad de Signals y los Observables de RxJS?

    Los Signals están optimizados para la reactividad síncrona de UI con evaluación perezosa y seguimiento automático de dependencias. RxJS está diseñado para la coordinación de eventos asíncronos en el tiempo (peticiones HTTP, WebSockets, timers). En aplicaciones modernas, se usan Signals para el estado del componente y RxJS para streams asíncronos.

    ¿Zod añade demasiado peso al bundle del cliente?

    No. Zod es una librería ultraligera (menos de 12 KB gzippeado) y soporta tree-shaking, por lo que solo se empaquetan en el cliente los métodos y validadores que utilices explícitamente en tu código.

    ¿Cómo persiste el estado basado en Signals entre recargas de página?

    Puedes crear un efecto reactivo que sincronice automáticamente el valor del Signal con localStorage o sessionStorage cada vez que el Signal cambia, parseando los datos con Zod al restaurar la sesión.


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

  • Pruebas unitarias ultrarrápidas con Vitest en proyectos de frontend e IA

    Pruebas unitarias ultrarrápidas con Vitest en proyectos de frontend e IA

    Hace un tiempo audité el repositorio de un proyecto en React y TypeScript que contaba con una suite de 400 pruebas unitarias en Jest. Cada vez que lanzabas npm test, tardaba 4 minutos y 15 segundos en completar la ejecución.

    Los desarrolladores habían dejado de ejecutar los tests en local. Su flujo de trabajo era hacer git push y esperar a que el servidor de integración continua (CI) les dijera 10 minutos después si habían roto algo.

    La fricción era enorme.

    Cuando reemplazamos Jest por Vitest y optimizamos el entorno de DOM con happy-dom, la misma suite de 400 tests pasó de tardar 4 minutos a completarse en 7.8 segundos.

    En la era del desarrollo acelerado con IA, tener un feedback loop de testing instantáneo no es un lujo; es el único pilar que te permite iterar rápido sin destruir producción.

    El problema de Jest en proyectos modernos (Vite / ESM)

    Jest fue el rey indiscutible de las pruebas en JavaScript durante casi una década. Sin embargo, su arquitectura arrastra decisiones de diseño pasadas:

    • Utiliza transformadores pesados en Babel/ts-jest que recompilan todo tu código TypeScript en CommonJS antes de ejecutar cada test.
    • La emulación del DOM con jsdom consume gigabytes de memoria Heap en proyectos medianos.
    • La ejecución en Watch mode rehace trabajo redundante de empaquetado.

    Vitest aprovecha la arquitectura moderna de Vite y esm.sh: usa el mismo pipeline de transformación de tu aplicación en desarrollo, comparte la configuración de alias e importaciones y aprovecha hilos de ejecución paralelos en Node.js de forma nativa.

    Configuración de Vitest para Frontend e IA

    Instalar Vitest en un proyecto moderno requiere un archivo de configuración mínimo (vitest.config.ts):

    import { defineConfig } from 'vitest/config';
    
    export default defineConfig({
      test: {
        environment: 'happy-dom', // Mucho más rápido y ligero que jsdom
        globals: true,
        setupFiles: ['./src/test/setup.ts'],
        include: ['src/**/*.{test,spec}.ts'],
        coverage: {
          provider: 'v8',
          reporter: ['text', 'json', 'html'],
        },
      },
    });
    

    Cómo mockear llamadas a Modelos de IA (LLMs) sin gastar tokens

    Cuando pruebas código que interactúa con APIs de IA (como Anthropic Claude, OpenAI o Vercel AI SDK), jamás debes realizar peticiones HTTP reales dentro de tus tests unitarios. Lanzarías la factura de tokens por las nubes y harías que tus tests sean no deterministas.

    Ejemplo de Mockeo Limpio con vi.mock()

    import { describe, it, expect, vi } from 'vitest';
    import { procesarRespuestaIA } from './ai-processor';
    
    // 1. Mockear la librería cliente de IA
    vi.mock('@anthropic-ai/sdk', () => {
      return {
        Anthropic: vi.fn().mockImplementation(() => ({
          messages: {
            create: vi.fn().mockResolvedValue({
              content: [{ type: 'text', text: 'Respuesta mockeada de prueba' }],
            }),
          },
        })),
      };
    });
    
    describe('Procesador de IA', () => {
      it('debe transformar la respuesta de la IA en una estructura válida', async () => {
        const resultado = await procesarRespuestaIA('Prompt de prueba');
        
        expect(resultado.ok).toBe(true);
        expect(resultado.texto).toBe('Respuesta mockeada de prueba');
      });
    });
    

    Al aplicar programación defensiva en TypeScript, garantizas que tus mocks cumplan exactamente con los contratos de tipos de las librerías originales.

    3 Principios para Suites de Test Ultrarrápidas

    1. Usa happy-dom en lugar de jsdom: happy-dom implementa las APIs del navegador necesarias para componentes de UI ocupando un 70% menos de memoria y ejecutando tests hasta 3 veces más rápido.
    2. Aísla las capas de tu aplicación: Separa los tests de unidades puras (funciones de dominio y utilidades) de los tests de componentes de UI. Al igual que recomendamos en nuestra guía sobre graph engineering, mantener fronteras claras evita inicializaciones innecesarias.
    3. No intentes testearlo todo: En nuestro análisis sobre cuándo NO usar Spec-Driven Development, recordamos que el objetivo de las pruebas es dar confianza en cambios de producción, no alcanzar el 100% de cobertura en código trivial sin valor de negocio.

    La velocidad de ejecución de tus pruebas unitarias determina el ritmo al que tu equipo puede innovar y refactorizar con seguridad.

    Si quieres aprender a construir pipelines de testing modernos e integraciones con IA, descubre los Cursos de Dominicode. Y si quieres colaborar en proyectos reales de alto nivel con desarrolladores senior, súmate a Dominicode Labs.

    Preguntas frecuentes

    ¿Es dificil migrar una suite existente de Jest a Vitest?

    No. Vitest incluye compatibilidad casi al 100% con las APIs de Jest (describe, it, expect, jest.fn() -> vi.fn()). En la mayoría de los proyectos basta con sustituir la importación y la configuración de jest.config.js por vitest.config.ts.

    ¿Se pueden ejecutar tests de Vitest en modo Watch continuo durante el desarrollo?

    Sí. El modo Watch de Vitest es uno de sus puntos más fuertes: gracias al HMR de Vite, solo reejecuta en milisegundos los tests directamente afectados por el archivo que acabas de modificar en tu editor.

    ¿Cómo pruebo componentes que utilizan hooks o reactividad de framework?

    Vitest se integra perfectamente con @testing-library/react, @testing-library/angular o @testing-library/vue, permitiéndote probar la interacción del usuario con componentes de UI usando la misma sintaxis que ya conoces.

    ¿Vitest funciona en proyectos que no usan Vite como empaquetador principal?

    Sí. Aunque Vitest brilla especialmente en proyectos basados en Vite (como Nuxt, Astro, SvelteKit o React con Vite), puede configurarse y funcionar perfectamente en cualquier proyecto de Node.js o TypeScript independiente.


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

  • Streaming de respuestas de IA en tiempo real con NestJS y Vercel AI SDK

    Streaming de respuestas de IA en tiempo real con NestJS y Vercel AI SDK

    Hace un tiempo audité la arquitectura backend de una plataforma SaaS que ofrecía asistentes conversacionales para empresas. Su backend estaba construido en NestJS.

    Cada vez que un usuario enviaba una consulta compleja, la pantalla mostraba un indicador de carga girando durante 7 u 8 segundos desesperantes. De repente, ¡pum!, la pantalla escupía el bloque entero de 600 palabras.

    Los usuarios se quejaban de que la aplicación era "lenta e inestable".

    El problema no era la velocidad de la API de Anthropic o OpenAI. El problema era que el backend esperaba a que el LLM generara la respuesta completa antes de enviársela al cliente. Al migrar el controlador de NestJS a un modelo de streaming de respuestas de IA en tiempo real utilizando Vercel AI SDK, redujimos el Time to First Token (TTFT) percibido por el usuario a menos de 250 milisegundos.

    Por qué el Streaming es obligatorio en aplicaciones de IA

    En productos basados en modelos de lenguaje, la percepción de velocidad lo es todo. Como analizamos en nuestra guía sobre agentes de voz en tiempo real, la latencia percibida es el producto.

    Cuando una persona lee texto en pantalla a medida que se genera token a token:

    • Siente que la aplicación responde de forma instantánea.
    • Empieza a procesar la información de inmediato sin quedarse esperando a un spinner.
    • El servidor no tiene que acumular buffers de memoria gigantescos antes de responder.

    El desafío de NestJS: HTTP de respuesta continua vs. Controllers estándar

    Por defecto, los controladores de NestJS están diseñados para devolver objetos JSON o promesas que se resuelven antes de cerrar la conexión HTTP.

    Para emitir un stream continuo de datos desde un backend en NestJS hacia una aplicación frontend (React, Angular, Next.js, etc.), debemos aprovechar las capacidades de Server-Sent Events (SSE) o escribir directamente en la respuesta nativa Response del servidor.

    1. Instalación de Vercel AI SDK en NestJS

    npm install ai @ai-sdk/anthropic
    

    2. Creación del Servicio de IA (ai.service.ts)

    import { Injectable } from '@nestjs/common';
    import { anthropic } from '@ai-sdk/anthropic';
    import { streamText } from 'ai';
    
    @Injectable()
    export class AiService {
      async generarRespuestaStream(prompt: string) {
        const result = await streamText({
          model: anthropic('claude-3-5-sonnet-20241022'),
          system: 'Eres un asistente técnico especializado en arquitectura de software.',
          prompt,
        });
    
        // Retorna el stream directo de texto/tokens
        return result.toDataStreamResponse();
      }
    }
    

    3. El Controlador de NestJS con Streaming (ai.controller.ts)

    import { Controller, Post, Body, Res } from '@nestjs/common';
    import { Response } from 'express';
    import { AiService } from './ai.service';
    
    @Controller('api/chat')
    export class AiController {
      constructor(private readonly aiService: AiService) {}
    
      @Post('stream')
      async chatStream(@Body('prompt') prompt: string, @Res() res: Response) {
        const aiResponse = await this.aiService.generarRespuestaStream(prompt);
    
        // Copiamos los cabezales y pipeamos la respuesta directamente al cliente
        res.setHeader('Content-Type', aiResponse.headers.get('Content-Type') || 'text/plain; charset=utf-8');
        
        // Convertimos el ReadableStream a Node.js Stream para enviarlo
        const reader = aiResponse.body.getReader();
        
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          res.write(value);
        }
        
        res.end();
      }
    }
    

    Optimización de Tokens y Tipado Defensivo

    Al implementar llamadas en streaming, debes tener en cuenta dos aspectos críticos de producción:

    1. Gestión del Tokenizador en Español: Como detallamos en nuestro artículo sobre el coste de los tokens en español, el texto en español consume ligeramente más tokens por palabra que el inglés. El streaming continuo evita que la latencia acumulada por esta diferencia afecte al usuario.
    2. Manejo Defensivo de Errores: Si la API del LLM falla a mitad del stream, el servidor no puede devolver un código HTTP 500 porque los encabezados HTTP 200 ya han sido enviados. Aplicando programación defensiva en TypeScript, debes emitir un evento de error formateado dentro del propio stream para que el cliente lo maneje elegantemente.

    El streaming de respuestas convierte un backend estático en un motor dinámico en tiempo real que ofrece experiencias de usuario fluidas y profesionales.

    Si quieres aprender a construir arquitecturas de backend modernas integradas con IA, explora los Cursos de Dominicode. Y si buscas desarrollar proyectos de alto impacto junto a desarrolladores senior, te esperamos en Dominicode Labs.

    Preguntas frecuentes

    ¿Es mejor usar Server-Sent Events (SSE) o WebSockets para streaming de texto?

    Para streaming unidireccional de texto desde el servidor hacia el cliente (como un chat de IA), Server-Sent Events (SSE) o HTTP Streaming es mucho más simple, eficiente y compatible con proxies que WebSockets, ya que reutiliza conexiones HTTP estándar.

    ¿Cómo consume este stream un cliente Frontend (React / Angular)?

    Librerías como Vercel AI SDK ofrecen hooks cliente como useChat() o useCompletion() en React/Next.js que consumen el endpoint en streaming automáticamente. En Angular o vanilla JS, puedes usar la API nativa fetch() examinando response.body.getReader().

    ¿Se pueden enviar metadatos estructurados (como IDs o fuentes) junto al stream de texto?

    Sí. Vercel AI SDK incluye soporte para StreamData, lo que te permite adjuntar JSONs con metadatos personalizados (ej. documentos de contexto RAG, fuentes o consumo de tokens) antes o durante la emisión del stream.

    ¿Cómo afecta el streaming al escalado de servidores en Kubernetes o Docker?

    Dado que las conexiones HTTP permanecen abiertas durante la generación del texto (habitualmente unos pocos segundos), debes asegurarte de ajustar el timeout de tus proxies o balancadores de carga (como NGINX o Traefik) para no cortar prematuramente la respuesta.


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

  • Patrones de diseño avanzados en TypeScript para aplicaciones en producción real

    Patrones de diseño avanzados en TypeScript para aplicaciones en producción real

    Hace unos meses revisé el repositorio de un proyecto TypeScript con más de dos años en producción. Al abrir el archivo de configuración del cliente principal, encontré comentarios como // TODO: quitar este any y casteos del tipo const data = response as unknown as UserData desperdigados por todo el código.

    El equipo se quejaba de que el compilador de TypeScript "les molestaba" en lugar de ayudarles.

    El problema era que estaban usando TypeScript simplemente como "JavaScript con anotaciones de tipo superficiales". No estaban aprovechando el sistema de tipos algebraicos ni los patrones de diseño expresivos que convierten a TypeScript en uno de los lenguajes más potentes para construir software resiliente.

    Conocer patrones de diseño avanzados en TypeScript no es para aprobar una entrevista técnica. Es lo que separa el código frágil del código mantenible que soporta años de evolución en producción.

    El espejismo del casting as Type

    El primer síntoma de una base de código TypeScript débil es el uso indiscriminado de aserciones de tipo (as).

    Cuando escribes as UserData, le estás diciendo al compilador: "Cállate, yo sé más que tú". Le estás quitando a TypeScript su superpoder principal: garantizar en tiempo de compilación que tus estructuras de datos son válidas.

    Como ya explicamos en nuestro análisis sobre programación defensiva en TypeScript, forzar tipos sin validación es la receta perfecta para lanzar excepciones TypeError: Cannot read properties of undefined en medio de la noche.

    3 Patrones de Diseño Esenciales para Developers Senior

    1. El Patrón Result (Discriminated Unions para manejo de errores)

    En lugar de lanzar excepciones con throw (que son invisibles en la firma de tus funciones), utiliza un tipo Result explícito basado en uniones discriminadas:

    type Success<T> = { readonly ok: true; readonly value: T };
    type Failure<E> = { readonly ok: false; readonly error: E };
    type Result<T, E = Error> = Success<T> | Failure<E>;
    
    function parseConfig(rawJson: string): Result<AppConfig, ParseError> {
      try {
        const data = JSON.parse(rawJson);
        if (!data.apiKey) {
          return { ok: false, error: new ParseError("apiKey es requerida") };
        }
        return { ok: true, value: data as AppConfig };
      } catch (e) {
        return { ok: false, error: new ParseError("JSON inválido") };
      }
    }
    
    // Uso obligatorio y seguro:
    const result = parseConfig(rawString);
    if (result.ok) {
      console.log(result.value.apiKey); // TypeScript sabe que ok es true y infiere el tipo de 'value'
    } else {
      console.error(result.error.message); // TypeScript infiere el tipo de 'error'
    }
    

    2. El Patrón Strategy para Proveedores de IA y Servicios

    Cuando construyes aplicaciones que interactúan con múltiples modelos de IA (OpenAI, Anthropic Claude, Ollama en local), el patrón Strategy te permite intercambiar algoritmos y proveedores sin modificar el código cliente:

    interface AIProviderStrategy {
      readonly name: string;
      generateCompletion(prompt: string): Promise<string>;
    }
    
    class AnthropicStrategy implements AIProviderStrategy {
      readonly name = "anthropic";
      async generateCompletion(prompt: string): Promise<string> {
        // Lógica específica para Anthropic API
        return "Respuesta de Claude";
      }
    }
    
    class OllamaLocalStrategy implements AIProviderStrategy {
      readonly name = "ollama";
      async generateCompletion(prompt: string): Promise<string> {
        // Lógica específica para modelo en local
        return "Respuesta de LLM local";
      }
    }
    
    class AIService {
      constructor(private strategy: AIProviderStrategy) {}
    
      setStrategy(newStrategy: AIProviderStrategy) {
        this.strategy = newStrategy;
      }
    
      async run(prompt: string) {
        return await this.strategy.generateCompletion(prompt);
      }
    }
    

    3. Builder Pattern con Validación en Tiempo de Compilación

    El patrón Builder permite crear objetos complejos garantizando que todos los parámetros requeridos se hayan establecido antes de instanciar la clase:

    type CompleteState = { host: string; port: number };
    
    class DatabaseConfigBuilder<State extends Partial<CompleteState> = {}> {
      private constructor(private readonly config: Partial<CompleteState>) {}
    
      static create(): DatabaseConfigBuilder<{}> {
        return new DatabaseConfigBuilder({});
      }
    
      setHost(host: string): DatabaseConfigBuilder<State & { host: string }> {
        return new DatabaseConfigBuilder({ ...this.config, host });
      }
    
      setPort(port: number): DatabaseConfigBuilder<State & { port: number }> {
        return new DatabaseConfigBuilder({ ...this.config, port });
      }
    
      build(this: DatabaseConfigBuilder<CompleteState>): DatabaseConfig {
        return new DatabaseConfig(this.config.host, this.config.port);
      }
    }
    

    Si intentas llamar a .build() sin haber establecido setHost() y setPort(), TypeScript rechazará la compilación de forma inmediata.


    Diseñar aplicaciones en TypeScript utilizando patrones expresivos y tipos algebraicos previene el 90% de los errores en producción antes de que el código toque el servidor.

    Al igual que discutimos al construir agentes de voz en tiempo real con TypeScript, el rigor en el tipado y en los contratos es lo que permite que tu código escale sin romperse. Y si combinas estos patrones con técnicas de graph engineering, tus aplicaciones serán limpias, modulares e inexpugnables.

    Si deseas dominar TypeScript avanzado y patrones de arquitectura modernos, explora los Cursos de Dominicode. Y si quieres construir software de producción en comunidad con otros desarrolladores senior, te esperamos en Dominicode Labs.

    Preguntas frecuentes

    ¿Cuándo debo preferir type sobre interface en TypeScript?

    Como regla general: usa interface cuando estés definiendo contratos de objetos que pueden ser extendidos o implementados por clases (OOP). Usa type para uniones, tuplas, tipos primitivos mapeados y combinaciones algebraicas de tipos.

    ¿El uso de tipos avanzados degrada el rendimiento de la aplicación en producción?

    No. Todo el sistema de tipos de TypeScript se elimina por completo durante el proceso de transpilación a JavaScript. Tu bundle de producción solo contiene JavaScript puro, por lo que los tipos no agregan ni un solo byte de sobrecarga en tiempo de ejecución.

    ¿El patrón Result reemplaza por completo los bloques try/catch?

    Se recomienda usar el patrón Result para errores de dominio previsibles (fallos de validación, usuario no encontrado, saldo insuficiente). Los bloques try/catch se reservan para excepciones no controladas a nivel de infraestructura (caída de red, falta de memoria).

    ¿Por qué evitar el tipo any si a veces acelera el desarrollo?

    Usar any desactiva completamente el verificador de tipos de TypeScript para esa variable y para todas las expresiones derivadas de ella. Si necesitas un tipo genérico desconocido temporalmente, utiliza siempre unknown y realiza narrowing con guardas de tipo (type guards).


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

  • Por qué Bun está reemplazando a Node.js en backend con TypeScript: Rendimiento y DX sin bundlers

    Por qué Bun está reemplazando a Node.js en backend con TypeScript: Rendimiento y DX sin bundlers

    Hace un tiempo migramos la suite de microservicios e integraciones de un proyecto en backend desde Node.js hacia Bun.

    En el entorno anterior basado en Node.js, la cadena de herramientas (toolchain) incluía tsx para desarrollo local, esbuild para compilar TypeScript a JavaScript antes de desplegar, jest para pruebas unitarias y npm para gestionar paquetes. Instalar dependencias en CI tardaba 35 segundos. El arranque del servidor de desarrollo tomaba 4.2 segundos.

    Al migrar a Bun:

    • bun install redujo el tiempo de instalación a 650 milisegundos.
    • bun run dev arrancó el servidor de TypeScript de forma instantánea (0 ms).
    • bun test ejecutó 300 tests unitarios en 1.2 segundos (frente a los 14 segundos de Jest).

    Bun no es simplemente un ejecutor de JavaScript alternativo; es una navaja suiza que reemplaza de un plumazo a Node.js, npm, ts-node, esbuild, Vite y Jest en aplicaciones backend con TypeScript.

    La diferencia arquitectónica: V8 vs. JavaScriptCore y Zig

    Node.js y Deno están construidos sobre el motor V8 de Google (escrito en C++).

    Bun fue desarrollado desde cero por Jarred Sumner utilizando el lenguaje de programación Zig y el motor JavaScriptCore (JSC) desarrollado por Apple para Safari.

    Esta decisión de diseño otorga a Bun ventajas competitivas clave:

    1. Menor tiempo de arranque (Cold Starts): JavaScriptCore arranca y genera bytecode significativamente más rápido que V8, lo que convierte a Bun en el runtime idóneo para funciones Serverless e integraciones con IA.
    2. Uso de memoria optimizado: El recolector de basura de JSC gestiona los objetos de heap con menor consumo de RAM en estado inactivo.
    3. Manejo de archivos a nivel de kernel: Operaciones I/O de lectura de archivos (Bun.file()) y llamadas de red HTTP son procesadas mediante llamadas de sistema de bajo nivel en Zig.
    ┌─────────────────────────────────────────────────────────┐
    │ Ecosistema Node.js Tradicional                          │
    │ ┌───────────────┬───────────────┬─────────────────────┐ │
    │ │ Node.js (V8)  │ npm / pnpm    │ ts-node / esbuild   │ │
    │ └───────────────┴───────────────┴─────────────────────┘ │
    └──────────────────────────┬──────────────────────────────┘
                               │ REEMPLAZADO POR:
    ┌──────────────────────────▼──────────────────────────────┐
    │ Runtime Bun (Zig + JavaScriptCore)                      │
    │ ┌─────────────────────────────────────────────────────┐ │
    │ │ Ejecución nativa de TypeScript / JSX sin transpilar  │ │
    │ ├─────────────────────────────────────────────────────┤ │
    │ │ Bundler + Test Runner + Package Manager (bun.lockb) │ │
    │ ├─────────────────────────────────────────────────────┤ │
    │ │ Driver SQLite/Postgres nativo + WebSockets + Servidor│ │
    │ └─────────────────────────────────────────────────────┘ │
    └─────────────────────────────────────────────────────────┘
    

    3 Características que Cambian la Experiencia de Desarrollo (DX)

    1. Ejecución nativa de TypeScript sin transpiladores externos

    En Node.js, para ejecutar un archivo .ts, necesitas configurar un transpilador como ts-node, tsx o compilar primero a una carpeta dist/ con tsc.

    En Bun, ejecutas directamente:

    bun run src/index.ts
    

    Bun transquila al vuelo archivos .ts, .tsx, .js y .jsx en memoria C++ sin requerir archivos tsconfig complejos ni configuraciones de Build.

    2. Gestor de paquetes ultrarrápido (bun install)

    bun install utiliza llamadas de sistema de copiado de memoria en Linux/macOS (copy-on-write) y un formato de lockfile binario (bun.lockb).

    Instalar un paquete como zod o hono toma milisegundos porque Bun aprovecha un sistema de caché global compartido entre todos tus proyectos locales.

    3. API HTTP y WebSockets nativas de alto rendimiento

    Servir peticiones HTTP en Bun requiere un bloque de código mínimo sin depender de Express o Fastify:

    import { serve } from "bun";
    
    serve({
      port: 3000,
      fetch(req) {
        const url = new URL(req.url);
        if (url.pathname === "/api/health") {
          return Response.json({ status: "ok", runtime: "Bun 1.2" });
        }
        return new Response("Not Found", { status: 404 });
      },
      websocket: {
        message(ws, message) {
          ws.send(`Eco: ${message}`);
        },
      },
    });
    

    Al combinar este rendimiento con los principios de programación defensiva en TypeScript, construyes servidores web robustos que responden en microsegundos y resisten picos de tráfico masivos.

    Comparativa con Next.js y Turbopack

    Como analizamos en nuestro informe sobre la optimización de memoria en Next.js y Turbopack, el consumo de recursos en herramientas de desarrollo ha sido un dolor constante para los desarrolladores.

    Bun soluciona este problema desde la raíz: consume hasta un 60% menos de memoria RAM durante la compilación y ejecución que Node.js con Webpack o Vite.

    Además, al simplificar la arquitectura de dependencias siguiendo buenas prácticas de graph engineering, tus repositorios se vuelven más fáciles de mantener tanto para desarrolladores como para agentes de IA.


    Bun no es el futuro del desarrollo backend en TypeScript; es el presente en proyectos de alta velocidad.

    Si quieres dominar el desarrollo backend moderno, microservicios y mejores prácticas de arquitectura con TypeScript y Bun, explora los Cursos de Dominicode. Y si quieres colaborar en proyectos reales junto a desarrolladores senior, súmate a Dominicode Labs.

    Preguntas frecuentes

    ¿Bun es compatible con el ecosistema existente de npm y módulos de Node.js?

    Sí. Bun implementa soporte para las APIs globales de Node.js (fs, path, http, stream, buffer) y soporta la importación de módulos tanto CommonJS (require) como ESM (import). La inmensa mayoría de paquetes de npm (incluyendo Prisma, Drizzle, Hono, NestJS y Express) funcionan de forma transparente en Bun.

    ¿Se puede utilizar Bun para producción en servidores Linux?

    Absolutamente. Bun cuenta con soporte de producción completo para entornos Linux x64 y ARM64. Grandes plataformas de despliegue como Vercel, Fly.io, Railway y AWS Lambda ofrecen ejecución nativa de aplicaciones basadas en Bun.

    ¿Cómo funciona bun test en comparación con Jest o Vitest?

    bun test es un test runner compatible con la sintaxis de Jest (describe, it, expect, beforeEach). Al estar integrado directamente en el runtime en lenguaje Zig, ejecuta suites de tests hasta 10 veces más rápido que Jest y 3 veces más rápido que Vitest sin requerir plugins adicionales.

    ¿Debo migrar todos mis proyectos existentes de Node.js a Bun de inmediato?

    Para proyectos nuevos, microservicios, scripts de automatización e integraciones con IA, Bun es la recomendación número uno. Para proyectos legacy de gran tamaño en Node.js, se sugiere comenzar migrando primero la ejecución de bun install y bun test en tus pipelines de CI/CD para ganar velocidad de inmediato antes de cambiar el runtime de producción.


    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.

  • 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.