Data Fetching - Obtención de Datos

Data fetching (obtención de datos) es el proceso de traer información desde algún lugar externo a tu aplicación: una API, una base de datos, un archivo, etc. En NextJS 15, este proceso es diferente y más simple que en versiones anteriores.

¿Qué es obtener datos?

Imagina que estás construyendo una tienda online. Necesitas:

  • Mostrar la lista de productos (vienen de tu base de datos)
  • Mostrar el perfil del usuario (viene de tu sistema de autenticación)
  • Mostrar reseñas de productos (vienen de una API externa)
  • Mostrar el carrito de compras (viene del navegador del usuario)

Cada vez que necesitas información que no está "hardcodeada" (escribe directamente) en tu código, necesitas "obtener datos".

Ejemplo de datos hardcodeados (NO recomendado para datos reales):

export default function ProductosPage() {
  // Datos escritos directamente en el código
  const productos = [
    { id: 1, nombre: 'Camisa', precio: 25 },
    { id: 2, nombre: 'Pantalón', precio: 40 },
  ]
  
  return <div>{/* Mostrar productos */}</div>
}

Ejemplo de datos obtenidos (recomendado):

export default async function ProductosPage() {
  // Datos obtenidos de una fuente externa
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(res => res.json())
  
  return <div>{/* Mostrar productos */}</div>
}

¿Qué cambió en NextJS 15?

En versiones anteriores de NextJS (y React tradicional), obtenías datos así:

// ❌ Forma antigua (NextJS 12 y anteriores)
'use client'

import { useEffect, useState } from 'react'

export default function ProductosPage() {
  const [productos, setProductos] = useState([])
  const [loading, setLoading] = useState(true)
  
  useEffect(() => {
    fetch('https://api.mitienda.com/productos')
      .then(res => res.json())
      .then(data => {
        setProductos(data)
        setLoading(false)
      })
  }, [])
  
  if (loading) return <div>Cargando...</div>
  
  return <div>{/* Mostrar productos */}</div>
}

Esto funciona, pero tiene problemas:

  • Mucho código repetitivo (boilerplate)
  • Necesitas gestionar estados de carga manualmente
  • Todo el código se ejecuta en el navegador del usuario
  • Más lento (el navegador debe descargar JavaScript primero)

En NextJS 15, con Server Components (componentes del servidor):

// ✓ Forma nueva (NextJS 15)
export default async function ProductosPage() {
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(res => res.json())
  
  return <div>{/* Mostrar productos */}</div>
}

Mucho más simple. Veamos por qué.

Dos lugares para obtener datos

En NextJS 15 puedes obtener datos en dos lugares diferentes:

1. En el servidor (Server Components)

Cuándo usar: La mayoría del tiempo.

Tus componentes pueden ser async (asíncronos) y obtener datos directamente:

// Este componente se ejecuta en el servidor
export default async function ProductosPage() {
  // Obtener datos en el servidor
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(res => res.json())
  
  return (
    <div>
      <h1>Nuestros Productos</h1>
      {productos.map(producto => (
        <div key={producto.id}>
          <h2>{producto.nombre}</h2>
          <p>${producto.precio}</p>
        </div>
      ))}
    </div>
  )
}

Ventajas:

  • Más rápido (el servidor ya tiene los datos cuando el usuario carga la página)
  • Más seguro (puedes acceder a tu base de datos directamente)
  • Menos JavaScript enviado al navegador
  • No necesitas gestionar estados de carga
  • Mejor para SEO (los motores de búsqueda ven el contenido inmediatamente)

Puedes hacer:

  • Consultar tu base de datos directamente
  • Usar API keys secretas
  • Leer archivos del servidor
  • Hacer llamadas a APIs externas
  • Procesar datos antes de enviarlos al cliente

2. En el cliente (Client Components)

Cuándo usar: Cuando los datos dependen de la interacción del usuario.

Para datos que necesitan actualizarse en respuesta a acciones del usuario:

'use client'

import { useState, useEffect } from 'react'

export default function BuscadorProductos() {
  const [busqueda, setBusqueda] = useState('')
  const [resultados, setResultados] = useState([])
  
  useEffect(() => {
    if (busqueda.length > 0) {
      fetch(`/api/buscar?q=${busqueda}`)
        .then(res => res.json())
        .then(data => setResultados(data))
    }
  }, [busqueda])
  
  return (
    <div>
      <input
        value={busqueda}
        onChange={(e) => setBusqueda(e.target.value)}
        placeholder="Buscar productos..."
      />
      {resultados.map(producto => (
        <div key={producto.id}>{producto.nombre}</div>
      ))}
    </div>
  )
}

