En Next.js 16, la mayoría de componentes que escribes en el App Router son Server Components por defecto. Esto cambia por completo la forma de pensar en datos: en vez de renderizar vacío y rellenar después con useEffect, el componente se ejecuta en el servidor y ya llega al cliente con los datos dentro.
En Pages Router (o en cualquier Client Component), cargar datos significaba un ciclo de renderizado en tres pasos: render vacío, efecto que dispara el fetch, y un segundo render con los datos.
// ❌ Client Component: tres renders, loading manual
'use client';
import { useEffect, useState } from 'react';
function PostList() {
const [posts, setPosts] = useState(null);
useEffect(() => {
fetch('/api/posts')
.then((res) => res.json())
.then(setPosts);
}, []);
if (!posts) return <p>Cargando...</p>;
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
Funciona, pero implica loading states manuales, waterfalls si hay fetches anidados, y código de cliente que solo existe para pedir datos.
Un Server Component es una función async normal. Puedes hacer await directamente en el cuerpo del componente:
// ✅ Server Component: sin estado, sin efecto
async function PostList() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
No hay useState, no hay useEffect, no hay loading manual. El HTML que llega al navegador ya contiene los datos. Next.js además cachea y deduplica automáticamente los fetch que comparten URL entre distintos componentes del mismo árbol.
En vez de un if (!posts) dentro del componente, el estado de carga se declara fuera, con loading.tsx o <Suspense>:
// app/blog/loading.tsx
export default function Loading() {
return <p>Cargando posts...</p>;
}
Next.js muestra automáticamente ese fallback mientras el Server Component async resuelve, y lo sustituye por el contenido real en cuanto termina.
Server Components no eliminan useEffect, lo acotan a su caso real: sincronizar con algo fuera de React en el navegador (event listeners, localStorage, WebSockets, animaciones). Para eso necesitas un Client Component:
'use client';
import { useEffect, useState } from 'react';
function OnlineStatus() {
const [online, setOnline] = useState(true);
useEffect(() => {
const update = () => setOnline(navigator.onLine);
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
return <span>{online ? '🟢 online' : '🔴 offline'}</span>;
}
La regla práctica: si el dato viene de una API o una base de datos, va en un Server Component con await. Si depende de algo que solo existe en el navegador, va en un Client Component con useEffect.
| Client Component + useEffect | Server Component async | |
|---|---|---|
| Dónde corre | Navegador | Servidor |
| Renders para tener datos | 2-3 | 1 |
| Loading state | manual (useState) | loading.tsx / Suspense |
| JS enviado al cliente | fetch + lógica | ninguno |
Acceso a window, localStorage | ✅ | ❌ |
Pensar en datos "sin useEffect" no es una regla dogmática, es una consecuencia natural de dónde vive el componente. En Next.js 16, la pregunta ya no es cómo cargo estos datos, sino este componente necesita el navegador, o puede vivir enteramente en el servidor.
React 19.2 trae el componente <ViewTransition> y Next.js 16 lo conecta con las navegaciones del App Router: transiciones animadas entre páginas sin librerías externas ni CSS manual.
Cache Components cambia el modelo mental del cacheo en Next.js: en vez de heurísticas implícitas, marcas explícitamente con "use cache" qué partes de tu app pueden cachearse.