Optimización - Cache Avanzado

Ahora que entiendes los fundamentos y la revalidación, veamos técnicas avanzadas para hacer tu aplicación extremadamente rápida.

Request Memoization (Deduplicación)

Request Memoization elimina automáticamente peticiones duplicadas dentro del mismo render del servidor.

El problema

// app/producto/[id]/page.tsx
export default async function ProductoPage({ params }) {
  const producto = await fetch(`/api/productos/${params.id}`)
  
  return (
    <div>
      <ProductoHeader productoId={params.id} />
      <ProductoInfo productoId={params.id} />
      <ProductosRelacionados productoId={params.id} />
    </div>
  )
}

async function ProductoHeader({ productoId }) {
  // Necesita info del producto
  const producto = await fetch(`/api/productos/${productoId}`)
  return <h1>{producto.nombre}</h1>
}

async function ProductoInfo({ productoId }) {
  // También necesita info del producto
  const producto = await fetch(`/api/productos/${productoId}`)
  return <p>{producto.descripcion}</p>
}

async function ProductosRelacionados({ productoId }) {
  // También necesita info del producto
  const producto = await fetch(`/api/productos/${productoId}`)
  const relacionados = await fetch(`/api/productos/relacionados?id=${productoId}`)
  return <div>{/* relacionados */}</div>
}

Sin memoization: 4 peticiones HTTP idénticas para el mismo producto.

Con memoization (automático): NextJS detecta que todas piden la misma URL y reutiliza la respuesta. Solo 1 petición HTTP real.

Cómo funciona

NextJS mantiene un mapa de peticiones durante el render:

Render empieza
  ↓
fetch('/api/productos/1') → Petición real → Guarda en mapa
  ↓
fetch('/api/productos/1') → Encuentra en mapa → Reutiliza
  ↓
fetch('/api/productos/1') → Encuentra en mapa → Reutiliza
  ↓
Render termina → Limpia el mapa

Solo funciona con fetch

// ✅ Se deduplica automáticamente
const data = await fetch('https://api.com/datos')

// ❌ NO se deduplica (consulta directa a DB)
const data = await db.producto.findUnique({ where: { id } })

Para consultas de base de datos, usa unstable_cache:

import { unstable_cache } from 'next/cache'

const obtenerProducto = unstable_cache(
  async (id: string) => {
    return await db.producto.findUnique({ where: { id } })
  },
  ['producto'],
  { revalidate: 300 }
)

// Ahora estas llamadas se deduplicarán
const producto1 = await obtenerProducto('1')
const producto2 = await obtenerProducto('1')
const producto3 = await obtenerProducto('1')
// Solo 1 consulta real a la DB

Pasar datos vs. volver a obtener

// Opción 1: Pasar datos como props (tradicional)
export default async function ProductoPage({ params }) {
  const producto = await fetch(`/api/productos/${params.id}`).then(r => r.json())
  
  return (
    <div>
      <ProductoHeader producto={producto} />
      <ProductoInfo producto={producto} />
      <ProductosRelacionados producto={producto} />
    </div>
  )
}

function ProductoHeader({ producto }) {
  return <h1>{producto.nombre}</h1>
}

// Opción 2: Cada componente obtiene sus datos (con memoization)
export default async function ProductoPage({ params }) {
  return (
    <div>
      <ProductoHeader productoId={params.id} />
      <ProductoInfo productoId={params.id} />
      <ProductosRelacionados productoId={params.id} />
    </div>
  )
}

async function ProductoHeader({ productoId }) {
  const producto = await fetch(`/api/productos/${productoId}`).then(r => r.json())
  return <h1>{producto.nombre}</h1>
}

async function ProductoInfo({ productoId }) {
  const producto = await fetch(`/api/productos/${productoId}`).then(r => r.json())
  return <p>{producto.descripcion}</p>
}

Ambas opciones tienen la misma performance gracias a memoization. La opción 2 tiene ventajas:

  • Cada componente es independiente
  • Más fácil mover componentes
  • Cada componente pide solo lo que necesita
💡
Patrón recomendado

