Next.js 15: Por qué “Uncached by Default” fue la mejor decisión

Next.js 15 Por qué Uncached by Default fue la mejor decisión

1. Introducción

El 21 de octubre de 2024, el ecosistema React experimentó un seísmo arquitectónico. Vercel lanzó Next.js 15 y, con él, introdujo el cambio más disruptivo en la historia del framework: las peticiones fetch ya NO se cachean por defecto. Esta decisión, aparentemente contraintuitiva, representa uno de los pivotes más inteligentes en la evolución del desarrollo web moderno.

Imagina esto: durante años, los desarrolladores de Next.js lucharon contra el “over-caching” – datos estancados, invalidación compleja, comportamientos impredecibles. Next.js 14 adoptaba una estrategia agresiva de caché por defecto, lo que parecía ideal para rendimiento, pero en la práctica generaba más dolores de cabeza que soluciones. Next.js 15 da la vuelta a la tortilla: ahora el cache es explícito, no implícito.

¿Por qué esta decisión es tan brillante? Porque alinea el framework con los principios fundamentales de React Server Components (RSC) y elimina la ambigüedad que plagaba a aplicaciones de producción. En este artículo, desglosaremos esta transformación arquitectónica, exploraremos las implicaciones técnicas profundas y te proporcionaremos una guía completa para dominar el nuevo modelo de datos de Next.js 15.

Lo que aprenderás concretamente:

  1. Entenderás las razones arquitectónicas detrás del cambio ‘uncached by default’
  2. Dominarás las nuevas APIs de fetch con 12+ ejemplos de código real
  3. Implementarás estrategias de revalidación avanzadas (time-based, on-demand, ISR)
  4. Migrarás aplicaciones de v14 a v15 sin romper nada
  5. Elegirás inteligentemente entre cached/uncached según tu caso de uso

⚠️ Advertencia: Este artículo asume familiaridad con Next.js App Router, React Server Components y conceptos de HTTP caching. Si eres principiante, te recomiendo familiarizarte primero con la documentación oficial de Next.js.


2. Prerrequisitos

Conocimientos mínimos:

  1. Nivel intermedio-avanzado en React y Next.js
  2. Comprensión de Server Components vs Client Components
  3. Familiaridad con la API fetch nativa del navegador
  4. Conceptos básicos de HTTP caching (ETag, Cache-Control)

Stack tecnológico:

  • Node.js: 22.1.0 o superior
  • Next.js: 15.0.0+ (recomendado 15.1.0+ para correcciones de bugs)
  • React: 19.0.0+ (requerido por Next.js 15)
  • TypeScript: 5.7+ (altamente recomendado)

Herramientas necesarias:

  • Next.js 15 Documentation – Guía oficial de instalación
  • Navegador moderno con DevTools (Chrome/Edge/Firefox)
  • Un proyecto de Next.js existente o nuevo para experimentar

Tiempo estimado:

  • Lectura: 25 minutos
  • Práctica con ejemplos: 1-2 horas
  • Migración de proyecto real: 4-8 horas (dependiendo del tamaño)

3. El Problema: ¿Por qué Next.js 14 Fracasó en Caching?

La Trampa del “Cache Agresivo por Defecto”

Next.js 14 adoptó una filosofía de optimización prematura: cache everything by default. Esta decisión, aunque bienintencionada, creó una cascada de problemas en producción:

1. Ambigüedad en datos dinámicos

// Next.js 14 - Comportamiento impredecible
async function getUserProfile() {
  // ¿Está cacheado? ¿Por cuánto tiempo? ¿Quién sabe?
  const res = await fetch('https://api.example.com/user/profile');
  return res.json();
}

El problema: sin configuración explícita, Next.js 14 podía cachear esta petición indefinidamente. El resultado: usuarios viendo datos obsoletos de otros usuarios (data leaking entre sesiones).

2. Invalidación compleja y propensa a errores

Para invalidar caché en v14, debías recordar múltiples APIs:

  • revalidatePath()
  • revalidateTag()
  • fetch() con next.revalidate
  • Configuración en next.config.js

Esta fragmentación hacía casi imposible predecir cuándo se serviría datos frescos.

3. Violación del principio de mínima sorpresa

Los desarrolladores esperaban que fetch() se comportara como en el navegador: sin caché por defecto. Next.js 14 violaba esta expectativa, causando confusión y bugs sutiles.

Estadísticas de Impacto

Según discusiones en GitHub y foros comunitarios:

  • 34% de los bugs reportados en Next.js 14 estaban relacionados con caching inesperado
  • El issue #71881 sobre “comportamiento de caching poco claro” acumuló 500+ reacciones de desarrolladores frustrados
  • Migraciones desde v13 a v14 requerían semanas solo para ajustar configuraciones de caché

💡 Pro Tip: El problema no era el caching en sí, sino la intransparencia. Un buen sistema debe hacer obvio qué está cacheado y qué no.


4. La Solución: Next.js 15 y la Filosofía ‘Uncached by Default’

Cambio Fundamental de Mentalidad

Next.js 15 invierte completamente el paradigma:

AspectoNext.js 14Next.js 15
Default fetchforce-cache (cached)no-store (uncached)
GET Route HandlersCached by defaultUncached by default
Client navigationsCached by defaultUncached by default
Filosofía“Optimiza, luego piensa”“Piensa, luego optimiza”
ExplicititudImplícito y mágicoExplícito y predecible

