Introducción: el renderizado ya no es una decisión de todo o nada
Si has seguido esta serie — cubrimos React Server Components, Server Actions, Middleware y Route Handlers — ya tienes las herramientas para construir aplicaciones full-stack con Next.js 15. Pero queda una pieza que lo conecta todo: la estrategia de renderizado. Básicamente, cómo y cuándo Next.js genera el HTML de cada página.
Y la verdad es que esto ha cambiado bastante.
En el Pages Router, elegías entre getStaticProps (SSG), getServerSideProps (SSR) o renderizado en el cliente. Una decisión por página, sin más. Con el App Router de Next.js 15, la cosa se complicó (para bien): los React Server Components son el modelo por defecto, el caché de fetch se desactivó por defecto, apareció Partial Prerendering (PPR) como opción experimental, y la configuración de cada ruta se controla con exports como dynamic y revalidate.
En esta guía vamos a recorrer las cinco estrategias de renderizado disponibles en Next.js 15 — SSG, ISR, SSR, PPR y CSR — con código que puedes copiar y pegar, y criterios claros para saber cuándo usar cada una. También le echaremos un vistazo a lo que viene en Next.js 16 con los Cache Components, para que no te pille por sorpresa cuando llegue la migración.
Las cinco estrategias de renderizado en Next.js 15
Antes de meternos de lleno, vale la pena tener el mapa completo. Next.js 15 con App Router te ofrece cinco formas de renderizar contenido:
- SSG (Static Site Generation) — HTML generado en build time, servido desde CDN.
- ISR (Incremental Static Regeneration) — SSG con revalidación automática o bajo demanda.
- SSR (Server-Side Rendering) — HTML generado en cada petición del usuario.
- PPR (Partial Prerendering) — shell estático + partes dinámicas vía streaming.
- CSR (Client-Side Rendering) — JavaScript renderiza en el navegador.
La gran diferencia con el Pages Router es que ya no eliges una estrategia por página. Con el App Router puedes mezclar estrategias dentro de la misma ruta gracias a los React Server Components y Suspense. Esto, honestamente, cambia las reglas del juego.
SSG: Generación estática en build time
Cómo funciona SSG en App Router
La generación estática sigue siendo la opción más rápida, y probablemente la más infravalorada. Next.js genera el HTML durante next build y lo sirve directamente desde el edge/CDN sin ejecutar código en el servidor por cada petición.
En el App Router, una página es estática por defecto si no utiliza funciones dinámicas como cookies(), headers() o searchParams. No tienes que hacer nada especial — Next.js lo detecta solo.
// app/about/page.tsx — Página estática automática
export default function AboutPage() {
return (
<main>
<h1>Sobre nosotros</h1>
<p>Esta página se genera en build time y se sirve estáticamente.</p>
</main>
)
}
SSG con datos: fetch + force-cache
Si tu página necesita datos externos, puedes marcar el fetch con force-cache para que se ejecute solo en build time:
// app/blog/page.tsx — SSG con datos
export default async function BlogPage() {
// force-cache: se ejecuta solo en build time
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache',
})
const posts = await res.json()
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</main>
)
}
Ojo con esto en Next.js 15: el comportamiento por defecto de fetch cambió. En Next.js 14, fetch usaba force-cache por defecto. En Next.js 15, ya no se cachea automáticamente. Necesitas ser explícito con cache: 'force-cache' o next: { revalidate: N } si quieres comportamiento estático. Es un detalle pequeño que puede causar dolores de cabeza si vienes de la versión anterior.
SSG con rutas dinámicas: generateStaticParams
Para generar páginas estáticas con segmentos dinámicos (como /blog/[slug]), usas generateStaticParams. Este reemplaza por completo al viejo getStaticPaths del Pages Router:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
// Genera las rutas estáticas en build time
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
return posts.map((post: any) => ({
slug: post.slug,
}))
}
// Página individual del post
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params // ← await obligatorio en Next.js 15
const res = await fetch(`https://api.example.com/posts/${slug}`, {
cache: 'force-cache',
})
if (!res.ok) notFound()
const post = await res.json()
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
Si alguien accede a un slug que no se generó en build time, Next.js intentará renderizarlo bajo demanda (el comportamiento por defecto con dynamicParams = true). Si prefieres devolver un 404 directo para rutas no generadas:
export const dynamicParams = false // Solo sirve rutas generadas en build
Cuándo usar SSG
- Páginas de marketing y landing pages
- Documentación y páginas de ayuda
- Blog posts que no cambian frecuentemente
- Cualquier contenido público que no depende del usuario
ISR: Revalidación incremental sin rebuild
ISR basado en tiempo
ISR no es realmente una estrategia diferente a SSG — es más bien SSG con superpoderes. Le dices a Next.js: "genera esta página estáticamente, pero cada N segundos comprueba si hay datos nuevos y regenera en segundo plano".
En el App Router, lo configuras con la opción revalidate en el fetch o como export de ruta:
// Opción 1: revalidate en el fetch
export default async function ProductsPage() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }, // Revalida cada 60 segundos
})
const products = await res.json()
return (
<main>
<h1>Catálogo de productos</h1>
{products.map((p: any) => (
<div key={p.id}>
<h2>{p.name}</h2>
<p>{p.price} €</p>
</div>
))}
</main>
)
}
// Opción 2: export a nivel de ruta (aplica a toda la ruta)
export const revalidate = 60
El mecanismo funciona así: la primera petición sirve la versión cacheada. Si han pasado más de 60 segundos, Next.js sigue sirviendo la versión antigua mientras regenera la nueva en segundo plano (el famoso patrón stale-while-revalidate). Y aquí viene lo bueno: si la regeneración falla, se mantiene la versión anterior. Tus usuarios nunca ven un error.
ISR bajo demanda con revalidateTag y revalidatePath
La revalidación basada en tiempo está bien, pero a veces necesitas invalidar el caché ahora mismo — por ejemplo, cuando un editor publica un artículo nuevo y no quiere esperar 60 segundos. Para eso existen revalidateTag y revalidatePath:
// app/blog/page.tsx — Marca los datos con un tag
export default async function BlogPage() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }, // Tag para invalidación selectiva
})
const posts = await res.json()
return (
<ul>
{posts.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// app/api/revalidar/route.ts — Endpoint para invalidar bajo demanda
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const { tag, secret } = await request.json()
// Verificar token de seguridad
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'No autorizado' }, { status: 401 })
}
revalidateTag(tag) // Invalida todas las páginas que usen este tag
return NextResponse.json({ revalidated: true, tag })
}
// Desde un Server Action también puedes revalidar
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function publishPost(formData: FormData) {
// ... guardar el post en la base de datos
// Opción A: invalidar por tag (más granular)
revalidateTag('posts')
// Opción B: invalidar por ruta (invalida toda la página)
revalidatePath('/blog')
}
Cuándo usar ISR
- Catálogos de productos con precios que cambian periódicamente
- Blogs con contenido gestionado por un CMS
- Páginas de noticias donde unos segundos de retraso son aceptables
- Cualquier contenido público que se actualiza pero no necesita ser en tiempo real
Un detalle importante: ISR solo funciona con el runtime de Node.js (el runtime por defecto). No está soportado con el Edge Runtime ni con exportaciones estáticas (output: 'export'). Esto ha pillado desprevenido a más de uno.
SSR: Renderizado dinámico en cada petición
Cuándo Next.js activa el renderizado dinámico
En el App Router, una página se renderiza dinámicamente (SSR) cuando utiliza funciones que dependen de la petición del usuario. Next.js lo detecta automáticamente cuando tu componente usa:
cookies()oheaders()searchParamsen un componente de páginafetchsin caché (cache: 'no-store')- Cualquier función dinámica de Next.js
// app/dashboard/page.tsx — SSR automático por usar cookies()
import { cookies } from 'next/headers'
export default async function DashboardPage() {
const cookieStore = await cookies()
const sessionId = cookieStore.get('session')?.value
if (!sessionId) {
return <p>No autenticado</p>
}
const res = await fetch(`https://api.example.com/user/${sessionId}`, {
cache: 'no-store', // Datos frescos en cada petición
})
const user = await res.json()
return (
<main>
<h1>Bienvenido, {user.name}</h1>
<p>Último acceso: {new Date(user.lastLogin).toLocaleString('es-ES')}</p>
</main>
)
}
Forzar renderizado dinámico explícitamente
Si quieres asegurarte de que una ruta sea siempre dinámica, sin depender de la detección automática de Next.js, puedes exportar la configuración dynamic:
// app/feed/page.tsx — Forzar SSR siempre
export const dynamic = 'force-dynamic'
export default async function FeedPage() {
const res = await fetch('https://api.example.com/feed')
const items = await res.json()
return (
<main>
<h1>Feed en tiempo real</h1>
{items.map((item: any) => (
<article key={item.id}>
<h2>{item.title}</h2>
<time>{item.publishedAt}</time>
</article>
))}
</main>
)
}
SSR con streaming y Suspense
Aquí es donde el SSR en App Router se pone realmente interesante. En lugar de esperar a que toda la página se renderice antes de enviarla, Next.js puede enviar HTML progresivamente usando Suspense:
// app/dashboard/page.tsx — SSR con streaming
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
{/* Se envía inmediatamente */}
<Suspense fallback={<p>Cargando estadísticas...</p>}>
<Stats /> {/* Se transmite cuando esté listo */}
</Suspense>
<Suspense fallback={<p>Cargando actividad...</p>}>
<RecentActivity /> {/* Se transmite en paralelo */}
</Suspense>
</main>
)
}
async function Stats() {
const res = await fetch('https://api.example.com/stats', {
cache: 'no-store',
})
const data = await res.json()
return (
<div>
<p>Usuarios activos: {data.activeUsers}</p>
<p>Ingresos hoy: {data.revenue} €</p>
</div>
)
}
async function RecentActivity() {
const res = await fetch('https://api.example.com/activity', {
cache: 'no-store',
})
const activities = await res.json()
return (
<ul>
{activities.map((a: any) => (
<li key={a.id}>{a.description}</li>
))}
</ul>
)
}
Con streaming, el usuario ve la estructura de la página al instante mientras los datos pesados se cargan en paralelo. Esto mejora muchísimo el Time to First Byte (TTFB) y los Core Web Vitals. En mi experiencia, la diferencia es bastante notable en dashboards con múltiples fuentes de datos.
Cuándo usar SSR
- Dashboards con datos personalizados por usuario
- Páginas de perfil y configuración
- Feeds de redes sociales con contenido en tiempo real
- Cualquier página donde los datos cambian en cada petición o dependen de cookies/headers
PPR: Partial Prerendering — lo mejor de ambos mundos
El problema que resuelve PPR
Imagina una página de producto en un e-commerce. El 90% de la página es estática (nombre, descripción, imágenes), pero necesitas dos elementos dinámicos: el precio actual y el stock disponible. Antes de PPR, tenías que elegir:
- SSG/ISR: Página rápida, pero el precio puede estar desactualizado.
- SSR: Precio siempre actual, pero toda la página se renderiza en el servidor.
- CSR: Cargar precio con JavaScript en el cliente — parpadeo visible y peor SEO.
Ninguna opción era ideal. PPR elimina este dilema.
La idea es elegante: genera un shell estático en build time (el layout, la navegación, el contenido que no cambia) y deja "huecos" para las partes dinámicas que se rellenan mediante streaming desde el servidor en el momento de la petición.
Cómo habilitar PPR en Next.js 15
PPR es experimental en Next.js 15. Para habilitarlo necesitas dos pasos: configurar next.config.ts y marcar las rutas que lo usan.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental', // Permite adopción gradual por ruta
},
}
export default nextConfig
// app/producto/[id]/page.tsx — PPR en acción
import { Suspense } from 'react'
export const experimental_ppr = true // Habilita PPR para esta ruta
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
// Datos estáticos — se renderizan en build time
const res = await fetch(`https://api.example.com/products/${id}`, {
cache: 'force-cache',
})
const product = await res.json()
return (
<main>
{/* Shell estático — servido desde CDN */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<img src={product.image} alt={product.name} />
{/* Partes dinámicas — se transmiten desde el servidor */}
<Suspense fallback={<p>Cargando precio...</p>}>
<LivePrice productId={id} />
</Suspense>
<Suspense fallback={<p>Comprobando stock...</p>}>
<StockStatus productId={id} />
</Suspense>
</main>
)
}
// Componente dinámico — usa cookies() para personalización
import { cookies } from 'next/headers'
async function LivePrice({ productId }: { productId: string }) {
const cookieStore = await cookies()
const region = cookieStore.get('region')?.value || 'EU'
const res = await fetch(
`https://api.example.com/prices/${productId}?region=${region}`,
{ cache: 'no-store' }
)
const { price, currency } = await res.json()
return (
<div>
<span>{price} {currency}</span>
</div>
)
}
async function StockStatus({ productId }: { productId: string }) {
const res = await fetch(
`https://api.example.com/stock/${productId}`,
{ cache: 'no-store' }
)
const { available, quantity } = await res.json()
return (
<div>
{available ? (
<span>En stock ({quantity} unidades)</span>
) : (
<span>Agotado</span>
)}
</div>
)
}
Cómo funciona internamente
Bueno, veamos qué pasa por debajo. El flujo de PPR es el siguiente:
- Build time: Next.js renderiza toda la página. Los componentes dentro de
Suspenseque usan funciones dinámicas (cookies(),headers(),fetchsin caché) se marcan como "huecos". Todo lo demás se convierte en HTML estático. - Petición del usuario: El CDN sirve el shell estático instantáneamente. Al mismo tiempo, el servidor arranca la ejecución de los componentes dinámicos.
- Streaming: Los componentes dinámicos se transmiten como HTML al navegador y rellenan los huecos del fallback de Suspense. Todo ocurre en una sola petición HTTP.
El resultado es que la primera carga es casi tan rápida como una página estática, pero con datos personalizados y en tiempo real. Y como el contenido dinámico se envía como HTML (no JavaScript), sigue siendo rastreable por motores de búsqueda. Bastante impresionante, la verdad.
Propagación a rutas hijas
La opción experimental_ppr se propaga automáticamente a todos los segmentos hijos de la ruta (layouts y páginas anidados). Solo necesitas declararla en el segmento superior. Si quieres desactivar PPR en un segmento hijo específico:
// app/producto/[id]/reviews/page.tsx
export const experimental_ppr = false // Desactiva PPR en esta sub-ruta
CSR: Renderizado en el cliente
El renderizado en el cliente sigue teniendo su lugar, y no hay que subestimarlo. Para componentes altamente interactivos que no necesitan SEO — filtros de búsqueda, editores en tiempo real, interfaces de chat — el CSR es la opción correcta.
En App Router, usas la directiva 'use client' para marcar componentes que se renderizan en el navegador:
// components/SearchFilter.tsx
'use client'
import { useState, useEffect } from 'react'
export function SearchFilter() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
useEffect(() => {
if (query.length < 3) return
const controller = new AbortController()
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then(setResults)
.catch(() => {})
return () => controller.abort()
}, [query])
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar productos..."
/>
<ul>
{results.map((r: any) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</div>
)
}
Un consejo que me ha funcionado bien: mantén los Client Components lo más pequeños posible. Envuelve solo la parte interactiva en 'use client' y deja que el Server Component padre se encargue del layout y los datos iniciales. Así evitas enviar JavaScript innecesario al navegador.
Configuración de ruta: los exports que controlan el renderizado
Next.js 15 ofrece varios exports a nivel de segmento de ruta que modifican el comportamiento de renderizado y caché. Aquí tienes la referencia rápida:
// Configuraciones disponibles en cualquier page.tsx o layout.tsx
export const dynamic = 'auto' | 'force-dynamic' | 'force-static' | 'error'
export const revalidate = false | 0 | number // false = caché indefinido, 0 = no-store, N = segundos
export const dynamicParams = true | false // Para rutas con generateStaticParams
export const runtime = 'nodejs' | 'edge' // Runtime de ejecución
Vamos a desglosar cada opción:
dynamic = 'auto'— Next.js decide automáticamente (por defecto).dynamic = 'force-dynamic'— Siempre SSR, nunca cachear.dynamic = 'force-static'— Forzar renderizado estático.cookies()yheaders()devuelven valores vacíos.dynamic = 'error'— SSG estricto. Lanza error si se usa alguna función dinámica.revalidate = 60— ISR con revalidación cada 60 segundos.revalidate = false— Cachear indefinidamente (equivalente a SSG puro).revalidate = 0— Equivalente ano-store, renderizado dinámico.
Regla de prioridad que debes conocer: si tienes múltiples valores de revalidate en una ruta (por ejemplo, en el layout y en la página), Next.js usa el valor más bajo. Así que si el layout dice revalidate = 3600 y la página dice revalidate = 60, toda la ruta se revalidará cada 60 segundos. Siempre gana el más agresivo.
Guía de decisión: cómo elegir la estrategia correcta
Con tantas opciones, es normal sentirse un poco perdido. Ante la duda, sigue este árbol de decisión:
- ¿Los datos dependen del usuario (cookies, sesión, preferencias)? → SSR o la parte dinámica de PPR.
- ¿Los datos son públicos pero cambian cada pocas horas? → ISR con
revalidate. - ¿Los datos son públicos y rara vez cambian? → SSG puro.
- ¿La página mezcla contenido estático y dinámico? → PPR.
- ¿El componente es altamente interactivo y no necesita SEO? → CSR con
'use client'.
Y para que sea todavía más concreto, aquí van unos ejemplos por tipo de página:
| Tipo de página | Estrategia recomendada | Configuración |
|---|---|---|
| Landing page | SSG | cache: 'force-cache' |
| Blog con CMS | ISR | revalidate: 60 + revalidateTag |
| Catálogo de productos | ISR + PPR | Shell estático + precio dinámico |
| Dashboard de usuario | SSR | dynamic: 'force-dynamic' |
| Página de checkout | SSR | cookies() + no-store |
| Buscador/filtros | CSR | 'use client' |
| E-commerce completo | PPR | Shell estático + carrito dinámico |
Mirando al futuro: Next.js 16 y Cache Components
Si estás empezando con Next.js 15, vale la pena saber qué se viene en Next.js 16, porque cambia bastante la forma de configurar PPR y el caché:
experimental.pprdesaparece. La configuración experimental de PPR y la opciónexperimental_ppra nivel de ruta se eliminan por completo.cacheComponentslo reemplaza todo. Un único flag ennext.config.tsactiva PPR + caché explícito. Se acabaron los flags experimentales separados.- Caché explícito con
"use cache". En lugar de configurar caché a nivel de ruta o fetch, marcas funciones y componentes individuales con la directiva"use cache". Solo esos bloques se cachean; todo lo demás es dinámico por defecto. Personalmente, creo que este modelo es mucho más intuitivo. <Activity>para navegación. React mantiene el estado de los componentes al navegar entre rutas, eliminando el re-mount innecesario.
// next.config.ts en Next.js 16
const nextConfig = {
cacheComponents: true, // Activa PPR + use cache
}
// Componente con caché explícito en Next.js 16
'use cache'
export default async function ProductInfo({ id }: { id: string }) {
const product = await db.product.findUnique({ where: { id } })
return <div>{product.name}</div>
}
Si hoy estás usando experimental.ppr en producción, la recomendación del equipo de Next.js es quedarte en tu versión actual de Next.js 15 hasta que publiquen la guía de migración oficial a Cache Components. No hay prisa por saltar — mejor esperar a que el camino esté claro.
Preguntas frecuentes
¿Cuál es la diferencia entre SSG e ISR en Next.js 15?
SSG genera las páginas una vez durante next build y no las actualiza hasta el siguiente despliegue. ISR hace lo mismo, pero le añade revalidación: después de un tiempo configurado (por ejemplo, 60 segundos), Next.js regenera la página en segundo plano cuando llega una nueva petición. En la práctica, ISR es SSG con una capa de cache inteligente encima. Ambos usan generateStaticParams para rutas dinámicas, pero ISR añade next: { revalidate: N } en el fetch o export const revalidate = N a nivel de ruta.
¿Se puede usar Partial Prerendering en producción?
En Next.js 15, PPR es experimental y no se recomienda para entornos de producción críticos. Dicho esto, bastantes equipos lo usan sin problemas en proyectos internos y aplicaciones no críticas. En Next.js 16, PPR evoluciona hacia los Cache Components (cacheComponents), un modelo más estable y pensado para producción. Si planeas usar PPR a largo plazo, tiene sentido empezar con Next.js 15 experimental y migrar a Cache Components cuando Next.js 16 sea estable.
¿Cómo sabe Next.js si una página debe ser estática o dinámica?
Next.js analiza tu código durante el build. Si un componente de página usa funciones dinámicas como cookies(), headers(), searchParams, o un fetch con cache: 'no-store', la página se marca como dinámica (SSR). Si no usa ninguna de estas funciones y todos los fetches son cacheables, se genera estáticamente (SSG). Puedes forzar el comportamiento con export const dynamic = 'force-dynamic' o 'force-static'.
¿Qué pasa con el fetch caching en Next.js 15 vs Next.js 14?
Este es uno de los cambios que más confusión genera. En Next.js 14, fetch usaba force-cache por defecto — todas las peticiones se cacheaban a menos que dijeras lo contrario. En Next.js 15, fetch no se cachea por defecto. Si estás migrando de Next.js 14, necesitas añadir explícitamente cache: 'force-cache' o next: { revalidate: N } a los fetches que quieras cachear. Es un cambio hacia un modelo más predecible donde el caché es opt-in en lugar de opt-out.
¿Puedo mezclar diferentes estrategias de renderizado en la misma aplicación?
Sí, y de hecho es lo que deberías hacer en la mayoría de proyectos. Cada ruta puede tener su propia estrategia. Puedes tener la landing page como SSG puro, el blog con ISR, el dashboard con SSR, y la página de producto con PPR — todo en el mismo proyecto. Con PPR vas un paso más allá: mezclas contenido estático y dinámico dentro de la misma página. Esta flexibilidad para combinar estrategias es, en mi opinión, una de las mayores ventajas del App Router sobre el Pages Router.