En Server Components, no te preocupes por "prop drilling" (pasar props por muchos niveles). Deja que cada componente obtenga sus propios datos. NextJS se encarga de la deduplicación.


Full Route Cache

El Full Route Cache guarda el HTML renderizado de tus páginas.

Páginas estáticas

// app/sobre-nosotros/page.tsx

export default function SobreNosotros() {
  return (
    <div>
      <h1>Sobre Nosotros</h1>
      <p>Fundada en 2020, somos una tienda de ropa...</p>
    </div>
  )
}

En build time:

npm run build

Route (app)                    Size     First Load JS
┌ ○ /sobre-nosotros            385 B          82.3 kB

○ (Static) Renderizada como HTML estático

NextJS genera el HTML una vez y lo sirve a todos. Extremadamente rápido.

Páginas con datos estáticos

// app/productos/page.tsx

export default async function ProductosPage() {
  const productos = await fetch('https://api.mitienda.com/productos', {
    cache: 'force-cache'  // Cachear para siempre
  }).then(r => r.json())
  
  return (
    <div>
      <h1>Productos</h1>
      {productos.map(p => (
        <div key={p.id}>{p.nombre}</div>
      ))}
    </div>
  )
}

Si no hay nada dinámico (cookies, headers, searchParams), NextJS genera HTML estático en build.

Páginas dinámicas

// app/carrito/page.tsx

import { cookies } from 'next/headers'

export default async function CarritoPage() {
  const cookieStore = await cookies()  // Usa cookies
  const userId = cookieStore.get('userId')
  
  // Esta página NO puede ser estática
  const carrito = await fetch(`/api/carrito/${userId}`).then(r => r.json())
  
  return <div>{/* carrito */}</div>
}
npm run build

Route (app)                    Size     First Load JS
└ ƒ /carrito                   891 B          86.7 kB

ƒ (Dynamic) Renderizada bajo demanda por cada request

NextJS detecta que usa cookies() y la marca como dinámica.

Forzar comportamiento

// Forzar página dinámica (no cachear)
export const dynamic = 'force-dynamic'

// Forzar página estática (cachear)
export const dynamic = 'force-static'

// Intentar estática, pero permitir fallback a dinámica
export const dynamic = 'auto'  // Default

// Error si intenta ser dinámica
export const dynamic = 'error'

Qué hace una página dinámica

Cosas que impiden que una página sea estática:

import { cookies, headers } from 'next/headers'
import { searchParams } from 'next/navigation'

// ❌ Estas funciones hacen la página dinámica:
const cookieStore = await cookies()
const headersList = await headers()
const params = useSearchParams()  // En Client Components

// ❌ Fetch sin cache
fetch('url', { cache: 'no-store' })

// ❌ revalidate: 0
fetch('url', { next: { revalidate: 0 } })

// ❌ Math.random(), Date.now() en Server Components
const timestamp = Date.now()

Para mantener páginas estáticas, evita estas funciones.


Router Cache (Navegación del cliente)

El Router Cache guarda páginas visitadas en el navegador durante la sesión.

Cómo funciona

// Usuario navega por tu sitio
/productos → Carga del servidor, guarda en cache
/producto/1Carga del servidor, guarda en cache
/productos → ⚡ Instantáneo (cache del browser)
/producto/2Carga del servidor, guarda en cache
/producto/1 → ⚡ Instantáneo (cache del browser)

Duración del cache

Tipo de páginaDuración en cache
Estática (sin datos)5 minutos
Estática (con datos)5 minutos
Dinámica30 segundos

Forzar revalidación

'use client'

import { useRouter } from 'next/navigation'

export default function MiComponente() {
  const router = useRouter()
  
  function handleClick() {
    // Limpia el cache del router y recarga la página actual
    router.refresh()
  }
  
  return <button onClick={handleClick}>Recargar datos</button>
}

Prefetching

NextJS precarga páginas automáticamente cuando el link es visible:

import Link from 'next/link'

export default function ProductosPage({ productos }) {
  return (
    <div>
      {productos.map(p => (
        // NextJS precarga esta página cuando el link es visible
        <Link href={`/productos/${p.id}`} key={p.id}>
          {p.nombre}
        </Link>
      ))}
    </div>
  )
}