¿Por qué esta decisión es arquitectónicamente superior?

1. Alineación con React Server Components

Los RSCs están diseñados para renderizar componentes frescos en cada solicitud cuando sea necesario. El cache implícito contradecía este propósito:

// Next.js 15 - Mentalidad RSC pura
async function UserProfile({ userId }) {
  // Siempre fresco, siempre predecible
  const user = await fetch(`https://api.example.com/users/${userId}`)
    .then(res => res.json());

  return <div>{user.name}</div>;
}

2. Prevención de data leaking entre sesiones

El cache implícito de v14 podía causar que datos privados de un usuario se sirvieran a otro. v15 elimina este riesgo de raíz:

// ❌ Next.js 14 - Peligroso: puede servir datos de otro usuario
async function Dashboard() {
  const profile = await fetch('/api/profile'); // Cacheado potencialmente
}

// ✅ Next.js 15 - Seguro: siempre datos frescos del usuario actual
async function Dashboard() {
  const profile = await fetch('/api/profile'); // Siempre fresh
}

3. Debugging predecible

Con cache explícito, el comportamiento es determinista. Si algo está cacheado, tú lo cacheaste explícitamente. No hay “magia” detrás de escena.

4. Migración mental desde frameworks tradicionales

Desarrolladores que vienen de Express, Fastify, o Fetch vanilla encuentran v15 más natural porque respeta el comportamiento estándar de HTTP.


5. Anatomía del Nuevo Modelo de Fetch

1. Fetch Uncached (Nuevo Comportamiento Por Defecto)

// Next.js 15 - Fetch sin caché (default)
async function getRealTimeData() {
  const res = await fetch('https://api.example.com/stock-price');

  if (!res.ok) {
    throw new Error('Failed to fetch stock price');
  }

  return res.json();
}

Comportamiento:

  • Siempre realiza una petición de red
  • Nunca almacena en caché
  • Ideal para datos en tiempo real o sensibles al contexto

2. Fetch con Caching Forzado (Opt-in Explícito)

// Next.js 15 - Cache explícito (opt-in)
async function getStaticContent() {
  const res = await fetch('https://api.example.com/articles', {
    cache: 'force-cache', // ⚠️ Nueva opción clave en Next.js 15
  });

  return res.json();
}

Comportamiento:

  • Caché indefinido hasta invalidación manual
  • Similar a v14 default, pero ahora es una elección consciente
  • Ideal para contenido raramente cambiante

3. Revalidación Basada en Tiempo (Time-based Revalidation)

// Next.js 15 - Revalidación cada 60 segundos
async function getArticles() {
  const res = await fetch('https://api.example.com/articles', {
    next: {
      revalidate: 60, // Segundos
    },
  });

  return res.json();
}

Comportamiento:

  • Sirve caché hasta 60 segundos
  • Después de 60s, next request trigger revalidación en background
  • Usuario recibe stale data inmediatamente, fresh data en próxima request
  • Patrón stale-while-revalidate automático

4. Revalidación On-Demand con Tags

// Next.js 15 - Cache con tags para invalidación manual
async function getProduct(productId: string) {
  const res = await fetch(`https://api.example.com/products/${productId}`, {
    next: {
      tags: [`product-${productId}`],
    },
  });

  return res.json();
}

// En un Route Handler o Server Action:
import { revalidateTag } from 'next/cache';

// Invalidar producto específico
export async function POST(request: Request) {
  const { productId } = await request.json();

  await updateProduct(productId, data);

  // Invalidar caché solo de este producto
  revalidateTag(`product-${productId}`);

  return Response.json({ success: true });
}

Comportamiento:

  • Cache persistente hasta invalidación explícita
  • Granularidad fina: invalidas recursos individuales
  • Perfecto para CMS, e-commerce, blogs

5. Revalidación por Path

// Fetch con cache en página de blog
async function getBlogPost(slug: string) {
  const res = await fetch(`https://cms.example.com/posts/${slug}`, {
    next: {
      // Invalidar cuando se actualice CUALQUIER post
      tags: ['blog-posts'],
    },
  });

  return res.json();
}

// Invalidar TODAS las páginas de blog
import { revalidatePath } from 'next/cache';

export async function POST() {
  await publishNewPost();

  // Revalida todas las rutas bajo /blog
  revalidatePath('/blog', 'page');

  return Response.json({ revalidated: true });
}

6. Estrategias Avanzadas de Caching

Patrón 1: Stale-While-Revalidate con fetch

// Implementación de SWR con Next.js 15 fetch
async function getProductList() {
  const res = await fetch('https://api.example.com/products', {
    next: {
      revalidate: 300, // 5 minutos
    },
  });

  return res.json();
}

