Loading UI

Los estados de carga mejoran la experiencia del usuario mostrando algo mientras se obtienen los datos. NextJS 15 hace esto simple con el archivo loading.tsx, que se muestra automáticamente mientras una página carga.

¿Qué es loading.tsx?

loading.tsx es un archivo especial que se muestra mientras el contenido de page.tsx se está cargando (obteniendo datos, procesando, etc.).

app/
└── productos/
    ├── page.tsx         ← Contenido final
    └── loading.tsx      ← Se muestra mientras page.tsx carga

Flujo de carga:

  1. Usuario navega a /productos
  2. NextJS muestra loading.tsx inmediatamente
  3. En paralelo, obtiene datos y renderiza page.tsx
  4. Cuando termina, reemplaza loading.tsx con page.tsx
ℹ️
Basado en React Suspense

Internamente, NextJS usa React Suspense para manejar esto. loading.tsx es automáticamente envuelto en un <Suspense> boundary.

Sintaxis básica

Loading simple

// app/productos/loading.tsx
export default function Loading() {
  return (
    <div className="flex items-center justify-center h-screen">
      <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
    </div>
  )
}

Eso es todo. Cuando alguien visite /productos, verá este spinner mientras page.tsx carga.

Loading con mensaje

// app/productos/loading.tsx
export default function Loading() {
  return (
    <div className="flex flex-col items-center justify-center h-screen">
      <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
      <p className="mt-4 text-gray-600">Cargando productos...</p>
    </div>
  )
}

Alcance de loading.tsx

Un archivo loading.tsx afecta a su ruta y todas las rutas anidadas debajo de él:

app/
└── productos/
    ├── loading.tsx              ← Aplica a /productos y rutas anidadas
    ├── page.tsx                 ✓ Usa este loading
    └── [id]/
        └── page.tsx             ✓ También usa este loading

Qué cubre:

  • /productos → muestra productos/loading.tsx
  • /productos/123 → muestra productos/loading.tsx

Loading específico para rutas anidadas

Puedes tener diferentes loadings en diferentes niveles:

app/
└── productos/
    ├── loading.tsx              ← Loading para /productos
    ├── page.tsx
    └── [id]/
        ├── loading.tsx          ← Loading para /productos/[id]
        └── page.tsx

Comportamiento:

  • /productos → usa productos/loading.tsx
  • /productos/123 → usa productos/[id]/loading.tsx (más específico)

El loading más cercano a la página siempre gana.

💡
Loading granular

Crea loadings específicos en rutas anidadas para experiencias más personalizadas. Un producto individual puede tener un skeleton diferente a la lista de productos.

Skeleton Screens

Los skeleton screens son mejores que spinners genéricos porque muestran la estructura de la página:

Skeleton de lista de productos

// app/productos/loading.tsx
export default function Loading() {
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6"></div>
      
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="border rounded-lg p-4">
            {/* Imagen */}
            <div className="h-48 bg-gray-200 rounded animate-pulse mb-4"></div>
            
            {/* Título */}
            <div className="h-4 bg-gray-200 rounded animate-pulse mb-2"></div>
            
            {/* Descripción */}
            <div className="h-3 bg-gray-200 rounded animate-pulse mb-1"></div>
            <div className="h-3 bg-gray-200 rounded animate-pulse w-2/3"></div>
            
            {/* Precio */}
            <div className="h-6 w-20 bg-gray-200 rounded animate-pulse mt-4"></div>
          </div>
        ))}
      </div>
    </div>
  )
}

Resultado: El usuario ve la estructura exacta de la página mientras carga.

Skeleton de producto individual

// app/productos/[id]/loading.tsx
export default function Loading() {
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        {/* Imagen del producto */}
        <div className="aspect-square bg-gray-200 rounded-lg animate-pulse"></div>
        
        {/* Info del producto */}
        <div>
          {/* Título */}
          <div className="h-8 bg-gray-200 rounded animate-pulse mb-4"></div>
          
          {/* Precio */}
          <div className="h-10 w-32 bg-gray-200 rounded animate-pulse mb-6"></div>
          
          {/* Descripción */}
          <div className="space-y-2 mb-6">
            <div className="h-4 bg-gray-200 rounded animate-pulse"></div>
            <div className="h-4 bg-gray-200 rounded animate-pulse"></div>
            <div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div>
          </div>
          
          {/* Botones */}
          <div className="h-12 bg-gray-200 rounded animate-pulse"></div>
        </div>
      </div>
    </div>
  )
}