Controlar prefetching:

// Prefetch activado (default)
<Link href="/productos/1" prefetch={true}>
  Ver producto
</Link>

// Prefetch desactivado
<Link href="/productos/1" prefetch={false}>
  Ver producto
</Link>

// Prefetch solo cuando pasa el mouse
<Link href="/productos/1" prefetch={null}>
  Ver producto
</Link>

Para listas grandes, desactiva prefetch:

export default function ProductosPage({ productos }) {
  return (
    <div>
      {productos.map(p => (
        // 1000 productos = no precargar todos
        <Link 
          href={`/productos/${p.id}`} 
          key={p.id}
          prefetch={false}
        >
          {p.nombre}
        </Link>
      ))}
    </div>
  )
}

generateStaticParams - Páginas dinámicas estáticas

Para rutas dinámicas que quieres pre-renderizar en build time.

Sin generateStaticParams

// app/productos/[id]/page.tsx

export default async function ProductoPage({ params }) {
  const producto = await fetch(`/api/productos/${params.id}`).then(r => r.json())
  
  return <div>{producto.nombre}</div>
}
npm run build

Route (app)                    Size     First Load JS
└ ƒ /productos/[id]            1.2 kB         84.1 kB

ƒ (Dynamic) Se genera cuando alguien la visita

Todas las páginas de productos se generan on-demand.

Con generateStaticParams

// app/productos/[id]/page.tsx

// Genera páginas estáticas para estos productos
export async function generateStaticParams() {
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(r => r.json())
  
  return productos.map(producto => ({
    id: producto.id.toString()
  }))
}

export default async function ProductoPage({ params }) {
  const producto = await fetch(`/api/productos/${params.id}`, {
    cache: 'force-cache'
  }).then(r => r.json())
  
  return <div>{producto.nombre}</div>
}
npm run build

Route (app)                    Size     First Load JS
└ ● /productos/[id] (100)      1.2 kB         84.1 kB

● (SSG) Generadas 100 páginas en build

NextJS genera 100 páginas HTML en build. Súper rápidas.

Estrategia: Solo productos populares

export async function generateStaticParams() {
  // Solo genera páginas para los 50 productos más vendidos
  const populares = await fetch('https://api.mitienda.com/productos/populares')
    .then(r => r.json())
  
  return populares.slice(0, 50).map(p => ({
    id: p.id.toString()
  }))
}

// Permitir generar páginas on-demand para otros productos
export const dynamicParams = true  // Default en v15

Resultado:

  • 50 productos populares: HTML pre-generado (instantáneo)
  • Otros productos: Generados on-demand la primera vez, luego cacheados

Rutas anidadas

// app/categorias/[categoriaId]/productos/[productoId]/page.tsx

export async function generateStaticParams() {
  const categorias = await fetch('https://api.mitienda.com/categorias')
    .then(r => r.json())
  
  const paths = []
  
  for (const cat of categorias) {
    const productos = await fetch(`https://api.mitienda.com/productos?cat=${cat.id}`)
      .then(r => r.json())
    
    productos.forEach(p => {
      paths.push({
        categoriaId: cat.id.toString(),
        productoId: p.id.toString()
      })
    })
  }
  
  return paths
}

export default async function ProductoPage({ params }) {
  const { categoriaId, productoId } = params
  // ...
}

Limitar generación

export async function generateStaticParams() {
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(r => r.json())
  
  // Solo genera las primeras 100
  return productos.slice(0, 100).map(p => ({
    id: p.id.toString()
  }))
}

// Comportamiento para IDs no generados
export const dynamicParams = true   // Genera on-demand (default)
// export const dynamicParams = false  // 404 error
💡
Estrategia óptima

Usa generateStaticParams para:

  • Páginas más visitadas (top 100 productos)
  • Páginas más importantes (categorías principales)
  • Páginas con contenido que raramente cambia

Combina con revalidate para actualizaciones automáticas y obtienes lo mejor de ambos mundos.