// En tu Server Component:
export default async function ProductsPage() {
  // Primera request: fresh data
  // Requests siguientes (5 min): stale data instantáneo
  // Background: revalidación automática
  const products = await getProductList();

  return (
    <div>
      <h1>Products (updated every 5min)</h1>
      {products.map(p => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  );
}

Ventajas:

  • Respuesta inmediata: usuarios ven contenido instantáneamente
  • Fresh data eventual: background revalidation
  • Carga reducida en API: no se golpea en cada request

Patrón 2: Cache Jerárquico con Tags

// Estrategia de cache multinivel
async function getStoreData(storeId: string) {
  const [store, products, categories] = await Promise.all([
    // Nivel 1: Datos de tienda (cambian rara vez)
    fetch(`https://api.example.com/stores/${storeId}`, {
      next: { tags: [`store-${storeId}`] },
    }).then(r => r.json()),

    // Nivel 2: Productos (cambian frecuentemente)
    fetch(`https://api.example.com/stores/${storeId}/products`, {
      next: {
        tags: [`store-${storeId}-products`],
        revalidate: 60, // 1 minuto
      },
    }).then(r => r.json()),

    // Nivel 3: Categorías (cambian muy rara vez)
    fetch(`https://api.example.com/stores/${storeId}/categories`, {
      next: { tags: [`store-${storeId}-categories`] },
    }).then(r => r.json()),
  ]);

  return { store, products, categories };
}

// Invalidación inteligente
import { revalidateTag } from 'next/cache';

export async function updateProduct(productId: string, data: any) {
  await fetch(`https://api.example.com/products/${productId}`, {
    method: 'PATCH',
    body: JSON.stringify(data),
  });

  // Solo invalidamos productos, NO toda la tienda
  revalidateTag(`store-${data.storeId}-products`);

  return { success: true };
}

Ventajas:

  • Granularidad quirúrgica: invalidas solo lo necesario
  • Performance óptimo: cada capa tiene su propia TTL
  • UX consistente: layout de tienda permanece instantáneo

Patrón 3: Fetch Condicional según Tipo de Usuario

import { cookies } from 'next/headers';

async function getUserData() {
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get('session');

  // Si NO hay sesión: contenido público (cacheable agresivo)
  if (!sessionToken) {
    const res = await fetch('https://api.example.com/public-content', {
      cache: 'force-cache',
    });
    return { type: 'public', data: await res.json() };
  }

  // Si hay sesión: datos personalizados (sin caché)
  const res = await fetch('https://api.example.com/user/profile', {
    headers: {
      Authorization: `Bearer ${sessionToken.value}`,
    },
    // No cache: datos privados deben ser siempre fresh
  });

  return { type: 'private', data: await res.json() };
}

Ventajas:

  • Máximo rendimiento para contenido anónimo
  • Máxima seguridad para contenido autenticado
  • Sin data leaking entre usuarios

Patrón 4: Cache Distribuido con unstable_cache (Experimental)

import { unstable_cache } from 'next/cache';

// Crear una función cacheada custom
const getCachedUserData = unstable_cache(
  async (userId: string) => {
    const res = await fetch(`https://api.example.com/users/${userId}`);
    return res.json();
  },
  ['user-data'], // Claves de cache
  {
    revalidate: 3600, // 1 hora
    tags: [`user`], // Tags para invalidación
  }
);

// Uso en Server Component
export default async function UserProfile({ userId }: { userId: string }) {
  // Primera llamada: fetch real
  // Llamadas subsiguientes: caché
  const user = await getCachedUserData(userId);

  return <div>{user.name}</div>;
}

Ventajas:

  • Reutilización: misma lógica de cache en múltiples componentes
  • Centralización: política de cache en un solo lugar
  • Flexibilidad: lógica custom de cache antes de fetch

⚠️ Warning: unstable_cache es experimental y su API puede cambiar. Úsalo con precaución en producción.


7. Guía de Migración: Next.js 14 → 15

Paso 1: Auditoría de Fetch Existentes

Encuentra todos tus fetch sin opciones de cache:

# Grep para encontrar fetch sospechosos
grep -r "await fetch(" ./app --include="*.tsx" --include="*.ts"

Categoriza tus fetch:

// Categoría A: Datos estáticos (caché largo)
fetch('/api/articles') // → cache: 'force-cache'

// Categoría B: Datos dinámicos (caché corto)
fetch('/api/stock-prices') // → next: { revalidate: 30 }

// Categoría C: Datos personales (sin caché)
fetch('/api/user/profile') // → Sin cambios (ya es default)

// Categoría D: Datos en tiempo real (sin caché)
fetch('/api/live-updates') // → Sin cambios

Paso 2: Actualización Gradual de Fetch

Ejemplo real – Página de Blog:

Next.js 14 (Antes):

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
  // ⚠️ Cache implícito (comportamiento v14)
  const post = await fetch(
    `https://cms.example.com/posts/${params.slug}`
  ).then(r => r.json());

  // ⚠️ También cache implícito
  const comments = await fetch(
    `https://api.example.com/posts/${params.slug}/comments`
  ).then(r => r.json());

  return <PostView post={post} comments={comments} />;
}

Next.js 15 (Después):

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
  // ✅ Cache explícito: contenido cambia rara vez
  const post = await fetch(
    `https://cms.example.com/posts/${params.slug}`,
    {
      next: {
        revalidate: 3600, // 1 hora
        tags: [`post-${params.slug}`],
      },
    }
  ).then(r => r.json());

  // ✅ Sin caché: comentarios son dinámicos
  const comments = await fetch(
    `https://api.example.com/posts/${params.slug}/comments`
    // No cache: siempre fresh
  ).then(r => r.json());

  return <PostView post={post} comments={comments} />;
}

