El modelo de rendering en Next.js
Entendé el modelo completo de rendering de Next.js: Server Components, Client Components, hydration, rendering estático y dinámico, Streaming con Suspense y Partial Prerendering.

Serie: Entendiendo Next.js — Este artículo es parte de la serie "Entendiendo Next.js", donde exploramos cómo funciona Next.js internamente.
Next.js no es solo React con routing. Es un framework que cambia fundamentalmente dónde corre tu código. Entender ese modelo es la diferencia entre escribir Next.js y realmente usarlo bien.
La pregunta central
En React puro, todo corre en el browser. El usuario descarga tu JS, React lo ejecuta en su máquina, y el DOM aparece. Punto.
Next.js rompe esa regla. Tu código puede correr en el servidor antes de que el usuario haga cualquier cosa, en el cliente después de que la página cargue, o en ambos lados en momentos distintos.
La pregunta que guía todo en Next.js es una sola:
¿Este código corre en el servidor, en el cliente, o en ambos?
Cada decisión de arquitectura en una app de Next.js responde a esa pregunta. El modelo completo se ve así:
Tu código (app/)
│
▼
┌─────────────┐
│ Servidor │ ← Server Components, fetch, DB, secrets
│ (Node.js) │
└──────┬──────┘
│ HTML + RSC Payload
▼
┌─────────────┐
│ Browser │ ← Descarga HTML, lo muestra inmediatamente
│ (usuario) │
└──────┬──────┘
│ React descarga y ejecuta JS
▼
┌─────────────┐
│ Hydration │ ← React "despierta" los Client Components
│ │
└──────┬──────┘
│
▼
┌─────────────┐
│ Interactivo │ ← onClick, useState, efectos funcionan
└─────────────┘
Server Components: el default
Todo componente que creás en app/ es un Server Component por defecto. No necesitás marcarlos con nada — el default es el servidor.
Esto es un cambio de paradigma respecto a React tradicional. En Next.js, la pregunta no es "¿cómo traigo datos al componente?" sino "¿dónde corre este componente?"
Ventajas de los Server Components:
- Acceso directo a bases de datos, sistemas de archivos y APIs internas
- Los secrets (API keys, tokens) nunca llegan al browser
- Cero JavaScript enviado al cliente para ese componente
- El HTML llega completamente renderizado — mejor para SEO y tiempo de carga inicial
Limitaciones:
- Sin estado (
useStateno existe) - Sin manejo de eventos (
onClick,onChange) - Sin APIs del browser (
window,document,localStorage) - Sin efectos (
useEffect)
Un Server Component típico:
// app/posts/page.tsx
// Este componente corre SOLO en el servidor
interface Post {
id: number
title: string
excerpt: string
}
async function getPosts(): Promise<Post[]> {
// fetch directo — esto nunca llega al browser
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts() // await directo en el componente
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</li>
))}
</ul>
)
}
El usuario recibe HTML completo. No descarga el código de getPosts. No ve la URL de la API.
Cuándo agregar 'use client'
'use client' es la directiva que crea el límite entre el mundo servidor y el mundo cliente. Todo lo que esté dentro de ese archivo y sus importaciones pasa a ser código de cliente.
Agregás 'use client' cuando necesitás:
- Estado local —
useState,useReducer - Efectos —
useEffect,useLayoutEffect - Eventos del usuario —
onClick,onChange,onSubmit - APIs del browser —
localStorage,navigator,window - Librerías que asumen el browser — animaciones, drag and drop, etc.
La regla práctica: usarlo lo más abajo posible en el árbol. Si solo el botón necesita interactividad, no marqués toda la página como cliente — extraé el botón.
// components/like-button.tsx
'use client'
import { useState } from 'react'
interface LikeButtonProps {
initialCount: number
}
export function LikeButton({ initialCount }: LikeButtonProps) {
const [count, setCount] = useState(initialCount)
return (
<button onClick={() => setCount(c => c + 1)}>
♥ {count}
</button>
)
}
// app/posts/[id]/page.tsx
// Server Component — sin 'use client'
import { LikeButton } from '@/components/like-button'
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id)
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Solo este botón es Client Component */}
<LikeButton initialCount={post.likes} />
</article>
)
}
El resultado: la página entera es Server Component, llega como HTML. Solo el botón descarga JS y se hidrata. Para más detalle sobre los límites y el árbol de componentes, consultá el post dedicado a Client Components.
Hydration: cómo el servidor pasa el control al cliente
Cuando el servidor renderiza tu app, genera HTML estático y lo envía al browser. El usuario ve la página casi instantáneamente. Pero ese HTML todavía no es interactivo — los onClick no funcionan, el useState no existe.
Hydration es el proceso por el cual React "despierta" la página: toma el HTML existente y lo conecta con el árbol de componentes de React.
Servidor genera: Browser recibe: Después de hydration:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ <article> │ │ <article> │ │ <article> │
│ <h1>Título</h1│ ───► │ <h1>Título</h1│ ───► │ <h1>Título</h1│
│ <button>♥ 5 │ │ <button>♥ 5 │ │ <button │
│ </button> │ │ </button> │ │ onClick=fn> │
│ </article> │ │ </article> │ │ </article> │
└─────────────────┘ └─────────────────┘ └─────────────────┘
HTML + RSC Payload visible pero interactivo
(con datos) no interactivo
Por eso existe esa ventana donde el usuario ve contenido pero los botones aún no responden. El contenido es inmediato (HTML del servidor), la interactividad llega después (JS del cliente).
Rendering estático vs dinámico
Más allá de servidor vs cliente, Next.js tiene otra dimensión: cuándo se ejecuta el código del servidor.
Estático (Static Rendering)
El componente se renderiza una vez en build time. El resultado se guarda y se sirve desde CDN para todos los usuarios.
- Default cuando no hay data dinámica
- Máxima performance — el servidor no hace trabajo en cada request
- Ideal para contenido que no cambia entre usuarios: landing pages, posts de blog, documentación
Dinámico (Dynamic Rendering)
El componente se renderiza en cada request. Permite personalización por usuario.
Next.js opta automáticamente por renderizado dinámico cuando detecta que usás:
cookies()— para leer cookies del requestheaders()— para leer headers HTTPsearchParams— parámetros de query en la URL (?q=algo)noStore()denext/cache— para optar out explícitamente
// Este componente será dinámico automáticamente
import { cookies } from 'next/headers'
export default async function UserGreeting() {
const cookieStore = await cookies()
const userId = cookieStore.get('user-id')?.value
if (!userId) return <p>Hola, visitante</p>
const user = await getUserById(userId)
return <p>Hola, {user.name}</p>
}
El caché de fetch
En App Router, fetch tiene caching automático integrado. Esto es lo que conecta el comportamiento de tus fetches con el tipo de rendering que Next.js elige.
// Cacheado indefinidamente — contribuye al rendering estático (default en Next.js 14)
const res = await fetch('https://api.example.com/posts')
// Sin caché — fuerza rendering dinámico en cada request
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store',
})
// ISR: se revalida cada 60 segundos
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
})
Nota: en Next.js 15, el comportamiento default cambió — fetch ya no cachea automáticamente a menos que lo especifiques con { next: { revalidate: ... } }. Si migrás desde Next.js 14, revisá este punto.
El patrón de revalidación (next: { revalidate: 60 }) es la base del ISR (Incremental Static Regeneration): la página se sirve desde caché como si fuera estática, pero se regenera en el servidor cada N segundos cuando llega un nuevo request.
Comparativa
| Estático | ISR | Dinámico | |
|---|---|---|---|
| Cuándo se renderiza | Build time | Build time + cada N segundos | Cada request |
| Caché | CDN, indefinido | CDN, con expiración | No cacheado |
| Performance | Máxima | Muy alta | Depende del servidor |
| Personalización | No | No | Sí |
| Cuándo usarlo | Contenido invariante | Contenido que cambia poco | Datos del usuario, sesiones |
| Cómo activarlo | Default / force-static | { next: { revalidate: N } } | cache: 'no-store' / cookies() |
Streaming con Suspense
El problema del rendering dinámico: si una parte de la página tarda 2 segundos en cargar datos, el usuario espera 2 segundos antes de ver cualquier cosa.
Streaming resuelve esto: en lugar de esperar que toda la página esté lista, Next.js envía el HTML en partes. Lo que está listo sale primero, lo que tarda llega después.
<Suspense> es la herramienta para controlarlo. Envolvés la parte lenta en un Suspense con un fallback, y esa sección fluye al browser cuando sus datos están listos:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from '@/components/user-stats'
import { RecentActivity } from '@/components/recent-activity'
import { StatsSkeleton, ActivitySkeleton } from '@/components/skeletons'
export default function DashboardPage() {
return (
<main>
{/* Esta sección aparece inmediatamente */}
<h1>Dashboard</h1>
{/* Esta sección muestra skeleton mientras carga */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
{/* Esta sección es independiente — no espera a UserStats */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</main>
)
}
Los skeletons muestran la forma de lo que va a aparecer:
// components/skeletons.tsx
export function StatsSkeleton() {
return (
<div className="animate-pulse">
<div className="h-8 w-32 bg-gray-200 rounded mb-4" />
<div className="grid grid-cols-3 gap-4">
<div className="h-24 bg-gray-200 rounded" />
<div className="h-24 bg-gray-200 rounded" />
<div className="h-24 bg-gray-200 rounded" />
</div>
</div>
)
}
export function ActivitySkeleton() {
return (
<div className="animate-pulse space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-3">
<div className="h-10 w-10 bg-gray-200 rounded-full" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
</div>
))}
</div>
)
}
loading.tsx es un atajo: Next.js lo convierte automáticamente en un Suspense boundary para toda la ruta:
// app/dashboard/loading.tsx
// Equivalente a envolver toda la página en <Suspense fallback={...}>
export default function Loading() {
return <StatsSkeleton />
}
Impacto en Core Web Vitals:
- TTFB (Time to First Byte): baja porque el servidor empieza a enviar HTML antes de terminar todos los fetches
- LCP (Largest Contentful Paint): mejora si el contenido principal no está detrás del dato lento
Partial Prerendering (PPR)
Rendering estático es rápido pero no personalizable. Rendering dinámico es personalizable pero más lento. Streaming mejora la experiencia pero la ruta sigue siendo dinámica.
PPR combina los tres: el shell estático de la página se genera en build time y se sirve desde CDN al instante. Las partes dinámicas streaman desde el servidor después, en el mismo request.
Build time: Request time:
┌────────────────────┐ ┌────────────────────┐
│ SHELL (estático) │ │ SHELL (desde CDN) │
│ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │ Header │ │ │ │ Header │ │ ← inmediato
│ │ Layout │ │ ───► │ │ Layout │ │
│ │ [▓▓▓ hole ▓▓▓] │ │ │ │ [skeleton] │ │ ← placeholder
│ └────────────────┘ │ │ └────────────────┘ │
└────────────────────┘ └────────────┬───────┘
│
┌───────────▼───────┐
│ Contenido dinámico│ ← streamed
│ (datos del usuario│
│ en ese request) │
└───────────────────┘
Para activarlo, primero en next.config.ts:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
}
export default nextConfig
Después, en las rutas que lo soporten:
// app/feed/page.tsx
import { Suspense } from 'react'
import { StaticHeader } from '@/components/static-header'
import { PersonalizedFeed } from '@/components/personalized-feed'
import { FeedSkeleton } from '@/components/skeletons'
export const experimental_ppr = true
export default function FeedPage() {
return (
<div>
{/* Este componente forma parte del shell estático */}
<StaticHeader />
{/* Este "hoyo" en el shell se rellena con contenido dinámico */}
<Suspense fallback={<FeedSkeleton />}>
<PersonalizedFeed /> {/* usa cookies() internamente */}
</Suspense>
</div>
)
}
El shell (todo lo que no está en Suspense) se genera en build time y se cachea. Los Suspense boundaries marcan dónde van los "hoyos" dinámicos.
Estado actual: experimental en Next.js 15+. Habilitado con experimental.ppr = true en la config y experimental_ppr = true por ruta. El objetivo del equipo de Next.js es estabilizarlo en Next.js 16. Aunque la idea es prometedora, PPR aún no está estabilizado y no todas las apps lo usan en producción — evaluá si tu caso de uso justifica asumir esa inestabilidad.
Tabla de decisión final
Cuando estés diseñando una ruta o componente, esta tabla te da el punto de partida:
| Situación | Estrategia | Herramienta |
|---|---|---|
| Contenido igual para todos, no cambia entre deploys | Estático | Default de Next.js |
| Contenido igual para todos, se actualiza periódicamente | Estático con revalidación | revalidate en fetch o next.revalidate |
| Contenido personalizado por usuario o sesión | Dinámico | cookies(), headers(), searchParams |
| Parte de la página tarda mucho en cargar | Streaming | <Suspense fallback={...}> |
| Ruta con shell estático + partes dinámicas | PPR | experimental_ppr = true + <Suspense> |
| Componente con estado, eventos o efectos | Client Component | 'use client' |
| Componente que solo muestra datos del servidor | Server Component | Default (sin directiva) |
La mayoría de las rutas de una app real combinan varias de estas estrategias. Un post de blog puede ser completamente estático. Un dashboard puede tener shell estático (PPR) con secciones de datos del usuario que streamean. Un formulario de búsqueda mezcla un Server Component para el layout con un Client Component para el input.
El modelo de Next.js no fuerza una sola estrategia — te da las herramientas para elegir la correcta para cada pieza de tu UI.
Seguir leyendo
Mantente al día
Te aviso cuando publique algo nuevo. Podés darte de baja cuando quieras.