Componente reutilizable de Skeleton

// components/Skeleton.tsx
interface SkeletonProps {
  className?: string
}

export function Skeleton({ className = '' }: SkeletonProps) {
  return (
    <div className={`bg-gray-200 rounded animate-pulse ${className}`}></div>
  )
}

// Uso
// app/productos/loading.tsx
import { Skeleton } from '@/components/Skeleton'

export default function Loading() {
  return (
    <div className="container mx-auto px-4 py-8">
      <Skeleton className="h-8 w-48 mb-6" />
      
      <div className="grid grid-cols-3 gap-6">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="border rounded-lg p-4">
            <Skeleton className="h-48 mb-4" />
            <Skeleton className="h-4 mb-2" />
            <Skeleton className="h-3 mb-1" />
            <Skeleton className="h-3 w-2/3" />
            <Skeleton className="h-6 w-20 mt-4" />
          </div>
        ))}
      </div>
    </div>
  )
}
💡
Skeleton vs Spinner

Skeleton screens son mejores porque:

  • Muestran la estructura de la página
  • Reducen la percepción de tiempo de carga
  • Se sienten más "premium"
  • Dan contexto sobre qué está cargando

Usa spinners solo para acciones rápidas (enviar formulario, etc.).

Streaming con Suspense

En lugar de esperar a que TODO cargue, puedes hacer streaming de partes de la página:

Streaming manual con Suspense

// app/productos/[id]/page.tsx
import { Suspense } from 'react'

// Componente que carga datos
async function ProductoInfo({ id }: { id: string }) {
  const producto = await obtenerProducto(id) // Operación lenta
  
  return (
    <div>
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
    </div>
  )
}

// Componente que carga datos
async function Resenas({ id }: { id: string }) {
  const resenas = await obtenerResenas(id) // Operación MUY lenta
  
  return (
    <div>
      <h2>Reseñas</h2>
      {resenas.map(resena => (
        <div key={resena.id}>{resena.texto}</div>
      ))}
    </div>
  )
}

// Página principal
export default function ProductoPage({
  params,
}: {
  params: { id: string }
}) {
  return (
    <div>
      {/* ProductoInfo se muestra cuando está listo */}
      <Suspense fallback={<div>Cargando producto...</div>}>
        <ProductoInfo id={params.id} />
      </Suspense>

      {/* Reseñas se muestran cuando están listas (independiente) */}
      <Suspense fallback={<div>Cargando reseñas...</div>}>
        <Resenas id={params.id} />
      </Suspense>
    </div>
  )
}

Qué sucede:

  1. La página se muestra inmediatamente con los fallbacks
  2. Cuando ProductoInfo termina de cargar → se reemplaza su fallback
  3. Cuando Resenas termina → se reemplaza su fallback
  4. Todo es independiente, no se bloquean entre sí

Streaming con skeletons específicos

// app/productos/[id]/page.tsx
import { Suspense } from 'react'

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

function ResenasSkeleton() {
  return (
    <div className="space-y-4">
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="animate-pulse">
          <div className="h-4 bg-gray-200 rounded mb-2"></div>
          <div className="h-3 bg-gray-200 rounded"></div>
        </div>
      ))}
    </div>
  )
}

export default function ProductoPage({ params }: { params: { id: string } }) {
  return (
    <div className="container mx-auto px-4 py-8">
      <Suspense fallback={<ProductoSkeleton />}>
        <ProductoInfo id={params.id} />
      </Suspense>

      <div className="mt-12">
        <Suspense fallback={<ResenasSkeleton />}>
          <Resenas id={params.id} />
        </Suspense>
      </div>
    </div>
  )
}
ℹ️
¿Cuándo usar Suspense manual?

Usa Suspense manual cuando:

  • Tienes partes de la página que cargan a diferentes velocidades
  • Quieres mostrar contenido importante primero
  • Algunas secciones son opcionales (reseñas, productos relacionados)

Usa loading.tsx cuando toda la página debe cargar junta.

Layouts y Loading

Los layouts NO son afectados por loading.tsx. El loading solo afecta el contenido de la página:

app/
└── productos/
    ├── layout.tsx       ← Siempre visible (header, sidebar)
    ├── loading.tsx      ← Solo afecta el contenido
    └── page.tsx         ← Contenido que se reemplaza

Flujo visual:

┌─────────────────────────────────┐
│  Layout (header, sidebar)       │  ← Siempre visible
│  ┌───────────────────────────┐  │
│  │                           │  │
│  │   Loading.tsx muestra     │  │  ← Se muestra mientras carga
│  │   aquí                    │  │
│  │                           │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘

Después:

┌─────────────────────────────────┐
│  Layout (header, sidebar)       │  ← Sigue visible
│  ┌───────────────────────────┐  │
│  │                           │  │
│  │   Page.tsx se muestra     │  │  ← Reemplaza a loading.tsx
│  │   aquí                    │  │
│  │                           │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘

Ejemplo: Dashboard con sidebar

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="flex">
      {/* Sidebar siempre visible */}
      <aside className="w-64 bg-gray-100 p-4">
        <nav>
          <a href="/dashboard">Inicio</a>
          <a href="/dashboard/ventas">Ventas</a>
          <a href="/dashboard/productos">Productos</a>
        </nav>
      </aside>

      {/* Contenido principal (aquí se muestra loading.tsx) */}
      <main className="flex-1 p-8">
        {children}
      </main>
    </div>
  )
}

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded mb-6 w-48"></div>
      <div className="h-64 bg-gray-200 rounded"></div>
    </div>
  )
}

Cuando navegues entre páginas del dashboard, el sidebar se mantiene y solo el contenido muestra el loading.

Patrones comunes de Loading

1. Lista de items (productos, posts)