Cuándo es necesario:

  • Búsqueda en tiempo real mientras el usuario escribe
  • Actualizar datos sin recargar la página
  • Datos que dependen del scroll, clicks, etc.
  • Datos específicos del navegador (geolocalización, preferencias locales)
💡
Regla general

Usa Server Components (servidor) para la carga inicial de datos. Usa Client Components (cliente) solo cuando los datos necesiten actualizarse por interacción del usuario.

Obtener datos en Server Components

La forma más común y recomendada en NextJS 15.

Sintaxis básica con fetch

fetch es una función estándar de JavaScript para obtener datos de URLs:

export default async function Page() {
  // await = espera a que los datos lleguen
  const response = await fetch('https://api.ejemplo.com/datos')
  const datos = await response.json()
  
  return <div>{datos.mensaje}</div>
}

Desglosando el código:

  1. async function - Le dice a JavaScript que esta función obtendrá datos
  2. await fetch(url) - Espera a que llegue la respuesta de la URL
  3. await response.json() - Convierte la respuesta a formato JSON
  4. Usas los datos normalmente en tu JSX
💡
Aprende más sobre estas herramientas

Si quieres dominar la obtención de datos en JavaScript, tenemos guías detalladas:

Estas guías te ayudarán a entender en profundidad las herramientas que usarás en NextJS.

Ejemplo real: Lista de productos

// app/productos/page.tsx
interface Producto {
  id: number
  nombre: string
  precio: number
  imagen: string
}

export default async function ProductosPage() {
  // Obtener productos de tu API
  const response = await fetch('https://api.mitienda.com/productos')
  const productos: Producto[] = await response.json()
  
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">Nuestros Productos</h1>
      
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {productos.map(producto => (
          <div key={producto.id} className="border rounded-lg p-4">
            <img 
              src={producto.imagen} 
              alt={producto.nombre}
              className="w-full h-48 object-cover rounded"
            />
            <h2 className="text-xl font-semibold mt-4">{producto.nombre}</h2>
            <p className="text-2xl font-bold text-blue-600 mt-2">
              ${producto.precio}
            </p>
            <button className="w-full bg-blue-600 text-white py-2 rounded mt-4">
              Ver detalles
            </button>
          </div>
        ))}
      </div>
    </div>
  )
}

Qué sucede cuando un usuario visita esta página:

  1. El servidor ejecuta el fetch y obtiene los productos
  2. El servidor genera el HTML con todos los productos
  3. El usuario recibe la página completa ya renderizada
  4. Todo es instantáneo para el usuario

Múltiples fuentes de datos

Puedes obtener datos de múltiples lugares en paralelo:

export default async function DashboardPage() {
  // Ejecutar todos los fetch en paralelo (más rápido)
  const [productos, usuarios, ventas] = await Promise.all([
    fetch('https://api.mitienda.com/productos').then(r => r.json()),
    fetch('https://api.mitienda.com/usuarios').then(r => r.json()),
    fetch('https://api.mitienda.com/ventas').then(r => r.json()),
  ])
  
  return (
    <div>
      <h1>Dashboard</h1>
      <div>Total productos: {productos.length}</div>
      <div>Total usuarios: {usuarios.length}</div>
      <div>Total ventas: {ventas.total}</div>
    </div>
  )
}

Promise.all ejecuta todos los fetch al mismo tiempo en lugar de uno tras otro, haciendo tu página más rápida.

Acceso directo a base de datos

En Server Components puedes acceder directamente a tu base de datos:

// app/productos/page.tsx
import { db } from '@/lib/database'

export default async function ProductosPage() {
  // Consulta directa a la base de datos
  const productos = await db.producto.findMany({
    where: { disponible: true },
    orderBy: { precio: 'asc' }
  })
  
  return (
    <div>
      {productos.map(producto => (
        <div key={producto.id}>
          <h2>{producto.nombre}</h2>
          <p>${producto.precio}</p>
        </div>
      ))}
    </div>
  )
}

Ventaja: No necesitas crear una API route (ruta de API) intermedia. Vas directo del componente a la base de datos.

⚠️
Solo en Server Components

Nunca accedas a la base de datos desde Client Components. Expondrías tus credenciales de base de datos al navegador del usuario. Esto es un riesgo de seguridad enorme.

Obtener datos en Client Components

Para datos que necesitan actualizarse después de la carga inicial.

Con useState y useEffect

El patrón tradicional cuando necesitas obtener datos en el cliente:

'use client'

import { useState, useEffect } from 'react'