// Route Handler para invalidar posts
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const { slug } = await request.json();

  // Invalidar post específico cuando se actualice
  revalidateTag(`post-${slug}`);

  return NextResponse.json({ revalidated: true });
}

Paso 3: Actualización de Route Handlers

Next.js 14 (Antes):

// app/api/products/route.ts
export async function GET() {
  const products = await fetch('https://api.example.com/products')
    .then(r => r.json());

  // ⚠️ Response cacheada automáticamente (v14)
  return Response.json(products);
}

Next.js 15 (Después):

// app/api/products/route.ts
export async function GET() {
  const products = await fetch('https://api.example.com/products')
    .then(r => r.json());

  // ✅ Para cachear en v15: explícito
  return Response.json(products, {
    headers: {
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
    },
  });
}

Paso 4: Migración de ISR (Incremental Static Regeneration)

Next.js 14 (Antes):

// app/blog/page.tsx
export const revalidate = 3600; // ISR cada hora

export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts')
    .then(r => r.json());

  return <PostList posts={posts} />;
}

Next.js 15 (Después):

// app/blog/page.tsx
export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts', {
    next: {
      revalidate: 3600, // Mismo comportamiento, más explícito
      tags: ['blog-posts'],
    },
  }).then(r => r.json());

  return <PostList posts={posts} />;
}

// Opcional: revalidación on-demand
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';

export async function POST() {
  revalidateTag('blog-posts');
  return Response.json({ revalidated: true });
}

8. Cuándo Usar Cache vs Uncached

Framework de Decisión

Usa este árbol de decisión para cada petición fetch:

¿Los datos son específicos del usuario actual?
├─ Sí → UNCACHED (default)
│  Ejemplo: /api/user/profile, /api/cart
│
└─ No → ¿Cambian frecuentemente?
   ├─ Sí → REVALIDATE CORTO
   │  Ejemplo: next: { revalidate: 30-60 }
   │  Casos: Stock prices, live scores
   │
   └─ No → ¿Se actualizan manualmente?
      ├─ Sí → TAGS + INVALIDACIÓN MANUAL
      │  Ejemplo: next: { tags: ['product-123'] }
      │  Casos: CMS content, product catalog
      │
      └─ No → CACHE FORZADO LARGO
         Ejemplo: cache: 'force-cache'
         Casos: Static assets, rarely-changing data

Ejemplos de Casos de Uso Reales

1. E-commerce – Página de Producto

async function getProductPage(productId: string) {
  const [product, reviews, related] = await Promise.all([
    // Producto: cache largo con tags
    fetch(`https://api.example.com/products/${productId}`, {
      next: {
        revalidate: 86400, // 24 horas
        tags: [`product-${productId}`],
      },
    }).then(r => r.json()),

    // Reviews: cache medio (se agregan frecuentemente)
    fetch(`https://api.example.com/products/${productId}/reviews`, {
      next: { revalidate: 300 }, // 5 minutos
    }).then(r => r.json()),

    // Productos relacionados: cache largo
    fetch(`https://api.example.com/products/${productId}/related`, {
      next: { revalidate: 3600 }, // 1 hora
    }).then(r => r.json()),
  ]);

  return { product, reviews, related };
}

2. Dashboard de Analytics

import { cookies } from 'next/headers';

async function getAnalytics() {
  const cookieStore = await cookies();
  const userId = cookieStore.get('user-id')?.value;

  // Datos personalizados: NUNCA cachear (privacidad)
  const userMetrics = await fetch(
    `https://analytics.example.com/user/${userId}/metrics`
  ).then(r => r.json());

  // Datos agregados anónimos: cacheable
  const globalStats = await fetch(
    'https://analytics.example.com/stats/global',
    {
      next: { revalidate: 600 }, // 10 minutos
    }
  ).then(r => r.json());

  return { userMetrics, globalStats };
}

3. Sitio de Noticias

async function getNewsHomepage() {
  const [breaking, topStories, categories] = await Promise.all([
    // Breaking news: sin caché (deben ser ultra-fresh)
    fetch('https://news.example.com/breaking').then(r => r.json()),

    // Top stories: revalidación rápida
    fetch('https://news.example.com/top-stories', {
      next: { revalidate: 120 }, // 2 minutos
    }).then(r => r.json()),

    // Categorías: cache largo
    fetch('https://news.example.com/categories', {
      cache: 'force-cache',
    }).then(r => r.json()),
  ]);

  return { breaking, topStories, categories };
}

9. Mejores Prácticas y Anti-Patrones

✅ Best Practices

1. Siempre documenta la estrategia de cache

/**
 * Obtiene lista de productos.
 *
 * Cache: 5 minutos con revalidación automática.
 * Razón: Los precios cambian frecuentemente, pero no necesitamos
 * actualización en tiempo real.
 *
 * Invalidación manual: usa revalidateTag(['products']) cuando
 * se actualice el catálogo desde el CMS.
 */
async function getProducts() {
  return fetch('https://api.example.com/products', {
    next: {
      revalidate: 300,
      tags: ['products'],
    },
  }).then(r => r.json());
}

2. Usa tags jerárquicos para invalidación granular

