Image for post Guía Completa sobre Next.js: Desarrollo de Aplicaciones Web React con Mejores Prácticas

Next.js 16: las decisiones de arquitectura detrás de Cache Components


Hasta la versión 15, diseñar una aplicación con Next.js significaba elegir entre SSR, SSG e ISR por ruta y confiar en que el framework cacheara lo razonable por defecto. Next.js 16 no añade un cuarto modo a esa lista: cambia el modelo mental completo, componente por componente, con una directiva que hay que escribir a propósito en cada función. Adoptarlo no es una casilla que marcas en next.config.ts, es una cadena de decisiones de arquitectura, cada una con una consecuencia concreta si la ignoras, y ninguna de las dos opciones —adoptarlo o quedarte en el modelo clásico— es gratis.

Next.js 16.3 en una frase

Next.js 16.3, publicado el 3 de agosto de 2026, sustituye los Web Streams por streams nativos de Node.js en el renderizado del App Router, con hasta un 22% más de peticiones atendidas bajo carga sin cambios en el código de la aplicación, y combina por defecto dos mejoras en next dev: la caché en disco (introducida en la 16.1) y un nuevo mecanismo de memory eviction, que juntos reducen hasta un 90% el uso de RAM en sesiones largas, según el anuncio oficial de Next.js 16.3. Es, en palabras del propio equipo, la actualización más grande desde la 16.0 de noviembre de 2025.

La decisión de fondo: de heurística implícita a directiva explícita

El modelo clásico, que sigue documentado como el modelo de caching anterior, no cachea una petición fetch por defecto: hay que pedirlo con cache: 'force-cache', o fijar dynamic, revalidate o fetchCache a nivel de segmento de ruta. Es un sistema con reglas heurísticas, por ejemplo qué se cachea antes o después de leer cookies(), que en apps medianas termina siendo difícil de razonar sin mirar el árbol de renderizado completo.

Desde la 16.0, activar cacheComponents: true en next.config.ts sustituye ese heurístico por una regla única: nada se cachea salvo que lo marques con la directiva 'use cache' a nivel de función o de componente. Esto habilita Partial Prerendering como comportamiento por defecto, según la documentación oficial de Cache Components. El coste está en dos sitios: este modo exige runtime Node.js (no funciona con runtime = 'edge'), y migrar una app existente implica revisar ruta por ruta, no es un cambio de configuración transparente, según la guía oficial de migración. Cada una de las siguientes secciones es una de esas decisiones y lo que cuesta ignorarla.

Decisión 1: qué directiva describe cada dato, no qué modo de render eliges por ruta

La pregunta que hay que responder por cada función que lee datos no es "SSR o SSG", es esta: ¿qué directiva describe la caducidad real de este dato, y qué pasa si no pongo ninguna?

  • Igual para todos, cambia poco (catálogo de producto, posts de blog): 'use cache' + cacheLife('hours') en la función que lee los datos. Un resultado, compartido por todas las visitas.
  • Siempre fresco por request (precio de bolsa, disponibilidad exacta): connection() antes del cálculo, envuelto en Suspense, sin 'use cache'. Aplica solo si tolerar unos segundos de stale no compensa el coste de recalcular en cada request.
  • HTML totalmente estático (landing, páginas legales): no hace falta ninguna directiva; se prerenderiza solo por no leer datos de request, salvo que dependa de un header de idioma que cambie por visitante.

El caso que de verdad exige detenerse a pensar es el cuarto: datos por sesión que quieres reutilizar, como un carrito visible o las preferencias de tema.

El matiz que falta en la mayoría de tutoriales: cachear por sesión tiene un coste de cardinalidad

