Static vs Dynamic Rendering en Next.js App Router: diferencias y ejemplos

DUGLAS MORENODUGLAS MORENO 👁 7

Introducción

En el ecosistema de Next.js, el App Router introdujo un nuevo modelo de renderizado que combina lo mejor del servidor y del cliente. Sin embargo, esta flexibilidad trae consigo una pregunta frecuente: ¿cuándo se renderiza de forma estática y cuándo de forma dinámica? Entender la diferencia no solo afecta al rendimiento, sino también al SEO, al caché y a la experiencia de usuario.

Este artículo está dirigido a desarrolladores que ya conocen los conceptos básicos de React y Next.js y quieren profundizar en cómo el App Router decide entre renderizado estático y dinámico. Al final, sabrás qué dispara cada modo, cómo aprovechar sus ventajas y evitar los errores más comunes.


¿Qué significa "renderizado" en el App Router?

En Next.js 13+ el App Router trata a todos los componentes como Server Components por defecto. Un Server Component se ejecuta en el servidor y su resultado (HTML) se envía al navegador. Dependiendo de qué datos utiliza y de qué APIs del entorno llama, Next.js puede decidir:

  • Renderizado estático: el HTML se genera en tiempo de construcción (o durante una revalidación) y se reutiliza para todas las solicitudes.
  • Renderizado dinámico: el HTML se genera en cada solicitud (o en un caché corto) porque depende de datos que solo están disponibles en tiempo de request.

Esta decisión se toma automáticamente, pero podés influir en ella mediante el uso de ciertas funciones y datos.


Renderizado estático (Static Rendering)

¿Cómo funciona?

Cuando un Server Component no accede a ninguna fuente de datos que varíe por request (como cookies(), headers(), searchParams, o funciones que dependan del estado de usuario), Next.js lo marca como estático. El resultado se almacena en el caché de generación estática (ISR o SSG) y se sirve igual a todos los usuarios hasta que se invalide.

Cuándo se usa

  • Fetch de datos externos en fetch con la opción { cache: 'force-cache' } (valor por defecto).
  • Uso de funciones puras que no dependen del request (por ejemplo, leer un archivo local, llamar a una API sin headers personalizados).
  • Componentes que solo muestran información de producto, blog, documentación, etc., donde el contenido no cambia frecuentemente.

Ventajas

  • Máxima performance: el HTML se entrega desde un CDN sin tocar el servidor de aplicación.
  • Mejor SEO: los crawlers reciben contenido completamente renderizado.
  • Caché compartido: una misma respuesta sirve a miles de usuarios.

Limitaciones

  • No podés mostrar datos personalizados (por ejemplo, el nombre del usuario logueado).
  • Si necesitás reflejar cambios en tiempo real, tenés que esperar a la revalidación.

Ejemplo práctico

Supongamos un blog donde cada publicación se obtiene de un CMS headless en tiempo de construcción.

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

export const dynamic = 'force-static'; // opcional, deja claro la intención

interface Post {
  id: string;
  title: string;
  content: string;
}

// Función que simula una llamada al CMS (en realidad se ejecuta en build)
async function getPost(slug: string): Promise<Post | null> {
  // En un caso real sería await fetch(`https://mi-cms.com/posts/${slug}`)
  const posts: Record<string, Post> = {
    'primer-post': {
      id: '1',
      title: 'Mi primer post',
      content: 'Bienvenido a mi blog.',
    },
  };
  return posts[slug] ?? null;
}

export default async function PostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Salida esperada (HTML simplificado)

Para cualquier visita a /blog/primer-post el servidor devolverá exactamente:

<article>
  <h1>Mi primer post</h1>
  <div>Bienvenido a mi blog.</div>
</article>

Este mismo HTML se sirve desde el caché aunque miles de usuarios lo soliciten simultáneamente.


Renderizado dinámico (Dynamic Rendering)

¿Cómo funciona?

Si un Server Component lee alguna dato que varía por request (por ejemplo, cookies, encabezados, searchParams, o llama a una función marcada como unstable_noStore o revalidate: 0), Next.js lo considera dinámico. En ese caso, el HTML se genera en cada solicitud (o se guarda en un caché de corta duración si usas ISR con un redefine bajo).

Cuándo se activa

  • Uso de cookies() o headers() desde next/navigation.
  • Lectura de searchParams con useSearchParams() (en un Server Component) o directamente desde params que provienen de la URL y pueden cambiar.
  • Llamada a una API externa sin caché (fetch(..., { cache: 'no-store' })).
  • Uso de unstable_noStore() para desactivar cualquier caché.
  • Dependencia de datos de usuario (por ejemplo, getServerSession de NextAuth).

Ventajas

  • Personalización: podés mostrar información específica del usuario, como su nombre, carrito o preferencias.
  • Datos siempre frescos: ideal para paneles de administración, feeds en tiempo real o cualquier cosa que no deba estar almacenada en caché.

Limitaciones

  • Mayor carga en el servidor de aplicación porque cada request genera HTML.
  • Posible impacto en el TTFB (Time To First Byte) si la lógica es pesada.
  • Menor beneficio de caché CDN; depende de la estrategia de revalidación que elijas.

Ejemplo práctico

Imaginemos un panel de control donde se muestra el nombre del usuario logueado y la hora actual del servidor.

// app/dashboard/page.tsx
import { cookies } from 'next/navigation';
import { unstable_noStore } from 'next/cache';

export const dynamic = 'force-dynamic'; // opcional, deja explícito

async function getUserName(): Promise<string> {
  // Simulamos lectura de una cookie que contiene el token JWT
  const token = cookies().get('auth-token')?.value;
  if (!token) return 'Invitado';
  // En un caso real verificaríamos el token y obteneríamos el nombre
  return 'Juan Pérez';
}