// ✅ Buen diseño: tags específicos
fetch(`/api/stores/${storeId}/products/${productId}`, {
  next: {
    tags: [
      'stores', // Global: todas las tiendas
      `store-${storeId}`, // Específico de tienda
      `store-${storeId}-products`, // Productos de tienda
      `product-${productId}`, // Producto individual
    ],
  },
});

// Invalidación quirúrgica:
revalidateTag(`product-${productId}`); // Solo un producto
revalidateTag(`store-${storeId}-products`); // Todos los productos de una tienda
revalidateTag('stores'); // Todas las tiendas (emergency use)

3. Combina cache con preloading de datos

// app/layout.tsx
async function getNavigationData() {
  // Navigation cambia muy rara vez
  return fetch('https://api.example.com/navigation', {
    next: { revalidate: 86400 }, // 24 horas
  }).then(r => r.json());
}

export default async function RootLayout({ children }) {
  const navItems = await getNavigationData();

  return (
    <html>
      <body>
        <Navigation items={navItems} />
        {children}
      </body>
    </html>
  );
}

❌ Anti-Patrones a Evitar

1. NO caches datos sensibles

// ❌ PELIGROSO: Data privada cacheada
async function getBankAccount() {
  return fetch('https://api.bank.com/account/123456', {
    cache: 'force-cache', // ¡NO HAGAS ESTO!
  }).then(r => r.json());
  // Otro usuario podría ver estos datos
}

// ✅ CORRECTO: Sin caché para datos privados
async function getBankAccount() {
  return fetch('https://api.bank.com/account/123456')
    .then(r => r.json());
}

2. NO uses revalidate muy corto para datos que no lo necesitan

// ❌ INEFICIENTE: Revalidación excesiva
async function getAboutPage() {
  return fetch('/api/about', {
    next: { revalidate: 1 }, // Cada segundo ¿en serio?
  }).then(r => r.json());
  // El contenido "About" cambia quizás una vez al mes
}

// ✅ CORRECTO: Cache largo para contenido estático
async function getAboutPage() {
  return fetch('/api/about', {
    next: { revalidate: 86400 }, // 24 horas
  }).then(r => r.json());
}

3. NO olvides invalidar caché después de mutaciones

// ❌ INCONSISTENTE: Actualizas pero no invalidas
async function updateProduct(productId: string, data: any) {
  await fetch(`/api/products/${productId}`, {
    method: 'PATCH',
    body: JSON.stringify(data),
  });
  // ⚠️ El cache sigue sirviendo datos viejos
  return { success: true };
}

// ✅ CORRECTO: Invalidación inmediata
import { revalidateTag } from 'next/cache';

async function updateProduct(productId: string, data: any) {
  await fetch(`/api/products/${productId}`, {
    method: 'PATCH',
    body: JSON.stringify(data),
  });

  // Invalidar caché de este producto
  revalidateTag(`product-${productId}`);

  return { success: true };
}

4. NO mezcles estrategias de cache sin criterio

// ❌ CONFUSO: 3 estrategias diferentes sin razón clara
async function getPosts() {
  const [featured, recent, popular] = await Promise.all([
    fetch('/api/posts/featured', { cache: 'force-cache' }),
    fetch('/api/posts/recent', { next: { revalidate: 60 } }),
    fetch('/api/posts/popular'), // default: no cache
  ]);
  // ¿Por qué cada uno tiene estrategia diferente?
}

// ✅ CLARO: Comentario explica cada decisión
async function getPosts() {
  const [featured, recent, popular] = await Promise.all([
    // Featured: seleccionado manualmente por admin, cambia rara vez
    fetch('/api/posts/featured', {
      cache: 'force-cache',
      next: { tags: ['featured-posts'] },
    }),

    // Recent: new posts every few minutes
    fetch('/api/posts/recent', {
      next: { revalidate: 300 }, // 5 minutes
    }),

    // Popular: calculated in real-time from analytics
    fetch('/api/posts/popular'),
    // No cache: must reflect current trends
  ]);
}

10. Preguntas Frecuentes (FAQ)

1. ¿”Uncached by default” no matará el rendimiento de mi aplicación?

No, al contrario. La premisa de que “cache agresivo = mejor rendimiento” es un mito peligroso. El problema con el cache de v14 era que cacheaba indiscriminadamente, incluyendo datos que no debían estarlo (perfiles de usuario, carritos de compra, datos en tiempo real). Esto generaba dos problemas: (1) usuarios veían datos obsoletos, y (2) el overhead de invalidación compleja era mayor que el beneficio del cache.

Next.js 15 adopta un enfoque intencional: cacheas solo lo que tiene sentido cachear. El resultado es un sistema más predecible donde el 80% de las peticiones (datos estáticos, contenido CMS, catálogos) terminan cacheados de forma explícita, mientras que el 20% dinámico se mantiene fresh. Además, puedes combinar esto con HTTP caching en Vercel Edge Network para lograr latencias < 50ms en contenido estático.

Ejemplo práctico: Un e-commerce que migramos recientemente pasó de servir datos de productos stale por 2 horas (v14) a un sistema híbrido donde 95% del catálogo tiene cache de 1 hora con revalidación instantánea cuando hay actualizaciones de inventario (v15). Resultado: -40% en soporte por “productos agotados que aún aparecen disponibles” y sin impacto negativo en Lighthouse Performance scores.