Estrategias de rendering

Static Rendering (default)

Genera HTML en build time. Más rápido posible.

// app/blog/[slug]/page.tsx

export async function generateStaticParams() {
  const posts = await fetch('https://api.miblog.com/posts').then(r => r.json())
  return posts.map(p => ({ slug: p.slug }))
}

export const revalidate = 3600  // ISR: actualiza cada hora

export default async function BlogPost({ params }) {
  const post = await fetch(`https://api.miblog.com/posts/${params.slug}`, {
    next: { tags: [`post-${params.slug}`] }
  }).then(r => r.json())
  
  return (
    <article>
      <h1>{post.titulo}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.contenido }} />
    </article>
  )
}

Ventajas:

  • Extremadamente rápido (HTML pre-generado)
  • Barato (no ejecuta código en cada request)
  • Excelente para SEO

Desventajas:

  • Datos pueden quedar desactualizados
  • Build puede ser lento con muchas páginas

Dynamic Rendering

Genera HTML en cada request. Siempre actualizado.

// app/dashboard/page.tsx

export const dynamic = 'force-dynamic'

export default async function Dashboard() {
  const usuario = await obtenerUsuarioActual()
  const estadisticas = await fetch(`/api/estadisticas/${usuario.id}`, {
    cache: 'no-store'
  }).then(r => r.json())
  
  return (
    <div>
      <h1>Dashboard de {usuario.nombre}</h1>
      <div>Ventas hoy: ${estadisticas.ventasHoy}</div>
    </div>
  )
}

Ventajas:

  • Siempre actualizado
  • Puede personalizar por usuario
  • Acceso a cookies, headers, etc

Desventajas:

  • Más lento (ejecuta código en cada request)
  • Más costoso en servidor

Streaming con Suspense

Muestra partes de la página progresivamente:

// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* Shell de la página se muestra inmediatamente */}
      <div className="grid grid-cols-2 gap-4">
        
        {/* Cada tarjeta carga independientemente */}
        <Suspense fallback={<CardSkeleton />}>
          <VentasCard />
        </Suspense>
        
        <Suspense fallback={<CardSkeleton />}>
          <ProductosCard />
        </Suspense>
        
        <Suspense fallback={<CardSkeleton />}>
          <UsuariosCard />
        </Suspense>
        
        <Suspense fallback={<CardSkeleton />}>
          <PedidosCard />
        </Suspense>
      </div>
    </div>
  )
}

// Cada componente async carga sus propios datos
async function VentasCard() {
  const ventas = await fetch('https://api.mitienda.com/ventas').then(r => r.json())
  return (
    <div className="border rounded p-4">
      <h2>Ventas</h2>
      <p className="text-3xl">${ventas.total}</p>
    </div>
  )
}

async function ProductosCard() {
  await new Promise(resolve => setTimeout(resolve, 2000))  // Simula lentitud
  const productos = await fetch('https://api.mitienda.com/productos').then(r => r.json())
  return (
    <div className="border rounded p-4">
      <h2>Productos</h2>
      <p className="text-3xl">{productos.length}</p>
    </div>
  )
}

function CardSkeleton() {
  return (
    <div className="border rounded p-4 animate-pulse">
      <div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
      <div className="h-8 bg-gray-200 rounded"></div>
    </div>
  )
}

Qué sucede:

  1. Usuario carga la página
  2. Estructura básica aparece inmediatamente
  3. Tarjetas aparecen progresivamente cuando terminan de cargar
  4. Usuario no ve pantalla en blanco

Ventajas:

  • Mejor UX (contenido progresivo)
  • Time to First Byte (TTFB) más rápido
  • Usuario ve algo inmediatamente

Partial Prerendering (PPR) - Experimental

Combina estático + dinámico en la misma página:

// next.config.js
module.exports = {
  experimental: {
    ppr: true
  }
}
// app/producto/[id]/page.tsx
import { Suspense } from 'react'