// app/productos/loading.tsx
export default function Loading() {
  return (
    <div className="container mx-auto px-4 py-8">
      {/* Header */}
      <div className="flex justify-between items-center mb-8">
        <div className="h-8 w-48 bg-gray-200 rounded animate-pulse"></div>
        <div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
      </div>

      {/* Grid de productos */}
      <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
        {Array.from({ length: 8 }).map((_, i) => (
          <div key={i} className="border rounded-lg overflow-hidden">
            <div className="h-48 bg-gray-200 animate-pulse"></div>
            <div className="p-4 space-y-2">
              <div className="h-4 bg-gray-200 rounded animate-pulse"></div>
              <div className="h-4 bg-gray-200 rounded animate-pulse w-2/3"></div>
              <div className="h-6 bg-gray-200 rounded animate-pulse w-24 mt-2"></div>
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

2. Detalle de producto

// app/productos/[id]/loading.tsx
export default function Loading() {
  return (
    <div className="container mx-auto px-4 py-8">
      {/* Breadcrumbs */}
      <div className="flex space-x-2 mb-6">
        <div className="h-4 w-16 bg-gray-200 rounded animate-pulse"></div>
        <div className="h-4 w-4 bg-gray-200 rounded animate-pulse"></div>
        <div className="h-4 w-24 bg-gray-200 rounded animate-pulse"></div>
      </div>

      <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
        {/* Imagen */}
        <div className="aspect-square bg-gray-200 rounded-lg animate-pulse"></div>

        {/* Info */}
        <div>
          <div className="h-10 bg-gray-200 rounded animate-pulse mb-4"></div>
          <div className="h-8 w-32 bg-gray-200 rounded animate-pulse mb-6"></div>
          
          <div className="space-y-2 mb-6">
            <div className="h-4 bg-gray-200 rounded animate-pulse"></div>
            <div className="h-4 bg-gray-200 rounded animate-pulse"></div>
            <div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div>
          </div>

          <div className="h-12 bg-gray-200 rounded animate-pulse w-full"></div>
        </div>
      </div>
    </div>
  )
}

3. Tabla de datos

// app/dashboard/ventas/loading.tsx
export default function Loading() {
  return (
    <div>
      {/* Header */}
      <div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6"></div>

      {/* Filtros */}
      <div className="flex space-x-4 mb-6">
        <div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
        <div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
        <div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
      </div>

      {/* Tabla */}
      <div className="border rounded-lg overflow-hidden">
        {/* Header de tabla */}
        <div className="bg-gray-50 border-b p-4 flex space-x-4">
          <div className="h-4 w-24 bg-gray-200 rounded animate-pulse"></div>
          <div className="h-4 w-32 bg-gray-200 rounded animate-pulse"></div>
          <div className="h-4 w-20 bg-gray-200 rounded animate-pulse"></div>
          <div className="h-4 w-24 bg-gray-200 rounded animate-pulse"></div>
        </div>

        {/* Filas */}
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="border-b p-4 flex space-x-4">
            <div className="h-4 w-24 bg-gray-200 rounded animate-pulse"></div>
            <div className="h-4 w-32 bg-gray-200 rounded animate-pulse"></div>
            <div className="h-4 w-20 bg-gray-200 rounded animate-pulse"></div>
            <div className="h-4 w-24 bg-gray-200 rounded animate-pulse"></div>
          </div>
        ))}
      </div>
    </div>
  )
}

4. Blog post

// app/blog/[slug]/loading.tsx
export default function Loading() {
  return (
    <article className="container max-w-3xl mx-auto px-4 py-8">
      {/* Título */}
      <div className="h-12 bg-gray-200 rounded animate-pulse mb-4"></div>
      <div className="h-12 bg-gray-200 rounded animate-pulse w-2/3 mb-8"></div>

      {/* Metadata */}
      <div className="flex items-center space-x-4 mb-8">
        <div className="h-10 w-10 bg-gray-200 rounded-full animate-pulse"></div>
        <div>
          <div className="h-4 w-32 bg-gray-200 rounded animate-pulse mb-2"></div>
          <div className="h-3 w-24 bg-gray-200 rounded animate-pulse"></div>
        </div>
      </div>

      {/* Imagen destacada */}
      <div className="h-96 bg-gray-200 rounded-lg animate-pulse mb-8"></div>

      {/* Contenido */}
      <div className="space-y-4">
        {Array.from({ length: 8 }).map((_, i) => (
          <div key={i} className="h-4 bg-gray-200 rounded animate-pulse"></div>
        ))}
        <div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div>
      </div>
    </article>
  )
}

Loading con Route Groups

Los Route Groups afectan dónde se aplica el loading:

app/
├── (marketing)/
│   ├── layout.tsx
│   ├── loading.tsx          ← Solo para rutas de marketing
│   ├── sobre-nosotros/
│   │   └── page.tsx
│   └── contacto/
│       └── page.tsx
│
└── (tienda)/
    ├── layout.tsx
    ├── loading.tsx          ← Solo para rutas de tienda
    ├── productos/
    │   └── page.tsx
    └── carrito/
        └── page.tsx

Cada Route Group puede tener su propio loading personalizado.

Errores comunes y soluciones

1. Loading no se muestra

// ❌ Incorrecto: No es async, no hay tiempo de carga
export default function Page() {
  const data = staticData // Datos inmediatos
  return <div>{data}</div>
}

// ✓ Correcto: Operación async, loading se muestra
export default async function Page() {
  const data = await fetchData() // Operación que toma tiempo
  return <div>{data}</div>
}
⚠️

loading.tsx solo se muestra cuando hay operaciones asíncronas (fetching de datos, etc.). Si tu página es estática o muy rápida, puede que no veas el loading.

2. Layout se oculta con loading

// ❌ Incorrecto: Loading muy grande oculta todo
export default function Loading() {
  return (
    <div className="fixed inset-0 bg-white"> {/* Cubre todo */}
      <div>Loading...</div>
    </div>
  )
}

// ✓ Correcto: Loading respeta el layout
export default function Loading() {
  return (
    <div className="p-8"> {/* Solo el contenido */}
      <div>Loading...</div>
    </div>
  )
}

3. No coincidir con el diseño final

// ❌ Incorrecto: Skeleton muy diferente al diseño real
export default function Loading() {
  return <div>Loading...</div> // Muy simple
}

// ✓ Correcto: Skeleton refleja la estructura real
export default function Loading() {
  return (
    <div className="grid grid-cols-3 gap-6"> {/* Igual que la página real */}
      {Array.from({ length: 6 }).map((_, i) => (
        <SkeletonCard key={i} />
      ))}
    </div>
  )
}

4. Animaciones bruscas

// ❌ Sin transición suave
<div className="bg-gray-200"></div>

// ✓ Con animación pulse
<div className="bg-gray-200 animate-pulse"></div>

5. Loading muy detallado

// ❌ Demasiado detalle en el skeleton
<div>
  <div className="h-4 w-24 bg-gray-200"></div>
  <div className="h-3 w-32 bg-gray-200"></div>
  <div className="h-3 w-28 bg-gray-200"></div>
  <div className="h-2 w-16 bg-gray-200"></div>
  {/* 50 líneas más... */}
</div>

// ✓ Balance entre detalle y simplicidad
<div>
  <div className="h-4 w-24 bg-gray-200 mb-2"></div>
  <div className="space-y-2">
    {Array.from({ length: 3 }).map((_, i) => (
      <div key={i} className="h-3 bg-gray-200"></div>
    ))}
  </div>
</div>

Mejores prácticas

1. Skeleton que refleja la UI final

Tu skeleton debe tener la misma estructura que el contenido final:

// Página final
<div className="grid grid-cols-3 gap-6">
  {productos.map(p => <ProductCard producto={p} />)}
</div>

// Loading debe usar la misma estructura
<div className="grid grid-cols-3 gap-6">
  {Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
</div>

2. Usa Suspense para partes independientes

// ✓ Bueno: Partes independientes
<div>
  <Suspense fallback={<HeaderSkeleton />}>
    <Header />
  </Suspense>

  <Suspense fallback={<ProductsSkeleton />}>
    <Products />
  </Suspense>

  <Suspense fallback={<ReviewsSkeleton />}>
    <Reviews />
  </Suspense>
</div>

3. Loading específico por ruta

app/
└── productos/
    ├── loading.tsx              ← Lista de productos
    └── [id]/
        └── loading.tsx          ← Detalle de producto (diferente)

4. Reutiliza componentes de skeleton

// components/skeletons/ProductCardSkeleton.tsx
export function ProductCardSkeleton() {
  return (
    <div className="border rounded-lg p-4">
      <div className="h-48 bg-gray-200 rounded animate-pulse mb-4"></div>
      <div className="h-4 bg-gray-200 rounded animate-pulse mb-2"></div>
      <div className="h-3 bg-gray-200 rounded animate-pulse w-2/3"></div>
    </div>
  )
}

// Usar en loading.tsx
import { ProductCardSkeleton } from '@/components/skeletons/ProductCardSkeleton'

export default function Loading() {
  return (
    <div className="grid grid-cols-3 gap-6">
      {Array.from({ length: 6 }).map((_, i) => (
        <ProductCardSkeleton key={i} />
      ))}
    </div>
  )
}

5. Animación consistente

// Define una clase global para consistencia
// globals.css
.skeleton {
  @apply bg-gray-200 animate-pulse rounded;
}

// Uso
<div className="skeleton h-4 w-32"></div>

6. Cantidad correcta de skeletons

// ✓ Muestra cantidad realista según viewport
export default function Loading() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
      {/* Muestra ~8-12 items para llenar la pantalla */}
      {Array.from({ length: 12 }).map((_, i) => (
        <SkeletonCard key={i} />
      ))}
    </div>
  )
}

Testing de Loading States

Puedes probar loading states agregando delays artificiales:

// app/productos/page.tsx
export default async function ProductosPage() {
  // Solo para desarrollo: simular carga lenta
  if (process.env.NODE_ENV === 'development') {
    await new Promise(resolve => setTimeout(resolve, 2000))
  }

  const productos = await obtenerProductos()
  
  return <ProductosGrid productos={productos} />
}

O usa las DevTools de React:

// Envolver en Suspense con delay
<Suspense fallback={<Loading />}>
  {/* En DevTools puedes "pausar" para ver el loading */}
  <ProductosContent />
</Suspense>

Ejemplo completo: E-commerce

app/
└── productos/
    ├── layout.tsx
    ├── loading.tsx                   ← Lista
    ├── page.tsx
    └── [slug]/
        ├── loading.tsx               ← Detalle
        └── page.tsx

Lista de productos:

// app/productos/loading.tsx
import { ProductCardSkeleton } from '@/components/skeletons'

export default function Loading() {
  return (
    <div className="container mx-auto px-4 py-8">
      {/* Header con filtros */}
      <div className="flex justify-between items-center mb-8">
        <div className="h-8 w-48 bg-gray-200 rounded animate-pulse"></div>
        <div className="flex space-x-4">
          <div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
          <div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
        </div>
      </div>

      {/* Grid de productos */}
      <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
        {Array.from({ length: 12 }).map((_, i) => (
          <ProductCardSkeleton key={i} />
        ))}
      </div>
    </div>
  )
}

Detalle de producto:

// app/productos/[slug]/loading.tsx
export default function Loading() {
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
        {/* Galería */}
        <div className="space-y-4">
          <div className="aspect-square bg-gray-200 rounded-lg animate-pulse"></div>
          <div className="grid grid-cols-4 gap-2">
            {Array.from({ length: 4 }).map((_, i) => (
              <div key={i} className="aspect-square bg-gray-200 rounded animate-pulse"></div>
            ))}
          </div>
        </div>

        {/* Info */}
        <div>
          <div className="h-10 bg-gray-200 rounded animate-pulse mb-4"></div>
          <div className="h-8 w-32 bg-gray-200 rounded animate-pulse mb-6"></div>
          
          <div className="space-y-2 mb-8">
            <div className="h-4 bg-gray-200 rounded animate-pulse"></div>
            <div className="h-4 bg-gray-200 rounded animate-pulse"></div>
            <div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div>
          </div>

          <div className="space-y-4 mb-8">
            <div className="h-12 bg-gray-200 rounded animate-pulse"></div>
            <div className="h-12 bg-gray-200 rounded animate-pulse"></div>
          </div>

          <div className="h-14 bg-gray-200 rounded-lg animate-pulse"></div>
        </div>
      </div>

      {/* Tabs */}
      <div className="mt-12">
        <div className="flex space-x-4 border-b mb-6">
          <div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
          <div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
        </div>
        <div className="space-y-2">
          {Array.from({ length: 4 }).map((_, i) => (
            <div key={i} className="h-4 bg-gray-200 rounded animate-pulse"></div>
          ))}
        </div>
      </div>
    </div>
  )
}