export default function ProductosDestacados() {
  const [productos, setProductos] = useState([])
  const [cargando, setCargando] = useState(true)
  const [error, setError] = useState(null)
  
  useEffect(() => {
    fetch('/api/productos/destacados')
      .then(response => {
        if (!response.ok) throw new Error('Error al cargar')
        return response.json()
      })
      .then(data => {
        setProductos(data)
        setCargando(false)
      })
      .catch(err => {
        setError(err.message)
        setCargando(false)
      })
  }, []) // [] significa: ejecutar solo una vez al montar
  
  if (cargando) {
    return <div>Cargando productos destacados...</div>
  }
  
  if (error) {
    return <div>Error: {error}</div>
  }
  
  return (
    <div>
      <h2>Productos Destacados</h2>
      {productos.map(producto => (
        <div key={producto.id}>{producto.nombre}</div>
      ))}
    </div>
  )
}

Explicación del flujo:

  1. Componente se monta → cargando es true
  2. useEffect se ejecuta y hace el fetch
  3. Mientras espera, muestra "Cargando..."
  4. Cuando llegan los datos, actualiza el estado
  5. Componente se re-renderiza con los productos

Con librerías modernas (SWR)

SWR es una librería de Vercel que simplifica la obtención de datos en el cliente:

npm install swr
'use client'

import useSWR from 'swr'

// Función fetcher reutilizable
const fetcher = (url: string) => fetch(url).then(r => r.json())

export default function ProductosDestacados() {
  const { data, error, isLoading } = useSWR('/api/productos/destacados', fetcher)
  
  if (isLoading) return <div>Cargando...</div>
  if (error) return <div>Error al cargar</div>
  
  return (
    <div>
      <h2>Productos Destacados</h2>
      {data.map(producto => (
        <div key={producto.id}>{producto.nombre}</div>
      ))}
    </div>
  )
}

Ventajas de SWR:

  • Menos código (no necesitas useState, useEffect)
  • Caché automático (si vuelves a la página, muestra datos instantáneos)
  • Revalidación en background (actualiza datos automáticamente)
  • Reintentos automáticos si falla
  • Mejor experiencia de usuario

Patrones comunes

Patrón 1: Página con datos del servidor

El más común - toda la página obtiene datos del servidor:

// app/blog/page.tsx
export default async function BlogPage() {
  const posts = await fetch('https://api.miblog.com/posts').then(r => r.json())
  
  return (
    <div>
      <h1>Blog</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.titulo}</h2>
          <p>{post.extracto}</p>
          <a href={`/blog/${post.slug}`}>Leer más</a>
        </article>
      ))}
    </div>
  )
}

Patrón 2: Servidor + Cliente combinados

Carga inicial del servidor, interactividad del cliente:

// app/productos/page.tsx (Server Component)
import BuscadorProductos from '@/components/BuscadorProductos'

export default async function ProductosPage() {
  // Datos iniciales del servidor
  const productosIniciales = await fetch('https://api.mitienda.com/productos')
    .then(r => r.json())
  
  return (
    <div>
      {/* Buscador interactivo del cliente */}
      <BuscadorProductos />
      
      {/* Lista inicial del servidor */}
      <div className="grid grid-cols-3 gap-4">
        {productosIniciales.map(producto => (
          <ProductoCard key={producto.id} producto={producto} />
        ))}
      </div>
    </div>
  )
}
// components/BuscadorProductos.tsx (Client Component)
'use client'

import { useState } from 'react'
import useSWR from 'swr'

export default function BuscadorProductos() {
  const [query, setQuery] = useState('')
  
  // Solo busca si hay query
  const { data } = useSWR(
    query.length > 0 ? `/api/buscar?q=${query}` : null,
    fetcher
  )
  
  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Buscar productos..."
      />
      
      {data && (
        <div className="resultados">
          {data.map(producto => (
            <div key={producto.id}>{producto.nombre}</div>
          ))}
        </div>
      )}
    </div>
  )
}

Patrón 3: Streaming con Suspense

Mostrar contenido parcial mientras otras partes cargan:

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

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* Estos componentes cargan en paralelo */}
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<div>Cargando ventas...</div>}>
          <VentasCard />
        </Suspense>
        
        <Suspense fallback={<div>Cargando usuarios...</div>}>
          <UsuariosCard />
        </Suspense>
        
        <Suspense fallback={<div>Cargando productos...</div>}>
          <ProductosCard />
        </Suspense>
        
        <Suspense fallback={<div>Cargando pedidos...</div>}>
          <PedidosCard />
        </Suspense>
      </div>
    </div>
  )
}

// Cada componente obtiene sus propios datos
async function VentasCard() {
  const ventas = await fetch('https://api.mitienda.com/ventas').then(r => r.json())
  return <div>Total ventas: ${ventas.total}</div>
}

async function UsuariosCard() {
  const usuarios = await fetch('https://api.mitienda.com/usuarios').then(r => r.json())
  return <div>Total usuarios: {usuarios.count}</div>
}

// etc...