export default async function ProductoPage({ params }) {
  // Parte estática (pre-renderizada)
  const producto = await fetch(`/api/productos/${params.id}`, {
    cache: 'force-cache'
  }).then(r => r.json())
  
  return (
    <div>
      {/* Estático */}
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
      <p>${producto.precio}</p>
      
      {/* Dinámico (renderizado en request) */}
      <Suspense fallback={<div>Cargando stock...</div>}>
        <StockDisponible productoId={params.id} />
      </Suspense>
      
      {/* Dinámico (personalizado por usuario) */}
      <Suspense fallback={<div>Cargando recomendaciones...</div>}>
        <ProductosRecomendados />
      </Suspense>
    </div>
  )
}

// Componente dinámico
async function StockDisponible({ productoId }) {
  const stock = await fetch(`/api/stock/${productoId}`, {
    cache: 'no-store'
  }).then(r => r.json())
  
  return <p>Stock disponible: {stock.cantidad}</p>
}

NextJS pre-renderiza la parte estática y deja "huecos" para las partes dinámicas.

⚠️
PPR es experimental

Partial Prerendering está en fase experimental. La API puede cambiar. Úsalo solo si te gusta vivir al límite.


Debugging de cache

Ver qué está cacheado

// app/productos/page.tsx

export default async function ProductosPage() {
  // Timestamp para ver cuándo se generó
  const generadoEn = new Date().toISOString()
  
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(r => r.json())
  
  return (
    <div>
      <p className="text-xs text-gray-500">
        Página generada: {generadoEn}
      </p>
      <h1>Productos</h1>
      {/* ... */}
    </div>
  )
}

Recarga varias veces. Si el timestamp no cambia, está cacheado.

Headers de respuesta

Abre DevTools → Network → Selecciona la página:

X-NextJS-Cache: HIT       // Vino del cache
X-NextJS-Cache: MISS      // No estaba en cache
X-NextJS-Cache: STALE     // Cache viejo, regenerando

Cache-Control: s-maxage=3600, stale-while-revalidate=86400

Cache en build

npm run build

Route (app)                                Size     First Load JS
┌ ○ /                                      385 B          82.3 kB
├ ○ /productos                             2.1 kB         84.4 kB
├ ● /productos/[id] (50 entries)           1.2 kB         83.5 kB
├ ƒ /carrito                               891 B          83.2 kB
└ ƒ /dashboard                             1.5 kB         83.8 kB

Legend:
○  (Static)   Pre-renderizada como HTML estático
●  (SSG)      Pre-renderizada como HTML (usa generateStaticParams)
ƒ  (Dynamic)  Renderizada por cada request

Force refresh en desarrollo

# Limpia cache de Next.js
rm -rf .next

# Reinstala dependencias y limpia cache
rm -rf .next node_modules
npm install

Desactivar cache en desarrollo

// next.config.js
module.exports = {
  // Útil para debugging
  experimental: {
    isrMemoryCacheSize: 0  // Desactiva cache de ISR
  }
}

Patrones avanzados de optimización

Patrón 1: Contenido above the fold estático

// app/landing/page.tsx

export default async function LandingPage() {
  // Hero es estático (rápido)
  return (
    <>
      <HeroSection />
      <FeaturesSection />
      
      {/* Testimonios dinámicos (carga después) */}
      <Suspense fallback={<TestimoniosSkeleton />}>
        <TestimoniosSection />
      </Suspense>
      
      {/* Estadísticas dinámicas */}
      <Suspense fallback={<EstadisticasSkeleton />}>
        <EstadisticasSection />
      </Suspense>
    </>
  )
}

// Estático - pre-renderizado
function HeroSection() {
  return (
    <section>
      <h1>Bienvenido a nuestra tienda</h1>
      <p>Los mejores productos al mejor precio</p>
    </section>
  )
}

// Dinámico - datos actualizados
async function TestimoniosSection() {
  const testimonios = await fetch('https://api.mitienda.com/testimonios/recientes', {
    next: { revalidate: 60 }
  }).then(r => r.json())
  
  return (
    <section>
      {testimonios.map(t => (
        <div key={t.id}>{t.texto}</div>
      ))}
    </section>
  )
}

Patrón 2: Cache por nivel de usuario