Extraer el valor de cookies() y pasarlo como argumento a una función con 'use cache' funciona: el valor entra en la clave de caché y cada sesión obtiene su propio resultado cacheado. Pero eso significa una entrada de caché por sesión activa, no una por ruta: en un sitio con decenas de miles de sesiones concurrentes, la cardinalidad de esa caché crece con el tráfico, no con el tamaño del catálogo, y el hit rate se comporta de forma muy distinta a cachear un producto compartido por todos los visitantes.

Si además el dato no solo es distinto por usuario sino que no debería persistir ni un momento en un almacén compartido entre instancias, la pieza correcta no es 'use cache' a secas, es 'use cache: private': sus resultados nunca se guardan en el servidor, solo en la memoria del navegador, así que no se acumula ninguna entrada por sesión del lado del servidor. La contrapartida es que ese resultado no sobrevive a una recarga completa de página ni se comparte entre pestañas, y no admite un cache handler personalizado. La propia documentación lo resume así: un shell que lee cookies() o headers() es específico de la sesión, y se cachea por sesión en el cliente en vez de en la caché compartida del servidor.

Decisión 2: qué pasa si un acceso dinámico no está envuelto en Suspense

Aquí es donde el modelo clásico y Cache Components se comportan de forma opuesta, y donde conviene ser preciso: Suspense no es una advertencia opcional que bloquea el renderizado si la usas. Es exactamente lo contrario: es el mecanismo que permite emitir el shell estático de la página sin esperar a que resuelva el contenido dinámico. Sin Suspense alrededor de un acceso a datos en tiempo de ejecución, no hay una ejecución silenciosa de fallback: hay un error explícito.

La guía oficial de migración lo describe con un ejemplo concreto: al quitar dynamic = 'force-static' de una ruta, cualquier acceso a datos de runtime sin gestionar (cookies(), headers(), un fetch sin 'use cache') que Next.js detecte durante el desarrollo o el build produce un error, y ese error señala exactamente dónde envolver el componente en <Suspense>. Es, en términos prácticos, el equivalente a un chequeo de tipos para el árbol de renderizado: si el dato es dinámico y no está declarado como tal con Suspense, la build falla en vez de servir una página a medias o con contenido incorrecto compartido entre usuarios. Diseñar una ruta con Cache Components es, en la práctica, decidir con antelación qué partes del árbol son estáticas, cuáles son dinámicas, y envolver estas últimas antes de que el propio framework te obligue a hacerlo.

Decisión 3: qué runtime y qué bundler exige, y qué se rompe si no lo revisas

Adoptar cacheComponents no es solo una decisión sobre datos: trae requisitos de plataforma que conviene revisar antes de activarlo en un proyecto real.

  • runtime = 'edge' no es compatible. Cache Components exige runtime Node.js; las rutas en Edge Runtime hay que migrarlas a Node o dejarlas fuera de cacheComponents.
  • generateStaticParams ya no acepta un array vacío. Antes, devolver [] diferia todos los paths al primer visitante en runtime; con Cache Components eso lanza el error empty-generate-static-params en build, porque Next.js necesita al menos un param para generar un shell estático válido y verificar que no está vacío. Los paths que no devuelvas siguen sirviéndose: Next.js prerenderiza un shell para los params desconocidos y transmite el resto en request time.
  • Turbopack, el bundler por defecto desde la 16.0.0, tiene huecos concretos. Según la referencia oficial de Turbopack: los plugins de webpack no tienen equivalente (solo soporta loaders); sassOptions.functions no funciona porque su arquitectura en Rust no ejecuta código JavaScript durante la compilación; Yarn PnP no está planeado como objetivo de soporte; y algunas características legacy de CSS Modules (:local independiente, @value, composes importando un .css) no están soportadas. Si tu proyecto depende de alguno de estos puntos, la salida documentada es seguir con el flag explícito --webpack en next dev/next build, no forzar la migración a Turbopack solo porque sea el valor por defecto.