Con streaming para reseñas:

// app/productos/[slug]/page.tsx
import { Suspense } from 'react'

async function ProductoInfo({ slug }: { slug: string }) {
  const producto = await obtenerProducto(slug)
  return <ProductoDetalle producto={producto} />
}

async function Resenas({ slug }: { slug: string }) {
  const resenas = await obtenerResenas(slug) // Operación lenta
  return <ResenasLista resenas={resenas} />
}

function ResenasSkeleton() {
  return (
    <div className="space-y-4">
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="border rounded p-4 animate-pulse">
          <div className="flex items-center space-x-2 mb-2">
            <div className="h-8 w-8 bg-gray-200 rounded-full"></div>
            <div className="h-4 w-32 bg-gray-200 rounded"></div>
          </div>
          <div className="space-y-2">
            <div className="h-3 bg-gray-200 rounded"></div>
            <div className="h-3 bg-gray-200 rounded w-3/4"></div>
          </div>
        </div>
      ))}
    </div>
  )
}

export default function ProductoPage({ params }: { params: { slug: string } }) {
  return (
    <div className="container mx-auto px-4 py-8">
      {/* Info del producto carga primero */}
      <Suspense fallback={<div>Cargando producto...</div>}>
        <ProductoInfo slug={params.slug} />
      </Suspense>

      {/* Reseñas cargan independientemente */}
      <div className="mt-12">
        <h2 className="text-2xl font-bold mb-6">Reseñas</h2>
        <Suspense fallback={<ResenasSkeleton />}>
          <Resenas slug={params.slug} />
        </Suspense>
      </div>
    </div>
  )
}

Resumen

Puntos clave sobre Loading UI:

  1. loading.tsx se muestra automáticamente mientras page.tsx carga
  2. Funciona con React Suspense bajo el capó
  3. Afecta su ruta y todas las rutas anidadas
  4. El layout NO es afectado, solo el contenido
  5. Skeleton screens son mejores que spinners genéricos
  6. Usa <Suspense> manual para streaming granular
  7. El skeleton debe reflejar la estructura de la UI final
  8. Reutiliza componentes de skeleton para consistencia
  9. Usa animate-pulse de Tailwind para animaciones suaves
  10. Puedes tener diferentes loadings en diferentes niveles de ruta

Decisiones de diseño:

CasoUsar
Toda la página carga juntaloading.tsx
Partes cargan a diferente velocidad<Suspense> manual
Acción rápida (< 1s)Spinner
Página completaSkeleton screen
Lista de itemsGrid de skeleton cards
Contenido importante primeroStreaming con Suspense