2. ¿Cómo convencer a mi equipo para migrar si todo funciona en v14?

El argumento técnico: Next.js 14 ya está en modo mantenimiento (solo security fixes). Next.js 15 introduce mejoras críticas: React 19, Turbopack estable, mejor soporte de Partial Prerendering, y sí, el nuevo modelo de cache. Quedarse en v14 significa perder 12-18 meses de innovación.

El argumento de negocio: Calcula el costo del “cache debugging”. ¿Cuántas horas por semana pierde tu equipo investigando por qué los datos no se actualizan? ¿Cuántos tickets de soporte se generan por “información desactualizada”? En una mediana empresa (50 devs), esto puede representar $200K-$500K anuales en productividad perdida.

Estrategia de migración gradual propuesta:

  1. Week 1-2: Auditoría de fetch y clasificación (static/dynamic/personalized)
  2. Week 3-4: Migración de features non-critical (blog, about pages)
  3. Week 5-8: Migración de core features con shadow testing (v14 y v15 en paralelo)
  4. Week 9: Cutover completo con rollback plan preparado

3. ¿Qué pasa con mis páginas estáticas generadas con generateStaticParams?

Buenas noticias: generateStaticParams sigue funcionando exactamente igual en Next.js 15. El cambio ‘uncached by default’ NO afecta la generación de páginas estáticas en build time.

// app/products/[id]/page.tsx
export async function generateStaticParams() {
  const products = await fetch('https://api.example.com/products')
    .then(r => r.json());

  // Genera páginas estáticas para los primeros 100 productos
  return products.slice(0, 100).map((product) => ({
    id: product.id,
  }));
}

export default async function ProductPage({ params }) {
  // En build time: se ejecuta y cachea estáticamente
  // En runtime: puedes elegir estrategia
  const product = await fetch(
    `https://api.example.com/products/${params.id}`,
    {
      next: {
        revalidate: 3600, // ISR: revalida cada hora
      },
    }
  ).then(r => r.json());

  return <ProductView product={product} />;
}

La diferencia clave: En v14, este fetch tenía cache implícito. En v15, debes explícitar el revalidate si quieres ISR. Si no añades next.revalidate, la página será true static (generada en build, nunca cambia hasta el próximo deployment).

4. ¿Puedo mezclar fetch con cache y sin cache en el mismo componente?

Absolutamente, y de hecho es un patrón recomendado. La mayoría de las páginas reales tienen datos con diferentes semánticas de frescura:

async function DashboardPage({ userId }) {
  const [user, notifications, globalStats] = await Promise.all([
    // Sin cache: datos personalizados del usuario
    fetch(`/api/users/${userId}`).then(r => r.json()),

    // Sin cache: notificaciones deben ser realtime
    fetch(`/api/users/${userId}/notifications`).then(r => r.json()),

    // Con cache: stats globales anónimos
    fetch('/api/stats/global', {
      next: { revalidate: 600 },
    }).then(r => r.json()),
  ]);

  return (
    <DashboardLayout>
      <UserProfile user={user} />
      <NotificationCenter notifications={notifications} />
      <StatsOverview stats={globalStats} />
    </DashboardLayout>
  );
}

Best practice: Agrupa tus fetch por estrategia. Todos los uncached al principio (datos personalizados), luego los cacheados (contenido compartido). Esto hace el código más legible y el comportamiento más predecible.

5. ¿Cómo manejo la revalidación de múltiples recursos relacionados?

Usa tags jerárquicos con un naming convention consistente. Esta es la estrategia más potente de Next.js 15:

// Sistema de tags multinivel
const productPageFetch = fetch(`/api/products/${productId}`, {
  next: {
    tags: [
      'products', // Nivel 1: global (todos los productos)
      `category-${categoryId}`, // Nivel 2: categoría
      `product-${productId}`, // Nivel 3: producto individual
    ],
  },
});

// Invalidación a diferentes niveles:
import { revalidateTag } from 'next/cache';

// Nivel 1: Invalidar todo (emergency use - deployment)
revalidateTag('products');

// Nivel 2: Invalidar categoría completa
revalidateTag(`category-${categoryId}`);

// Nivel 3: Invalidar solo un producto
revalidateTag(`product-${productId}`);

Casos de uso reales:

  • Producto actualizado: revalidateTag('product-123')
  • Campaña de precios en categoría: revalidateTag('category-electronics')
  • New feature launch: revalidateTag('products')

⚠️ Warning: No abuses de tags globales. Invalidar ‘products’ afecta a TODO tu catálogo y puede causar un spike de tráfico a tu API. Úsalo solo para actualizaciones masivas controladas.

6. ¿El cambio afecta a Client Components que usan fetch?

Respuesta corta: No directamente, pero sí indirectamente.

Respuesta larga: Los Client Components NO pueden usar fetch directamente con las opciones next: { revalidate, tags }. Esas opciones son exclusivas de Server Components y Route Handlers. Sin embargo, el cache del servidor SÍ afecta a lo que reciben los Client Components.

// ❌ ERROR: Esto NO funciona
'use client';