// lib/cache.ts
import { unstable_cache } from 'next/cache'

export function cacheParaTipoUsuario<T>(
  fn: (userId: string) => Promise<T>,
  cacheKey: string,
  tipoUsuario: 'admin' | 'cliente'
) {
  // Admins ven datos más frescos
  const revalidate = tipoUsuario === 'admin' ? 60 : 300
  
  return unstable_cache(fn, [cacheKey], { revalidate })
}

// Uso
const obtenerDashboard = cacheParaTipoUsuario(
  async (userId) => {
    return await db.dashboard.findUnique({ where: { userId } })
  },
  'dashboard',
  usuario.tipo
)

Patrón 3: Preload de datos

// app/productos/[id]/page.tsx

// Preload (inicio de carga)
export async function generateMetadata({ params }) {
  const producto = await fetch(`/api/productos/${params.id}`).then(r => r.json())
  
  return {
    title: producto.nombre,
    description: producto.descripcion
  }
}

// El fetch ya está en cache gracias a memoization
export default async function ProductoPage({ params }) {
  const producto = await fetch(`/api/productos/${params.id}`).then(r => r.json())
  
  return <div>{producto.nombre}</div>
}

NextJS empieza a cargar datos en generateMetadata, y los reutiliza en el componente.

Patrón 4: Parallel data fetching

// ❌ Secuencial (lento)
async function DashboardPage() {
  const ventas = await fetch('/api/ventas').then(r => r.json())     // 500ms
  const usuarios = await fetch('/api/usuarios').then(r => r.json()) // 500ms
  const productos = await fetch('/api/productos').then(r => r.json()) // 500ms
  
  // Total: 1500ms
  return <div>{/* ... */}</div>
}

// ✅ Paralelo (rápido)
async function DashboardPage() {
  const [ventas, usuarios, productos] = await Promise.all([
    fetch('/api/ventas').then(r => r.json()),     // Todos en paralelo
    fetch('/api/usuarios').then(r => r.json()),
    fetch('/api/productos').then(r => r.json())
  ])
  
  // Total: 500ms (el más lento de los 3)
  return <div>{/* ... */}</div>
}

Patrón 5: Waterfall prevention con Suspense

// ❌ Waterfall: Cada nivel espera al anterior
async function ProductoPage({ params }) {
  const producto = await fetch(`/api/productos/${params.id}`)
  
  return (
    <div>
      <ProductoInfo producto={producto} />
      <Suspense fallback={<div>Cargando reseñas...</div>}>
        <Reseñas productoId={params.id} />
      </Suspense>
    </div>
  )
}

async function Reseñas({ productoId }) {
  // Espera a que producto termine
  const reseñas = await fetch(`/api/reseñas/${productoId}`)
  return <div>{/* reseñas */}</div>
}

// ✅ Sin waterfall: Todo en paralelo
async function ProductoPage({ params }) {
  return (
    <div>
      <Suspense fallback={<div>Cargando producto...</div>}>
        <ProductoInfo productoId={params.id} />
      </Suspense>
      
      <Suspense fallback={<div>Cargando reseñas...</div>}>
        <Reseñas productoId={params.id} />
      </Suspense>
    </div>
  )
}

// Ahora ambos fetch empiezan simultáneamente
async function ProductoInfo({ productoId }) {
  const producto = await fetch(`/api/productos/${productoId}`)
  return <div>{/* producto */}</div>
}

async function Reseñas({ productoId }) {
  const reseñas = await fetch(`/api/reseñas/${productoId}`)
  return <div>{/* reseñas */}</div>
}

Mejores prácticas

1. Mide antes de optimizar

// Agrega timestamps para medir
console.time('fetch-productos')
const productos = await fetch('/api/productos')
console.timeEnd('fetch-productos')

No optimices sin datos. Puede que no sea el cuello de botella.

2. Cache agresivamente, revalida inteligentemente

// ✅ Cache largo + revalidación manual
export const revalidate = 3600

async function agregarProducto() {
  await db.producto.create(/* ... */)
  revalidateTag('productos')  // Actualiza inmediatamente
}

