Tema
Data fetching y caché
En Next.js, obtener datos es tan simple como hacer await dentro de un Server Component. Lo interesante es cuándo se vuelven a pedir esos datos: en cada visita, cada cierto tiempo o sólo cuando algo cambia. En esta lección dominarás el fetch del servidor, la caché, la revalidación, las peticiones en paralelo y el streaming con Suspense.
Concepto
En un Server Component no necesitas useEffect ni estados de carga manuales:
tsx
export default async function Pagina() {
const datos = await obtenerDatos() // se ejecuta en el servidor
return <Lista datos={datos} />
}La pregunta clave es: ¿cuánto tiempo pueden reutilizarse estos datos?
| Tipo de dato | Ejemplo | Estrategia |
|---|---|---|
| Casi nunca cambia | Textos legales, categorías | Caché indefinida |
| Cambia de vez en cuando | Catálogo, blog | Revalidar cada X tiempo o al editar |
| Cambia constantemente o es personal | Carrito, notificaciones, panel del usuario | Sin caché (dinámico) |
Analogía: la caché es el refrigerador. Puedes cocinar cada vez que alguien tiene hambre (sin caché), guardar comida y tirarla cada 3 días (revalidar por tiempo), o guardarla hasta que alguien avise "ya se echó a perder" (revalidar bajo demanda).
fetch en el servidor
Next.js extiende fetch con opciones de caché:
tsx
// 1. Sin caché (por defecto desde Next.js 15): se pide en cada render
const res = await fetch('https://api.ejemplo.com/precios')
// 2. Caché indefinida: se guarda hasta que la revalides o hagas un nuevo build
const res2 = await fetch('https://api.ejemplo.com/categorias', { cache: 'force-cache' })
// 3. Revalidar por tiempo (ISR): reutiliza hasta 1 hora y luego se refresca
const res3 = await fetch('https://api.ejemplo.com/productos', { next: { revalidate: 3600 } })
// 4. Con etiquetas para revalidar bajo demanda
const res4 = await fetch('https://api.ejemplo.com/productos', {
next: { revalidate: 3600, tags: ['productos'] }
})Cambio en Next.js 15
En Next.js 14, fetch se cacheaba por defecto. Desde Next.js 15 no se cachea por defecto. Si sigues un tutorial antiguo, verás comportamientos distintos.
Opciones a nivel de página
En lugar de configurar cada fetch, puedes configurar el segmento completo:
tsx
// app/blog/page.tsx
// Revalidar toda la página cada 10 minutos (ISR)
export const revalidate = 600
// O forzar comportamientos:
// export const dynamic = 'force-dynamic' // siempre SSR
// export const dynamic = 'force-static' // siempre estáticoDatos sin fetch: base de datos y SDKs
Si lees de una base de datos con Prisma o de un SDK, no hay fetch que configurar. La página se comporta según su configuración de segmento (revalidate, dynamic). Para cachear funciones específicas, Next.js ofrece unstable_cache (y en versiones recientes la directiva 'use cache'):
ts
// lib/productos.ts
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'
export const obtenerProductosDestacados = unstable_cache(
async () => {
return prisma.producto.findMany({ where: { destacado: true }, take: 8 })
},
['productos-destacados'], // clave de caché
{ revalidate: 3600, tags: ['productos'] }
)'use cache' (Next.js 16+)
Next.js 16 introduce Cache Components: activas cacheComponents: true en next.config.ts y marcas funciones o componentes con la directiva 'use cache', junto con cacheLife('hours') y cacheTag('productos'). Es la evolución de unstable_cache. La idea es la misma: decidir qué se guarda, cuánto tiempo y con qué etiqueta invalidarlo.
ts
import { cacheLife, cacheTag } from 'next/cache'
export async function obtenerCategorias() {
'use cache'
cacheLife('days')
cacheTag('categorias')
return prisma.categoria.findMany()
}Evitar consultas duplicadas: cache de React
Si generateMetadata y la página necesitan el mismo dato, envuelve la función con cache de React para que se ejecute una sola vez por petición:
ts
// lib/posts.ts
import { cache } from 'react'
import { prisma } from '@/lib/prisma'
export const obtenerPost = cache(async (slug: string) => {
return prisma.post.findUnique({ where: { slug } })
})(Las llamadas fetch con la misma URL y opciones se deduplican automáticamente durante un render.)
Revalidación bajo demanda
Cuando el cliente edita un producto en su panel, quieres que la tienda se actualice ya, no en una hora. Usa revalidatePath o revalidateTag desde una Server Action o un Route Handler:
ts
// app/admin/acciones.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function actualizarProducto(id: number, datos: { precio: number }) {
// ...guardar en la base de datos
revalidatePath('/productos') // regenera esa ruta
revalidatePath(`/productos/${id}`)
revalidateTag('productos', 'max') // invalida todo lo etiquetado con 'productos'
}Diferencia entre versiones
En Next.js 15, revalidateTag('productos') recibe sólo la etiqueta. En Next.js 16 se recomienda pasar un perfil como segundo argumento ('max' sirve contenido viejo mientras regenera en segundo plano), y en Server Actions existe updateTag('productos') para que el usuario vea su propio cambio de inmediato.
Webhook desde un CMS
Si el contenido viene de un CMS (Sanity, Strapi, WordPress headless), el CMS puede llamar a un endpoint tuyo cada vez que se publica algo:
ts
// app/api/revalidar/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
const secreto = req.headers.get('x-webhook-secret')
if (secreto !== process.env.WEBHOOK_SECRET) {
return Response.json({ error: 'No autorizado' }, { status: 401 })
}
revalidateTag('posts', 'max')
return Response.json({ revalidado: true, fecha: Date.now() })
}Peticiones en paralelo
Si una página necesita varios datos independientes, no los pidas uno tras otro:
tsx
// ❌ En cascada: 1 s + 1 s + 1 s = 3 s
const usuario = await obtenerUsuario(id)
const pedidos = await obtenerPedidos(id)
const recomendaciones = await obtenerRecomendaciones()
// ✅ En paralelo: ~1 s
const [usuario, pedidos, recomendaciones] = await Promise.all([
obtenerUsuario(id),
obtenerPedidos(id),
obtenerRecomendaciones()
])Streaming con Suspense
¿Y si una parte es lenta (recomendaciones, reseñas) pero el resto es rápido? Con Suspense muestras lo rápido de inmediato y la parte lenta aparece cuando esté lista, sin bloquear la página.
tsx
import { Suspense } from 'react'
import Resenas from './Resenas'
import Relacionados from './Relacionados'
type Props = { params: Promise<{ id: string }> }
export default async function Producto({ params }: Props) {
const { id } = await params
// Dato principal: rápido y cacheado
const res = await fetch(`https://dummyjson.com/products/${id}`, { next: { revalidate: 3600 } })
const p = await res.json()
return (
<main className="mx-auto max-w-3xl space-y-8 p-8">
<section>
<h1 className="text-3xl font-bold">{p.title}</h1>
<p className="mt-2 text-2xl">${p.price}</p>
<p className="mt-4">{p.description}</p>
</section>
{/* Cada sección lenta se muestra cuando está lista */}
<Suspense fallback={<p className="animate-pulse text-gray-400">Cargando reseñas…</p>}>
<Resenas id={id} />
</Suspense>
<Suspense fallback={<div className="h-40 animate-pulse rounded bg-gray-100" />}>
<Relacionados categoria={p.category} />
</Suspense>
</main>
)
}tsx
type Resena = { rating: number; comment: string; reviewerName: string }
export default async function Resenas({ id }: { id: string }) {
await new Promise((r) => setTimeout(r, 2000)) // simula un servicio lento
const res = await fetch(`https://dummyjson.com/products/${id}?select=reviews`)
const { reviews }: { reviews: Resena[] } = await res.json()
return (
<section>
<h2 className="mb-3 text-xl font-bold">Reseñas</h2>
{reviews.map((r, i) => (
<article key={i} className="border-b py-2">
<strong>{r.reviewerName}</strong> {'★'.repeat(r.rating)}
<p>{r.comment}</p>
</article>
))}
</section>
)
}tsx
import Link from 'next/link'
export default async function Relacionados({ categoria }: { categoria: string }) {
await new Promise((r) => setTimeout(r, 3000))
const res = await fetch(`https://dummyjson.com/products/category/${categoria}?limit=4&select=title,price`, {
next: { revalidate: 3600 }
})
const { products } = await res.json()
return (
<section>
<h2 className="mb-3 text-xl font-bold">También te puede gustar</h2>
<ul className="grid grid-cols-2 gap-3">
{products.map((p: { id: number; title: string; price: number }) => (
<li key={p.id} className="rounded border p-3">
<Link href={`/producto/${p.id}`}>{p.title}</Link>
<p>${p.price}</p>
</li>
))}
</ul>
</section>
)
}El usuario ve el producto al instante, las reseñas a los 2 s y los relacionados a los 3 s. loading.tsx hace lo mismo pero para la página completa; Suspense te da control granular.
Datos en Client Components
A veces necesitas datos en el navegador (actualizaciones en tiempo real, datos que dependen de interacción). Opciones:
- Pasar una promesa del servidor y leerla con
use(React 19):
tsx
// app/estadisticas/page.tsx (Server)
import { Suspense } from 'react'
import Grafica from './Grafica'
export default function Pagina() {
const datosPromise = fetch('https://dummyjson.com/carts?limit=5').then((r) => r.json()) // sin await
return (
<Suspense fallback={<p>Cargando gráfica…</p>}>
<Grafica datosPromise={datosPromise} />
</Suspense>
)
}tsx
// app/estadisticas/Grafica.tsx (Client)
'use client'
import { use } from 'react'
export default function Grafica({ datosPromise }: { datosPromise: Promise<{ carts: { id: number; total: number }[] }> }) {
const { carts } = use(datosPromise) // suspende hasta que se resuelva
const max = Math.max(...carts.map((c) => c.total))
return (
<div className="space-y-2">
{carts.map((c) => (
<div key={c.id} className="h-6 rounded bg-violet-500 text-xs text-white" style={{ width: `${(c.total / max) * 100}%` }}>
#{c.id}
</div>
))}
</div>
)
}- TanStack Query o SWR en el cliente, igual que en React (ver Zustand y TanStack Query), para polling, scroll infinito o datos muy interactivos.
Resumen de decisiones
| Situación | Configuración |
|---|---|
| Datos públicos que cambian poco | fetch(url, { cache: 'force-cache' }) o export const revalidate = 86400 |
| Catálogo o blog | next: { revalidate: 3600, tags: [...] } + revalidateTag al editar |
| Datos del usuario (cookies/sesión) | Sin caché (por defecto) |
| Varias consultas independientes | Promise.all |
| Partes lentas de la página | Suspense con fallback |
| Misma consulta en metadata y página | cache() de React |
Buena práctica
Revisa la salida de npm run build: te dice qué rutas son estáticas (○) y cuáles dinámicas (ƒ). Si una página que esperabas estática aparece como dinámica, busca qué la hace dinámica (cookies(), searchParams, fetch sin caché).
Error común
Llamar a tu propio Route Handler (fetch('http://localhost:3000/api/productos')) desde un Server Component. Es un viaje de red innecesario y falla en el build. Llama directamente a la función que obtiene los datos (la misma que usa el Route Handler).
Ejercicios
- Crea
/noticiasque obtenga posts dehttps://jsonplaceholder.typicode.com/posts?_limit=5con revalidación cada 30 segundos y muestre la hora de generación. Verifica connpm run build && npm startque la hora cambia como máximo cada 30 s. - Crea
/perfil/[id]que obtenga en paralelo el usuario (/users/:id) y sus posts (/posts?userId=:id) de JSONPlaceholder, y muestre los posts dentro de unSuspensecon un retraso artificial.
Ver solución
tsx
// 1. app/noticias/page.tsx
export const revalidate = 30
export default async function Noticias() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
const posts: { id: number; title: string }[] = await res.json()
return (
<main className="p-8">
<p className="text-sm text-gray-500">Generado: {new Date().toLocaleTimeString('es-MX')}</p>
<ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
</main>
)
}tsx
// 2. app/perfil/[id]/page.tsx
import { Suspense } from 'react'
const API = 'https://jsonplaceholder.typicode.com'
async function Posts({ promesa }: { promesa: Promise<{ id: number; title: string }[]> }) {
await new Promise((r) => setTimeout(r, 1500))
const posts = await promesa
return <ul className="list-disc pl-5">{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}
export default async function Perfil({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
// Ambas peticiones arrancan al mismo tiempo
const usuarioP = fetch(`${API}/users/${id}`).then((r) => r.json())
const postsP = fetch(`${API}/posts?userId=${id}`).then((r) => r.json())
const usuario = await usuarioP // sólo esperamos al usuario
return (
<main className="p-8">
<h1 className="text-2xl font-bold">{usuario.name}</h1>
<p>{usuario.email}</p>
<Suspense fallback={<p>Cargando posts…</p>}>
<Posts promesa={postsP} />
</Suspense>
</main>
)
}Resumen
- En Server Components obtienes datos con
await; desde Next.js 15fetchno se cachea por defecto. - Controla la caché con
cache: 'force-cache',next.revalidate,next.tagsoexport const revalidate. - Revalida bajo demanda con
revalidatePath/revalidateTagal modificar datos. - Usa
Promise.allpara paralelizar ySuspensepara mostrar partes lentas sin bloquear la página.