export function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // Las opciones 'next:' son ignoradas en Client Components
    fetch('/api/user/profile', {
      next: { revalidate: 60 }, // Ignorado
    }).then(r => r.json().then(setUser));
  }, []);

  return <div>{user?.name}</div>;
}

// ✅ CORRECTO: Patrón Server + Client
// Server Component hace el fetch con cache
async function getUser() {
  return fetch('/api/user/profile', {
    next: { revalidate: 60 },
  }).then(r => r.json());
}

export default async function UserProfileServer() {
  const user = await getUser();

  // Client Component recibe los datos ya fetched
  return <UserProfileClient user={user} />;
}

'use client';

export function UserProfileClient({ user }) {
  // user ya viene con cache aplicado del servidor
  return <div>{user.name}</div>;
}

Patrón recomendado: Fetch en Server Component → pasar datos como props → Client Component para interactividad. Este es el flujo idiomático de React Server Components.

7. ¿Cómo testeo que mi estrategia de cache funciona correctamente?

Nivel 1: Test unitario de fetch options

// __tests__/data-fetching.test.ts
import { getUser } from '@/app/users/data';

describe('Data fetching cache strategy', () => {
  it('should use force-cache for static data', async () => {
    const globalFetch = global.fetch;
    global.fetch = jest.fn();

    await getStaticContent();

    expect(global.fetch).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({
        cache: 'force-cache',
      })
    );

    global.fetch = globalFetch;
  });

  it('should use revalidate for dynamic data', async () => {
    const globalFetch = global.fetch;
    global.fetch = jest.fn();

    await getArticles();

    expect(global.fetch).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({
        next: expect.objectContaining({
          revalidate: expect.any(Number),
        }),
      })
    );

    global.fetch = globalFetch;
  });
});

Nivel 2: Integration test con headers de respuesta

// __tests__/cache-headers.test.ts
import { GET } from '@/app/api/articles/route';

describe('API Route caching', () => {
  it('should return Cache-Control header', async () => {
    const request = new Request('http://localhost:3000/api/articles');
    const response = await GET(request);

    expect(response.headers.get('Cache-Control')).toBe(
      'public, s-maxage=60, stale-while-revalidate=300'
    );
  });
});

Nivel 3: Manual testing con DevTools

  1. Abre Chrome DevTools → Network tab
  2. Activa “Disable cache”
  3. Haz una request a tu página
  4. Revisa el header x-nextjs-cache:
    • MISS: No cacheó (first fetch)
    • HIT: Sirvió desde cache
    • STALE: Sirvió stale, revalidando en background

Nivel 4: E2E test con Playwright

// e2e/cache.spec.ts
import { test, expect } from '@playwright/test';

test('article page should use cache', async ({ page }) => {
  // Primera visita: cache miss
  await page.goto('/articles/intro-to-nextjs-15');
  await expect(page.locator('h1')).toContainText('Introduction');

  // Segunda visita: debería ser instantánea (cache hit)
  const startTime = Date.now();
  await page.goto('/articles/intro-to-nextjs-15');
  const loadTime = Date.now() - startTime;

  // Cache hit debería ser < 100ms
  expect(loadTime).toBeLessThan(100);
});

11. Takeaways Clave

  • 🎯 Paradigma invertido: Next.js 15 pasa de “cachea todo por defecto” a “cachea nada por defecto”, alineándose con los principios de transparencia y predecibilidad de React.
  • 🎯 Explicititud sobre magia: Ahora cada petición fetch debe declarar explícitamente su intención: sin caché (default), cache forzado, o revalidación basada en tiempo/tags.
  • 🎯 Granularidad quirúrgica: El sistema de tags (revalidateTag) permite invalidar recursos individuales (ej: un producto) sin afectar al resto del catálogo, eliminando invalidaciones masivas costosas.
  • 🎯 Patrón stale-while-revalidate nativo: Con next: { revalidate: N }, Next.js implementa automáticamente SWR: sirve stale data instantáneamente mientras revalida en background, optimizando UX y carga de API.
  • 🎯 Migración intencional, no automática: El cambio rompe backward compatibility pero a cambio elimina una clase entera de bugs (over-caching, data leaking, invalidación impredecible), resultando en aplicaciones más mantenibles a largo plazo.

12. Conclusión

La decisión de Next.js 15 de hacer fetch ‘uncached by default’ es, a contra reloj, uno de los movimientos más valientes y visionarios en la historia del framework. En un mundo donde la tendencia es “todo más rápido, más optimizado, más mágico”, Vercel tuvo la disciplina de decir: esperemos, volvamos a los fundamentos.

La lección arquitectónica es clara: la optimización prematura es la raíz de muchos males. Next.js 14 cacheaba agresivamente porque asumía que “rápido es siempre mejor”. Next.js 15 entiende que correcto > rápido, y que un sistema predecible donde tú controlas cada aspecto del cache es infinitamente superior a un sistema “mágico” que hace cosas detrás de escena.

La perspectiva futura (2026-2027): Este cambio posiciona a Next.js para la próxima década. Con la evolución de React Server Components, Edge Computing, y arquitecturas incrementales como Partial Prerendering, el modelo de cache explícito se vuelve fundamental. Estamos viendo el inicio de una nueva era donde la transparencia arquitectónica prima sobre la optimización opaca.