Mejor que cache corto que se regenera constantemente.

3. Separa estático de dinámico

// ✅ Layout estático, página dinámica
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }) {
  return (
    <div>
      {/* Navegación estática */}
      <nav>
        <Link href="/dashboard">Inicio</Link>
        <Link href="/dashboard/productos">Productos</Link>
      </nav>
      
      {/* Contenido dinámico */}
      {children}
    </div>
  )
}

4. Usa loading.tsx para UX

// app/productos/loading.tsx
export default function Loading() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {Array.from({ length: 9 }).map((_, i) => (
        <div key={i} className="animate-pulse">
          <div className="h-48 bg-gray-200 rounded mb-2"></div>
          <div className="h-4 bg-gray-200 rounded"></div>
        </div>
      ))}
    </div>
  )
}

Usuarios prefieren ver loading que pantalla en blanco.

5. Monitorea el tamaño de build

npm run build

# Busca páginas grandes
Route (app)                    Size     First Load JS
├ ○ /productos                 25 kB    ⚠️ 108 kB  # DEMASIADO GRANDE

Si una página estática es >100 kB, considera dividirla.

6. Usa generateStaticParams con límite

// ❌ 10,000 productos = build de 30 minutos
export async function generateStaticParams() {
  const productos = await fetch('/api/productos').then(r => r.json())
  return productos.map(p => ({ id: p.id }))
}

// ✅ Solo 100 productos = build de 30 segundos
export async function generateStaticParams() {
  const productos = await fetch('/api/productos/populares').then(r => r.json())
  return productos.slice(0, 100).map(p => ({ id: p.id }))
}

export const dynamicParams = true  // Otros on-demand

7. Cache de queries complejas

import { unstable_cache } from 'next/cache'

const obtenerEstadisticas = unstable_cache(
  async () => {
    // Query pesada con múltiples joins
    return await db.$queryRaw`
      SELECT 
        COUNT(*) as total_ventas,
        SUM(total) as ingresos,
        AVG(total) as ticket_promedio
      FROM ventas
      WHERE fecha > NOW() - INTERVAL '30 days'
    `
  },
  ['estadisticas-mensuales'],
  { revalidate: 3600 }
)

Checklist de optimización

Antes de desplegar a producción:

Build time:

  • Ejecuta npm run build y revisa el output
  • Verifica que páginas importantes son estáticas (○ o ●)
  • Confirma que páginas dinámicas realmente necesitan serlo (ƒ)
  • First Load JS < 100 kB para páginas principales

Runtime:

  • Configura revalidate apropiado para cada página
  • Implementa revalidación on-demand para mutaciones
  • Usa tags para invalidación granular
  • Agrega loading.tsx a rutas lentas

Queries:

  • Usa unstable_cache para queries de DB complejas
  • Implementa generateStaticParams para rutas dinámicas populares
  • Configura cache: 'force-cache' en fetch cuando apropiado
  • Parallel fetching con Promise.all() cuando sea posible

UX:

  • Implementa Suspense para contenido above-the-fold rápido
  • Skeletons/placeholders para estados de carga
  • Prefetch desactivado en listas largas
  • router.refresh() donde usuarios esperan datos frescos

Resumen

Request Memoization:

  • Automático, elimina duplicados en el mismo render
  • Solo funciona con fetch()
  • Para DB usa unstable_cache

Full Route Cache:

  • HTML pre-generado en build
  • Usa generateStaticParams para rutas dinámicas
  • Combina con ISR para actualizaciones automáticas

Router Cache:

  • Navegación instantánea en el cliente
  • Controla con prefetch en Link
  • Limpia con router.refresh()

Estrategias:

  • Static cuando puedas
  • Dynamic cuando debas
  • Streaming para mejor UX
  • PPR para lo mejor de ambos mundos (experimental)

Performance óptima:

  • Mide primero
  • Cache agresivamente
  • Revalida inteligentemente
  • Separa estático de dinámico
  • Usa Suspense para contenido progresivo

Con estas técnicas, tu aplicación NextJS será extremadamente rápida. 🚀