Para migrar una app existente sin hacerlo a mano ruta por ruta, Vercel mantiene el skill next-cache-components-adoption para agentes de código (npx skills add vercel/next.js --skill next-cache-components-adoption), con un modo incremental que abre primero un PR mecánico que excluye todas las rutas de la validación y luego migra feature por feature, y un modo directo que adopta todo en una sola rama.

Decisión 4: cómo trata Next.js a los bots, y qué no significa eso

Aquí es donde conviene acotar bien el mecanismo, porque hay dos comportamientos relacionados pero distintos y es fácil generalizar de más. El que sí es configurable por nombre de user agent es htmlLimitedBots: controla qué bots reciben metadata bloqueante en vez de metadata en streaming, con una lista por defecto que incluye los crawlers de Google, Bingbot, Twitterbot y Slackbot, según la referencia de htmlLimitedBots. No es un interruptor que activas por ruta según su "intención SEO": es una lista global de user agents que se aplica igual a cualquier ruta, y que puedes sobrescribir con tu propia expresión regular si tienes bots adicionales que necesiten el mismo trato.

La consecuencia práctica en una app con Cache Components es más amplia que solo la metadata: al detectar uno de esos user agents, Next.js no sirve el shell estático con streaming de la parte dinámica, sino que renderiza la página completa en el momento del request y envía el HTML terminado de una sola vez, según describe la sección "Bots and crawlers" de la guía de streaming. El motivo es que un bot limitado en HTML necesita el documento completo, metadata incluida, antes de procesarlo; no puede consumir los chunks progresivos que sí entiende un navegador. La consecuencia para quien diseña la ruta: si parte del shell depende de un dato que solo existe en build time y no está disponible en el entorno de request, una persona puede ver la página bien y un crawler de esa lista puede fallar exactamente en esa parte.

Cómo se ve en código: shell estático, dato compartido y dato por sesión conviviendo en una ruta

Este ejemplo combina las decisiones anteriores en una sola ruta: una ficha de producto (cacheada y compartida por todos, decisión 1) con un contador de carrito por sesión (decisión 1, caso de cardinalidad), sobre un layout que respeta la decisión 2 sobre Suspense, con la invalidación tras mutación que exige cualquier dato cacheado que cambia por acción del usuario. Requiere cacheComponents: true:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

export default nextConfig
import { cacheLife, cacheTag } from 'next/cache'
import { cookies } from 'next/headers'
import { Suspense } from 'react'

const API_BASE = process.env.INTERNAL_API_URL ?? 'https://api.example.com'

export default function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  return (
    <>
      <ProductShell params={params} />
      <Suspense fallback={<p>Cargando carrito...</p>}>
        <CartBadge />
      </Suspense>
    </>
  )
}