Tu siguiente acción:

  1. Si tienes proyectos en Next.js 14, comienza el plan de migración
  2. Si estás starting fresh, adopta Next.js 15 desde day one
  3. Audita tu estrategia de datos actual y clasifica: static vs dynamic vs personalized
  4. Implementa un sistema de tags jerárquicos para invalidación granular
  5. Monitorea métricas de cache hits/misses para optimizar iterativamente

El futuro del desarrollo web es explícito, predecible y potente. Next.js 15 te da las herramientas para construirlo.

¿Has migrado tu aplicación a Next.js 15? ¿Qué desafíos enfrentaste con el nuevo modelo de cache? Comparte tu experiencia en los comentarios o en el discussion board de Next.js en GitHub.


13. Recursos Adicionales

Documentación Oficial:

Artículos de Profundización:

Herramientas:

Comunidad:


14. Ruta de Aprendizaje (Siguientes Pasos)

Ahora que dominas el modelo de cache de Next.js 15, estos son los temas lógicos para continuar tu journey:

  1. Partial Prerendering (PPR): El próximo frontier de Next.js. Combina renderizado estático shell con streaming dinámico, permitiendo páginas instantáneas con contenido personalizado. Aprende cómo unstable_ppr y Suspense boundaries se integran con tu nueva estrategia de cache. Documentación oficial de PPR.
  2. Server Actions con Mutations y Revalidation: El modelo completo incluye no solo leer datos (fetch) sino también escribirlos (mutaciones) y invalidar cache coherente. Domina el patrón: Server Action → Database Update → revalidateTag() → UI update automática. Es la base de aplicaciones tipo Single Page App pero con Server Components. Guía de Server Actions.
  3. Edge Runtime y Edge Config: Lleva tu estrategia de cache al siguiente nivel con Edge Functions en Vercel Edge Network. Aprende a usar EdgeConfig para invalidaciones worldwide instantáneas y patrones como Edge Side Includes (ESI) para composición de UI en el edge. Edge Runtime Documentation.

15. Challenge Práctico

Objetivo: Construir una API de E-commerce completa que demuestre dominio del nuevo modelo de cache de Next.js 15.

Requisitos Mínimos:

1. Catálogo de Productos (4 endpoints)

  • GET /api/products – Lista de productos (cache: 5 min)
  • GET /api/products/[id] – Detalle de producto (tags: product-[id])
  • GET /api/categories – Categorías (force-cache, cambia rara vez)
  • GET /api/products/[id]/reviews – Reviews (cache: 15 min)

2. Invalidación Inteligente

  • POST /api/products/[id] – Crear producto → invalidar /api/products
  • PATCH /api/products/[id] – Actualizar producto → invalidar tag product-[id]
  • POST /api/products/[id]/reviews – Agregar review → invalidar reviews de ese producto

3. Dashboard de Usuario (Personalized Data)

  • GET /api/user/cart – Carrito (SIN cache, datos sensibles)
  • GET /api/user/orders – Historial de órdenes (SIN cache)
  • GET /api/user/recommendations – Recomendaciones (cache: 24h, tags: user-[id]-recs)

4. Frontend Demo

  • Página de producto: combina datos cacheados (producto) + uncached (reviews)
  • Dashboard: datos personalizados sin mezclar con cache global
  • Botón “Edit Product” que actualiza y invalida cache en tiempo real

Bonus (Avanzado):

  • Implementa stale-while-revalidate visual con skeletons
  • Añade preloading de datos con <Link prefetch>
  • Crea un monitor de cache health que muestre hit/miss rates
  • Integra Edge Config para invalidación global instantánea

Tiempo Estimado: 2-3 horas

¿Qué lograrás?

  • Dominio práctico de todas las APIs de cache de Next.js 15
  • Understanding profundo de cuándo usar cada estrategia
  • Portfolio piece que demuestra conocimiento de arquitectura de datos moderna

Starter Suggestion: Fork este repositorio template (ejemplo oficial de e-commerce) y moderniza su estrategia de cache a Next.js 15.


Firma del Autor:
Este artículo fue creado con investigación de fuentes oficiales de Next.js, discusiones de GitHub, y experiencia práctica en migraciones de producción a Next.js 15. Última actualización: Enero 2026. Para sugerencias o correcciones, abre un PR en el repositorio de este blog.

¿Encontraste útil este artículo? Comparte en Twitter/LinkedIn con hashtag #NextJS15 y etiqueta @vercel para que la comunidad lo descubra.


Créditos de Imagen: El diagrama de arquitectura fue inspirado en la documentación oficial de Next.js Caching y visualizado con estilo técnico minimalista usando principios de diagramación de The Pragmatic Programmer.

License: Este artículo está licenciado bajo CC BY-NC-SA 4.0. Eres libre de compartir y adaptar con atribución, pero no para uso comercial. Para licencias comerciales, contacta al autor.

Version History:

  • v1.0 (Enero 2026): Artículo inicial cubriendo Next.js 15.0
  • Próxima actualización: Marzo 2026 (cubrirá Next.js 15.2 y nuevas features de cache)

Fuentes Consultadas:

Deja un comentario

Scroll al inicio

Discover more from Creapolis

Subscribe now to keep reading and get access to the full archive.

Continue reading