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.

El modelo de rendering en Next.js

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 (useState no existe)
  • Sin manejo de eventos (onClick, onChange)
  • Sin APIs del browser (window, document, localStorage)
  • Sin efectos (useEffect)

Un Server Component típico:

tsx
// 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 localuseState, useReducer
  • EfectosuseEffect, useLayoutEffect
  • Eventos del usuarioonClick, onChange, onSubmit
  • APIs del browserlocalStorage, 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.

tsx
// 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>
  )
}
tsx
// 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 request
  • headers() — para leer headers HTTP
  • searchParams — parámetros de query en la URL (?q=algo)
  • noStore() de next/cache — para optar out explícitamente
tsx
// 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.

tsx
// 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áticoISRDinámico
Cuándo se renderizaBuild timeBuild time + cada N segundosCada request
CachéCDN, indefinidoCDN, con expiraciónNo cacheado
PerformanceMáximaMuy altaDepende del servidor
PersonalizaciónNoNo
Cuándo usarloContenido invarianteContenido que cambia pocoDatos del usuario, sesiones
Cómo activarloDefault / 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:

tsx
// 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:

tsx
// 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:

tsx
// 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:

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:

tsx
// 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ónEstrategiaHerramienta
Contenido igual para todos, no cambia entre deploysEstáticoDefault de Next.js
Contenido igual para todos, se actualiza periódicamenteEstático con revalidaciónrevalidate en fetch o next.revalidate
Contenido personalizado por usuario o sesiónDinámicocookies(), headers(), searchParams
Parte de la página tarda mucho en cargarStreaming<Suspense fallback={...}>
Ruta con shell estático + partes dinámicasPPRexperimental_ppr = true + <Suspense>
Componente con estado, eventos o efectosClient Component'use client'
Componente que solo muestra datos del servidorServer ComponentDefault (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.

Emanuel López

Emanuel López

Desarrollador de Software · Montevideo, Uruguay

Mantente al día

Te aviso cuando publique algo nuevo. Podés darte de baja cuando quieras.