export default async function Dashboard() {
  unstable_noStore(); // forzamos renderizado en cada request

  const userName = await getUserName();
  const now = new Date().toLocaleTimeString();

  return (
    <section>
      <h2>Panel de control</h2>
      <p>Hola, <strong>{userName}</strong>!</p>
      <p>Hora del servidor: {now}</p>
    </section>
  );
}

Salida esperada (HTML simplificado)

Cada vez que se solicite /dashboard el servidor producirá algo similar a:

<section>
  <h2>Panel de control</h2>
  <p>Hola, <strong>Juan Pérez</strong>!</p>
  <p>Hora del servidor: 14:23:07</p>
</section>

Si el usuario cambia (o la hora avanza), el HTML será diferente en la siguiente solicitud.


Incremental Static Regeneration (ISR): el punto intermedio

Next.js permite que un componente estático se vuelva a generar en segundo plano después de un tiempo determinado. Esta técnica se llama ISR y brinda los beneficios del caché estático con la posibilidad de actualizar el contenido sin rebuild completo.

Cómo funciona ISR en el App Router

  • Exportás una función revalidate en un layout.ts o page.ts (o usás la opción fetch(..., { next: { revalidate: 60 } })).
  • La primera request después del tiempo de expiración dispara una regeneración en segundo plano; la request actual sigue recibiendo la versión cached.
  • Las siguientes requests reciben la versión recién generada.

Ejemplo de ISR

// app/products/page.tsx
import { notFound } from 'next/navigation';

// Esta función se ejecutará en build y luego se revalidará cada 60 segundos
export const revalidate = 60;

async function fetchProducts() {
  const res = await fetch('https://api.ejemplo.com/products', {
    // next: { revalidate: 60 } también funciona aquí
  });
  if (!res.ok) throw new Error('Error al obtener productos');
  return res.json();
}

export default async function ProductsPage() {
  const products = await fetchProducts();

  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>{p.name} – ${p.price}</li>
      ))}
    </ul>
  );
}

Qué ocurre en la práctica

  • En la primera visita (o después de 60 s sin visitas) Next.js genera el HTML llamando a la API y lo guarda en caché.
  • Durante los próximos 60 s, cualquier usuario recibe ese mismo HTML sin tocar la API.
  • Al pasar el minuto, la siguiente request activa una nueva llamada a la API en segundo plano; mientras tanto, el usuario ve la versión anterior.
  • Una vez que la nueva generación termina, el caché se actualiza y los siguientes visitantes ven la lista actualizada.

Buenas prácticas y errores comunes

Buenas prácticas

  1. Declará la intención: aunque Next.js infera el modo, usar export const dynamic = 'force-static' o export const dynamic = 'force-dynamic' hace el código más legible y evita sorpresas.
  2. Minimizá el uso de datos dinámicos: si solo necesitás un dato de usuario (como el nombre), considerá cargarlo del cliente con useEffect o una SWR request, manteniendo el Server Component estático.
  3. Aproveché el caché de fetch: usar fetch(..., { next: { revalidate: 60 } }) permite obtener datos relativamente frescos sin volver a renderizar todo el layout.
  4. Separá lo estático de lo dinámico: dividí la UI en componentes; dejá los que dependen de request en archivos separados y mantené el resto estático.
  5. Monitoreá el caché: revisá los encabezados Age y X-Nextjs-Cache en las respuestas para confirmar si estás recibiendo una versión estática o dinámica.

Errores comunes

  • Olvidar que cookies() y headers() son solo Server: intentar usarlos en un Client Component provoca error; mové esa lógica al Server Component o pasá los valores como props.
  • Usar fetch sin opciones de caché y esperar que sea estático: por defecto, fetch en Next.js usa force-cache, pero si alguna vez agregaste { cache: 'no-store' } sin darte cuenta, pasarás a renderizado dinámico y aumentarás la carga del servidor.
  • Confundir revalidate con cache: 'no-store': revalidate sigue generando una versión estática que se actualiza periódicamente; no-store elimina cualquier caché y fuerza renderizado en cada request.
  • Renderizar datos de usuario en el layout: si el layout lee cookies(), todo el árbol debajo se vuelve dinámico, incluso partes que podrían ser estáticas (como el footer). Envolvé esas secciones en componentes separados o pasá los datos como props.
  • No manejar errores en datos estáticos: si la fetch falla en tiempo de build, la construcción fallará. Para ISR, manejá el error y mostrá un fallback o una página de error.

Conclusión

El App Router de Next.js brinda un modelo de renderizado único donde la decisión entre estático y dinámico depende exclusivamente de los datos que tus Server Components consumen. Al comprender qué dispara cada modo, podés:

  • Aprovechar el poder del caché CDN para páginas públicas y de alto tráfico.
  • Mantener la personalización y la frescura de los datos donde realmente lo necesitás.
  • Usar ISR como puente para obtener lo mejor de ambos mundos.

Recordá siempre declarar tu intención con dynamic o revalidate cuando el comportamiento no sea obvio, y separar claramente las responsabilidades entre componentes estáticos y dinámicos. Con estas prácticas, tus aplicaciones Next.js serán más rápidas, más escalables y ofrecerán una mejor experiencia tanto a los usuarios como a los motores de búsqueda.

Próximos pasos: experimentá con un pequeño proyecto que tenga una lista de productos (estática con ISR) y un panel de usuario (dinámico). Observá los encabezados de respuesta y los tiempos de carga para ver la diferencia en la práctica. ¡Éxitos!


Palabras clave: Next.js App Router, static rendering, dynamic rendering, ISR, SSR, SSG, Server Components, caché, rendimiento, SEO.

Comentarios (0)

Sé el primero en comentar.

Dejá tu comentario