async function ProductShell({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const product = await getProduct(slug)
  return (
    <article>
      <h2>{product.name}</h2>
      <p>{product.description}</p>
      <p>{product.price} EUR</p>
    </article>
  )
}

async function getProduct(slug: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('product-' + slug)
  const res = await fetch(API_BASE + '/products/' + slug)
  if (!res.ok) {
    throw new Error('No se pudo cargar el producto ' + slug + ': HTTP ' + res.status)
  }
  return res.json()
}

async function CartBadge() {
  const sessionId = (await cookies()).get('session')?.value
  if (!sessionId) return <span>0</span>
  return <CachedCartCount sessionId={sessionId} />
}

async function CachedCartCount({
  sessionId,
}: {
  sessionId: string
}) {
  'use cache'
  cacheLife('minutes')
  cacheTag('cart-' + sessionId)
  const count = await getCartCount(sessionId)
  return <span>{count}</span>
}

async function getCartCount(sessionId: string): Promise<number> {
  const res = await fetch(API_BASE + '/cart/' + sessionId + '/count')
  if (!res.ok) {
    throw new Error('No se pudo leer el carrito de la sesion ' + sessionId + ': HTTP ' + res.status)
  }
  const data = await res.json()
  return data.count
}

La ficha del producto entra en el shell estático porque su única dependencia (slug) llega ya resuelta y su dato está cacheado con una etiqueta propia. El contador de carrito depende de una cookie de sesión, así que vive detrás del Suspense y solo bloquea esa parte del árbol, no la página entera; y por la decisión 1, cacheLife('minutes') sin invalidación explícita puede quedarse obsoleto varios minutos tras un cambio real en el carrito. El paso que falta si el ejemplo terminara aquí es la invalidación tras la mutación que causó el cambio:

'use server'
import { updateTag } from 'next/cache'

const API_BASE = process.env.INTERNAL_API_URL ?? 'https://api.example.com'

export async function addToCart(sessionId: string, productId: string) {
  const res = await fetch(API_BASE + '/cart/' + sessionId + '/items', {
    method: 'POST',
    body: JSON.stringify({ productId }),
  })
  if (!res.ok) {
    throw new Error('No se pudo anadir el producto al carrito: HTTP ' + res.status)
  }
  // Expira la etiqueta de esta sesion: la siguiente lectura de
  // CachedCartCount espera al dato fresco en vez de servir el que ya
  // habia en cache, sin esperar a que cacheLife('minutes') expire sola.
  updateTag('cart-' + sessionId)
}

La guía de migración distingue dos APIs de invalidación según el efecto que buscas: updateTag, usada aquí, expira la etiqueta para que la petición siguiente espere al dato fresco (lo correcto cuando el usuario debe ver su propio cambio de inmediato, como acabar de añadir algo al carrito); revalidateTag en cambio sirve el dato cacheado mientras se refresca en segundo plano (stale-while-revalidate), y exige un perfil de caché como segundo argumento. Sin esta llamada tras la mutación, el contador de carrito queda desincronizado durante minutos, exactamente el problema que cacheLife('minutes') por sí sola no resuelve.

Decisión 5: qué se paga en cada despliegue

El resultado de 'use cache' vive en memoria por instancia salvo que uses 'use cache: remote' con un cache handler configurado, y aun con almacenamiento durable cada entrada deja de ser válida en el siguiente despliegue porque la clave de caché incluye el build id, según la documentación de Cache Components. Si despliegas varias veces al día, el hit rate real de esa caché nunca llega a acumularse, y en un self-hosting sencillo sin CDN ni cache handler remoto el beneficio de Cache Components se reduce al streaming con Suspense, no a una caché compartida entre instancias. Es la decisión que suele pasarse por alto al comparar el coste de adoptar el modelo: no es solo el trabajo de migración inicial, es que el retorno de la caché depende de cuántos despliegues haces al día.

Cuándo la respuesta correcta es no adoptar este modelo

Sitio totalmente estático sin personalización: si no hay ISR, ni datos por usuario, ni rutas dinámicas que dependan de cookies o headers, un generador estático sin runtime Node (Astro, Eleventy) resuelve lo mismo con menos superficie operativa: no hay servidor que mantener ni cache handler que configurar.

Herramienta interna sin necesidad de SEO: el modelo de Server Components más Cache Components añade una capa conceptual (qué se ejecuta en servidor, qué se cachea, dónde poner cada Suspense) que solo se paga si necesitas indexación o un primer render rápido. Para un panel de administración interno, un SPA con Vite y React Router evita esa complejidad sin coste real.

Equipo sin experiencia previa en RSC: migrar una app existente a Cache Components no es cambiar un flag y listo: exige revisar cada dynamic, revalidate y fetchCache de cada ruta y sustituirlos por 'use cache' o Suspense, algo que la propia guía de migración resuelve con un codemod y el skill de adopción para agentes visto en la decisión 3, no con un simple find-and-replace.

En cualquiera de estos tres casos, el coste de las cinco decisiones anteriores se paga sin el beneficio que las justifica: shell estático servido al instante y datos dinámicos en streaming solo donde de verdad hacen falta.

Compartir X LinkedIn