Qué sucede:

  1. La página se carga inmediatamente con los placeholders (fallbacks)
  2. Cada componente hace su propio fetch independientemente
  3. A medida que cada uno termina, reemplaza su fallback
  4. El usuario ve contenido progresivamente, no una pantalla en blanco

Manejo de errores

Siempre debes manejar posibles errores al obtener datos.

En Server Components

export default async function ProductosPage() {
  try {
    const response = await fetch('https://api.mitienda.com/productos')
    
    if (!response.ok) {
      throw new Error('Error al obtener productos')
    }
    
    const productos = await response.json()
    
    return (
      <div>
        {productos.map(producto => (
          <div key={producto.id}>{producto.nombre}</div>
        ))}
      </div>
    )
  } catch (error) {
    return (
      <div>
        <h2>Error al cargar productos</h2>
        <p>No pudimos cargar los productos. Intenta de nuevo más tarde.</p>
      </div>
    )
  }
}

Con error.tsx

NextJS tiene un archivo especial para manejar errores:

// app/productos/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div className="p-8 text-center">
      <h2 className="text-2xl font-bold mb-4">Algo salió mal</h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-600 text-white rounded"
      >
        Intentar de nuevo
      </button>
    </div>
  )
}

Si hay un error en productos/page.tsx, se muestra automáticamente productos/error.tsx.

En Client Components

'use client'

import { useState, useEffect } from 'react'

export default function ProductosDestacados() {
  const [productos, setProductos] = useState([])
  const [error, setError] = useState<string | null>(null)
  const [cargando, setCargando] = useState(true)
  
  useEffect(() => {
    fetch('/api/productos/destacados')
      .then(response => {
        if (!response.ok) {
          throw new Error(`Error: ${response.status}`)
        }
        return response.json()
      })
      .then(data => {
        setProductos(data)
        setCargando(false)
      })
      .catch(err => {
        setError(err.message)
        setCargando(false)
      })
  }, [])
  
  if (cargando) return <div>Cargando...</div>
  
  if (error) {
    return (
      <div className="bg-red-50 border border-red-200 rounded p-4">
        <p className="text-red-800">Error: {error}</p>
        <button 
          onClick={() => window.location.reload()}
          className="mt-2 text-red-600 underline"
        >
          Recargar página
        </button>
      </div>
    )
  }
  
  return <div>{/* Mostrar productos */}</div>
}

Estados de carga

Mostrar al usuario que algo está cargando.

En Server Components con loading.tsx

NextJS tiene un archivo especial para estados de carga:

// app/productos/loading.tsx
export default function Loading() {
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">Nuestros Productos</h1>
      
      {/* Skeleton de productos */}
      <div className="grid grid-cols-3 gap-6">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="border rounded-lg p-4 animate-pulse">
            <div className="h-48 bg-gray-200 rounded"></div>
            <div className="h-4 bg-gray-200 rounded mt-4"></div>
            <div className="h-4 bg-gray-200 rounded mt-2 w-2/3"></div>
          </div>
        ))}
      </div>
    </div>
  )
}

Mientras productos/page.tsx carga datos, NextJS muestra automáticamente productos/loading.tsx.

En Client Components

Ya vimos el patrón con useState:

const [cargando, setCargando] = useState(true)

if (cargando) {
  return <div>Cargando...</div>
}

Resumen

Data fetching en NextJS 15:

  1. Prefiere Server Components - Más rápido, más seguro, más simple
  2. Usa Client Components - Solo cuando necesites interactividad
  3. async/await - Para Server Components
  4. useState/useEffect o SWR - Para Client Components
  5. Maneja errores - Siempre valida respuestas
  6. Muestra estados de carga - El usuario debe saber qué está pasando

Tabla de decisión:

SituaciónUsa
Carga inicial de páginaServer Component con async/await
Lista de productosServer Component
Búsqueda en tiempo realClient Component con SWR
Dashboard con múltiples datosServer Component con Promise.all o Suspense
Datos después de un clickClient Component con fetch
Acceso a base de datosServer Component (nunca Client)
Datos que cambian frecuentementeClient Component con polling o WebSocket

En las siguientes secciones veremos en detalle:

  • Caché y revalidación
  • Server Actions (acciones del servidor)
  • Optimización de rendimiento
  • Patrones avanzados
Profundiza en las herramientas

Antes de continuar con temas avanzados de NextJS, asegúrate de dominar las bases:

  • Async/Await en JavaScript - Si async/await aún no te queda 100% claro, esta guía te lo explica desde cero con ejemplos prácticos
  • Fetch API en JavaScript - Todo lo que necesitas saber sobre fetch: GET, POST, headers, errores, y más
  • Axios en JavaScript - Alternativa a fetch con interceptors, mejor manejo de errores, y funcionalidades avanzadas

Dominar estas herramientas hará que el resto de la documentación de NextJS sea mucho más fácil de entender.