Skip to content

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 datoEjemploEstrategia
Casi nunca cambiaTextos legales, categoríasCaché indefinida
Cambia de vez en cuandoCatálogo, blogRevalidar cada X tiempo o al editar
Cambia constantemente o es personalCarrito, notificaciones, panel del usuarioSin 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ático

Datos 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:

  1. 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>
  )
}
  1. 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ónConfiguración
Datos públicos que cambian pocofetch(url, { cache: 'force-cache' }) o export const revalidate = 86400
Catálogo o blognext: { revalidate: 3600, tags: [...] } + revalidateTag al editar
Datos del usuario (cookies/sesión)Sin caché (por defecto)
Varias consultas independientesPromise.all
Partes lentas de la páginaSuspense con fallback
Misma consulta en metadata y páginacache() 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 ​

  1. Crea /noticias que obtenga posts de https://jsonplaceholder.typicode.com/posts?_limit=5 con revalidación cada 30 segundos y muestre la hora de generación. Verifica con npm run build && npm start que la hora cambia como máximo cada 30 s.
  2. 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 un Suspense con 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 15 fetch no se cachea por defecto.
  • Controla la caché con cache: 'force-cache', next.revalidate, next.tags o export const revalidate.
  • Revalida bajo demanda con revalidatePath / revalidateTag al modificar datos.
  • Usa Promise.all para paralelizar y Suspense para mostrar partes lentas sin bloquear la página.

Academia Web — aprende, copia y construye.