Gestión de estados de carga en Next.js: guía práctica de loading.tsx y Suspense

Seguro que te ha pasado: el usuario hace clic en un enlace y la página se queda en blanco durante 3 segundos enteros, sin ningún feedback. Empieza a pensar: «¿Se ha colgado?» y pulsa F5 como loco; justo cuando la página termina de cargar, la recarga y vuelve a empezar…
Antes yo también gestionaba el loading así. En cada página nueva tenía que añadir en el componente:
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
setLoading(true);
fetchData()
.then(setData)
.finally(() => setLoading(false));
}, []);
if (loading) return <Spinner />;
Código largo y feo, y repetido en cada página. Peor aún: en el equipo cada uno implementaba el loading a su manera — unos con estado global, otros con Context — y mantenerlo era una pesadilla.
Hasta que, leyendo la documentación oficial de Next.js, descubrí que Next.js ya trae un sistema más elegante para gestionar la carga: loading.tsx y Suspense.
Al usarlo me di cuenta de que gestionar el loading puede ser así de simple. La mitad de código y una experiencia de usuario mucho mejor. En este artículo comparto la experiencia práctica con este enfoque.
Por qué usar loading.tsx y Suspense
Los problemas del enfoque tradicional
Te muestro un ejemplo real. Supongamos una página de listado de blog; el enfoque clásico sería algo así:
// app/blog/page.tsx
'use client';
import { useState, useEffect } from 'react';
export default function BlogPage() {
const [loading, setLoading] = useState(true);
const [posts, setPosts] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/posts')
.then(res => res.json())
.then(data => {
setPosts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) {
return <div className="spinner">Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
Parece razonable, ¿no? Pero los problemas son:
- Código redundante: en cada página repites este bloque de gestión de estado
- Estado fragmentado: loading, data y error en tres states distintos; fácil desincronizarlos
- Tiene que ser Client Component: con useState y useEffect todo corre en el cliente y pierdes ventajas del SSR
- Mala UX: entre el clic y ver el loading hay una pantalla en blanco notable
Si has hecho code review, sabes que cada desarrollador lo hace distinto: Context, Zustand global o lógica local en cada componente. En proyectos grandes es imposible de mantener.
La solución de Next.js
El App Router de Next.js ofrece tres piezas clave:
1. loading.tsx — convención sobre configuración
Creas un archivo loading.tsx en la carpeta de la ruta y Next.js lo usa como UI de carga de esa ruta. Sin useState manual, sin gestionar estado, sin envolver Suspense tú mismo.
2. Suspense — soporte nativo de React 18
Suspense te permite controlar el loading a nivel de componente. Lo que tarde en cargar lo envuelves en un límite Suspense; el resto se muestra sin esperar a toda la página.
3. Streaming — mostrar mientras carga
Con el renderizado en streaming de Next.js, la página aparece por partes: primero la cabecera, luego la barra lateral y al final los datos lentos. Menos pantalla en blanco y mejor experiencia.
Con skeleton screens y streaming suele bajar el FCP (First Contentful Paint) y el LCP (Largest Contentful Paint); en PageSpeed Insights puedes ganar varios puntos.
Uso básico de loading.tsx
Primeros pasos: tu primer loading.tsx
Vamos directo al grano con el loading.tsx más simple.
Supón esta estructura:
app/
blog/
page.tsx
Solo añade loading.tsx en blog:
app/
blog/
loading.tsx ← nuevo
page.tsx
Contenido mínimo:
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
<p className="ml-4">Cargando...</p>
</div>
);
}
Así de simple, unas 10 líneas. Al visitar /blog, antes de que termine page.tsx, Next.js muestra este componente.
Importante: no hace falta envolver Suspense a mano; Next.js envuelve tu page.tsx en <Suspense fallback={<Loading />}>.
La primera vez pensé: «¿Tan fácil? ¿De verdad funciona?» Lo probé y sí. El código queda mucho más limpio, sin useState en cada página.
Alcance de loading.tsx
Concepto clave: el segmento de ruta (Route Segment). loading.tsx afecta al page.tsx de la misma carpeta y a todas las subrutas.
Ejemplo:
app/
blog/
loading.tsx ← afecta a /blog y /blog/[id]
page.tsx ← listado /blog
[id]/
page.tsx ← detalle /blog/123
Se muestra cuando:
- el usuario entra en
/blog(carga del listado) - pasa del listado a
/blog/123(carga del detalle)
No afecta al layout. Si blog/layout.tsx tiene una barra de navegación, sigue visible; solo se sustituye la parte de page.tsx por el loading.
Eso es lo que la documentación llama «el layout compartido sigue siendo interactivo»: mientras carga la nueva página, el usuario puede usar la navegación sin bloquear toda la interfaz.
Esquema:
Layout (siempre visible)
├─ Barra de navegación
└─ Límite Suspense
├─ Loading UI (mientras cargan datos)
└─ Page (cuando terminan)
Server Component vs Client Component
loading.tsx por defecto es un Server Component. En la mayoría de casos basta con devolver JSX.
Si quieres animaciones (Framer Motion, librerías que necesitan JS en cliente), añade 'use client':
// app/blog/loading.tsx
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center min-h-screen"
>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
</motion.div>
);
}
Mi regla: Server Component siempre que puedas; 'use client' solo si hace falta interacción en cliente. Menos bundle en el cliente y carga más rápida.
Skeleton screens en la práctica
Por qué un skeleton supera al spinner
Todos hemos visto spinners girando. En UX, un skeleton screen suele funcionar mejor que un spinner.
La psicología: con skeleton el cerebro anticipa «el contenido llega ya» y la espera se percibe más corta. Con spinner solo sabes «está cargando», sin saber qué ni cuánto — más ansiedad.
Además el skeleton anticipa el layout: tres barras sugieren tres artículos; hay expectativa y menos nervios.
Tres formas de implementarlo
Opción 1: solo CSS (más ligera)
Sin dependencias extra:
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8 animate-pulse">
{/* Skeleton del título */}
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
{/* Skeleton del resumen */}
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
{/* Skeleton de metadatos */}
<div className="flex gap-4 mt-4">
<div className="h-3 bg-gray-200 rounded w-20"></div>
<div className="h-3 bg-gray-200 rounded w-24"></div>
</div>
</div>
))}
</div>
);
}
Cero dependencias y buen rendimiento; tú escribes los estilos.
Opción 2: react-loading-skeleton (rápida)
npm install react-loading-skeleton
// app/blog/loading.tsx
'use client';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8">
<Skeleton height={32} width="75%" className="mb-4" />
<Skeleton count={2} />
<div className="flex gap-4 mt-4">
<Skeleton width={80} />
<Skeleton width={100} />
</div>
</div>
))}
</div>
);
}
Cómodo y con buenas animaciones; lo uso en proyectos pequeños.
Opción 3: shadcn/ui (más profesional)
Si ya usas shadcn/ui:
npx shadcn-ui@latest add skeleton
// app/blog/loading.tsx
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8">
<Skeleton className="h-8 w-3/4 mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-5/6 mb-4" />
<div className="flex gap-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
);
}
Los estilos encajan con tu design system.
Principios de diseño del skeleton
- Coincidir con el layout real: título, resumen y etiquetas en el listado → el skeleton debe reflejarlo.
- Animación sutil: un pulso suave basta; animaciones llamativas distraen y alargan la sensación de espera.
- Cantidad razonable: 3–5 ítems suele bastar; llenar la pantalla cansa.
Caso completo: listado de blog
loading.tsx:
// app/blog/loading.tsx
export default function BlogLoading() {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="h-12 bg-gray-200 rounded w-1/3 mb-8 animate-pulse"></div>
<div className="space-y-8">
{[1, 2, 3].map((i) => (
<article key={i} className="border-b pb-8 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-3"></div>
<div className="space-y-2 mb-4">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-11/12"></div>
<div className="h-4 bg-gray-200 rounded w-4/5"></div>
</div>
<div className="flex gap-3">
<div className="h-6 bg-gray-200 rounded-full w-16"></div>
<div className="h-6 bg-gray-200 rounded-full w-20"></div>
</div>
</article>
))}
</div>
</div>
);
}
page.tsx como Server Component:
// app/blog/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store' // asegura fetch fresco cada vez
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Artículos del blog</h1>
<div className="space-y-8">
{posts.map((post) => (
<article key={post.id} className="border-b pb-8">
<h2 className="text-2xl font-semibold mb-3">
<a href={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</a>
</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex gap-3">
{post.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
{tag}
</span>
))}
</div>
</article>
))}
</div>
</div>
);
}
page.tsx es async y hace await de los datos en el componente. Sin useState ni useEffect. Al ser Server Component, no aumenta el bundle del cliente y la primera carga es más rápida.
Depuración con React DevTools
A veces los datos cargan tan rápido que el loading apenas se ve.
Truco: React DevTools para suspender el límite Suspense manualmente.
- Instala la extensión React DevTools
- Abre las herramientas de desarrollo, pestaña Components
- Localiza
<Suspense> - Clic derecho → «Suspend this Suspense boundary»
El loading queda fijo y puedes ajustar estilos. Luego cancelas el suspend.
Me costó varios tropiezos descubrirlo; habría ahorrado mucho tiempo.
Suspense: técnicas avanzadas
Límites Suspense manuales
loading.tsx es cómodo, pero a veces quieres control fino: varias fuentes de datos con loading independiente, no esperar a que todo esté listo.
Ahí entran los límites Suspense manuales.
Error habitual (yo al principio): poner Suspense dentro del componente que obtiene datos:
// ❌ Incorrecto — Suspense demasiado abajo
async function PostList() {
const posts = await fetchPosts();
return (
<Suspense fallback={<Loading />}> {/* ¡no funciona así! */}
<div>
{posts.map(post => <Post key={post.id} {...post} />)}
</div>
</Suspense>
);
}
No surte efecto. Suspense debe estar más arriba en el árbol para «capturar» las operaciones asíncronas de los hijos.
Forma correcta en el padre:
// ✅ Correcto — Suspense en el padre
export default function BlogPage() {
return (
<div>
<h1>Artículos del blog</h1>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
);
}
// El hijo obtiene datos
async function PostList() {
const posts = await fetchPosts();
return (
<div>
{posts.map(post => <Post key={post.id} {...post} />)}
</div>
);
}
Imagina Suspense como una compuerta: vigila lo que hay debajo; si algún hijo espera datos, se cierra y muestra el fallback; cuando todo llega, abre y muestra el contenido.
Rutas dinámicas
Un caso que me costó: detalle de producto /products/[id], de producto A (id=1) a B (id=2). ¡loading.tsx no aparece!
El contenido salta de A a B sin transición; se siente brusco.
React reutiliza la misma instancia si el tipo de componente es el mismo (ProductPage) y solo cambian props. Suspense piensa «no hay componente nuevo, no suspendo de nuevo».
Solución: key en Suspense para forzar un montaje nuevo.
// app/products/[id]/page.tsx
import { Suspense } from 'react';
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<Suspense key={params.id} fallback={<ProductSkeleton />}>
<ProductDetail id={params.id} />
</Suspense>
);
}
async function ProductDetail({ id }: { id: string }) {
const product = await fetchProduct(id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
);
}
Fíjate en <Suspense key={params.id} ...>.
Al cambiar el id, React destruye el Suspense anterior y crea uno nuevo; vuelve a suspender y el loading se muestra bien.
Lo vi en un issue de GitHub; una línea de key y listo.
Varias cargas a la vez
Escenario: dashboard con usuario, estadísticas y actividad reciente, cada uno con su API.
Estrategia 1: mostrar todo junto (un Suspense para todo)
export default function Dashboard() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<UserInfo /> {/* API 1 */}
<Statistics /> {/* API 2 */}
<RecentActivity /> {/* API 3 */}
</Suspense>
);
}
Ventaja: simple, contenido completo de golpe. Inconveniente: la API más lenta frena todo; espera = tiempo de la más lenta.
Estrategia 2: mostrar por partes (varios límites Suspense)
export default function Dashboard() {
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<Statistics />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
Ventaja: lo rápido aparece primero; espera percibida menor. Inconveniente: la página «salta» al ir entrando bloques.
Yo elijo según importancia de los datos:
- Datos core (usuario) en un Suspense para mostrarlos juntos
- Secundarios (recomendaciones, anuncios) en Suspense aparte
Equilibrio entre experiencia y rendimiento.
Problemas frecuentes y soluciones
Suspense no funciona
Comprueba:
1. Forma de obtener datos
Suspense solo con métodos compatibles. En App Router:
- ✅ await directo en Server Component (recomendado)
- ✅ librerías con Suspense (SWR, React Query)
- ❌ fetch en useEffect
- ❌ Promise.then clásico
2. Posición del componente
Suspense encima del componente que obtiene datos, no en el mismo nivel ni debajo.
3. Versiones
- React 18+
- Next.js 13+ (App Router)
4. Depuración
Suspende manualmente con React DevTools; si no reacciona, Suspense no está activo — revisa lo anterior.
Trampa de useFormStatus
Con Server Actions para formularios, useFormStatus muestra el estado de envío.
useFormStatus solo funciona en Client Component.
El <form> debe renderizarse en Server Component para enlazar la Server Action.
Patrón: Server Component para el form, Client Component para el estado.
// app/actions.ts
'use server';
export async function submitForm(formData: FormData) {
// procesar formulario...
await saveToDatabase(formData);
}
// app/page.tsx (Server Component)
import { submitForm } from './actions';
import { SubmitButton } from './submit-button';
export default function Page() {
return (
<form action={submitForm}>
<input name="email" type="email" />
<SubmitButton />
</form>
);
}
// app/submit-button.tsx (Client Component)
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Enviando...' : 'Enviar'}
</button>
);
}
Form en servidor, botón en cliente: Server Action y estado de carga funcionan.
Prefetch y el loading
<Link> de Next.js hace prefetch por defecto cuando el enlace entra en el viewport.
A veces al hacer clic el loading apenas se ve porque la página ya está precargada.
Para probar el loading, desactiva prefetch temporalmente:
<Link href="/blog" prefetch={false}>
Blog
</Link>
En producción conviene dejar prefetch activo. Si el loading es demasiado breve, puedes imponer un mínimo (p. ej. 300 ms) o usar skeleton en lugar de spinner.
Resumen
Puntos clave:
-
loading.tsx es la mejor práctica a nivel de ruta: en la carpeta de la ruta; Next.js hace el resto. Menos useState manual y la mitad de código.
-
Skeleton mejor que spinner: anticipa el layout y reduce ansiedad. CSS puro, react-loading-skeleton o tu UI kit.
-
Suspense arriba en el árbol: es una compuerta; mal colocado no funciona.
-
keyen rutas dinámicas: sin él, al cambiar id no hay loading. No olvides<Suspense key={params.id}>. -
Varias fuentes: Suspense según necesidad: core junto, secundario en paralelo.
Pasar de useState manual a loading.tsx no es más trabajo: es trabajar más inteligente. Menos código, menos bugs y mejor UX.
Próximos pasos
Acción inmediata: en un proyecto existente, elige un listado simple y migra el loading a loading.tsx. Probarlo vale más que leer diez artículos.
Siguiente nivel: cuando domines loading, mira Error Boundaries — loading y errores van de la mano. Tengo previsto un artículo práctico sobre Error Boundaries.
Comparte: ¿cómo gestionas el loading en tus proyectos? ¿Qué enfoque usas? ¿Qué trampas has encontrado? Cuéntalo en los comentarios.
Referencias:
- Documentación Next.js - loading.js
- Documentación Next.js - Loading UI y Streaming
- Documentación React - Suspense
Flujo completo de gestión de carga en Next.js
Usa loading.tsx y Suspense para una experiencia de carga profesional sin useState manual
⏱️ Estimated time: 1 hr
- 1
Step 1: Crear el archivo loading.tsx
Crea loading.tsx en el directorio de la ruta:
• app/dashboard/loading.tsx: estado de carga de dashboard
• app/products/[id]/loading.tsx: estado de carga en ruta dinámica
Contenido del archivo:
export default function Loading() {
return <div>Cargando...</div>
}
Next.js mostrará este componente automáticamente al cargar la página - 2
Step 2: Implementar skeleton screen
Crea una UI de carga más profesional:
• Usa componentes Skeleton que imiten el layout del contenido
• Mantén un layout similar al contenido real
• Añade animación para mejorar la experiencia
Ejemplo:
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
)
} - 3
Step 3: Envolver componentes asíncronos con Suspense
Usa Suspense en los componentes:
• Envuelve componentes que obtienen datos de forma asíncrona
• Define fallback para el estado de carga
• Suspense anidado para granularidad fina
Ejemplo:
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
Varios componentes pueden tener su propio Suspense:
• Cada uno carga de forma independiente
• Lo rápido aparece primero
• Mejor experiencia de usuario - 4
Step 4: Gestionar loading en rutas dinámicas
Loading en rutas dinámicas:
• Crea loading.tsx en el directorio de la ruta dinámica
• Next.js gestiona la carga cuando cambian los parámetros
• Sin gestión manual del estado de carga
Ejemplo:
app/products/[id]/
├── loading.tsx # se muestra al cambiar parámetros
└── page.tsx
Al navegar de /products/1 a /products/2,
loading.tsx se muestra automáticamente - 5
Step 5: Optimizar la experiencia de carga
Consejos de optimización:
• Skeleton en lugar de un Spinner simple
• Mantén la UI de carga alineada con el layout real
• Animación (animate-pulse) para mejor sensación
• Suspense razonable para streaming
Evita:
• loading.tsx en todas partes sin criterio
• UI de carga demasiado compleja
• Ignorar errores (combina con error.tsx) - 6
Step 6: Probar y validar
Puntos de prueba:
• Estado de carga al navegar entre páginas
• Carga al cambiar parámetros en rutas dinámicas
• Experiencia con red lenta
• Comprobar que la UI de carga es fluida
Checklist:
• Cada ruta tiene un estado de carga adecuado
• La UI de carga coincide con el layout del contenido
• Sin parpadeos ni saltos de layout
• Experiencia fluida para el usuario
FAQ
¿Qué diferencia hay entre loading.tsx y useState manual?
¿Cuándo se muestra loading.tsx?
¿Qué diferencia hay entre Suspense y loading.tsx?
¿Cómo implementar un skeleton screen?
¿Cómo gestionar el loading en rutas dinámicas?
¿Puedo personalizar el estilo del loading?
¿loading.tsx afecta al rendimiento?
14 min de lectura · Publicado el: 5 ene 2026 · Actualizado el: 21 ago 2026
Guía completa de Next.js
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Configuración de ingeniería en Next.js: guía integral de ESLint + Prettier + Husky
¿PR rechazada por formato o conflictos de estilo en el equipo? Guía paso a paso para configurar ESLint, Prettier y Husky en Next.js con validaciones y formateo automático antes de cada commit.
Parte 31 de 51
Siguiente
Guía completa para personalizar páginas de error 404 y 500 en Next.js: de la implementación técnica al diseño optimizado
Te enseñamos paso a paso a personalizar las páginas de error de Next.js, con ejemplos completos de not-found.tsx, error.tsx y global-error.tsx, mejores prácticas de diseño y soluciones a problemas habituales para mejorar la UX y reducir la tasa de rebote
Parte 33 de 51



Comentarios
Inicia sesión con GitHub para dejar un comentario