Por qué los componentes en Next.js App Router son Server Components por defecto y cuándo usar 'use client'

DUGLAS MORENODUGLAS MORENO 👁 7

Introducción

En el ecosistema de React, la forma en que pensamos sobre dónde se ejecuta el código ha cambiado radicalmente con la llegada de los Server Components. Next.js 13 introdujo el App Router, que adopta este nuevo modelo como la base por defecto. Si recién empezás a trabajar con Next.js o venís del Pages Router, probablemente te hayas preguntado: ¿Por qué los componentes son Server Components por defecto? y ¿Cuándo debo marcar un componente con 'use client'? En este artículo vamos a responder esas preguntas con profundidad, mostrar ejemplos concretos y señalar las buenas prácticas para que podés tomar decisiones informadas al diseñar tu aplicación.

A quién está dirigido

Este post está pensado para desarrolladores que ya conocen los fundamentos de React (hooks, JSX, renderizado del lado del cliente) y que tienen experiencia básica con Next.js. Si ya usaste el Pages Router y querés migrar o simplemente querés entender el nuevo modelo de renderizado, acá encontrarás la explicación que necesitás.

Qué vas a aprender

  • Qué es un Server Component y cómo difiere de un Client Component.
  • Por qué el App Router de Next.js los establece como predeterminados.
  • Cómo el entorno de ejecución afecta a la disponibilidad de APIs (por ejemplo, window, document, hooks de estado).
  • Cuándo y cómo marcar un componente con la directiva 'use client'.
  • Patrones de composición entre Server y Client Components.
  • Buenas prácticas, errores comunes y recomendaciones de rendimiento.

¿Qué son los Server Components?

Un Server Component es un componente de React que se renderiza exclusivamente en el servidor. Su salida es HTML puro (o un flujo de datos que el cliente puede interpretar) y nunca se envía JavaScript del componente al navegador. Esto significa que:

  • No se incluye en el bundle de JavaScript que descarga el usuario.
  • No puede usar hooks que dependan del estado del cliente (useState, useEffect, etc.).
  • No tiene acceso a APIs del navegador como window, document, localStorage o navigator.
  • Puede leer directamente de fuentes de datos del servidor (bases de datos, sistemas de archivos, APIs internas) sin exponer credenciales al cliente.

En contraste, un Client Component se renderiza tanto en el servidor (para la primera pintura) como en el cliente (para volver a renderizar interactivamente). Por eso necesita ser incluido en el bundle de JavaScript y sí puede usar estado, efectos y eventos del DOM.

Por qué el App Router hace que los componentes sean Server Components por defecto

El equipo de Next.js tomó esta decisión por varias razones de rendimiento y seguridad:

  1. Reducción del JavaScript enviado al cliente
    Cada kilobyte de JavaScript que el navegador debe descargar, analizar y ejecutar impacta directamente en el tiempo de interacción. Al mantener la mayor parte del árbol de componentes en el servidor, se envía solo el HTML necesario y el JavaScript estrictamente requerido para las partes interactivas.

  2. Aprovechamiento de la infraestructura del servidor
    Los Server Components pueden ejecutar consultas a bases de datos, leer archivos del sistema o llamar a microservicios internos sin que esas operaciones tengan que cruzar la red hacia el cliente. Esto elimina la necesidad de crear rutas API intermedias solo para obtener datos.

  3. Modelo de datos más sencillo
    Al renderizar en el servidor, los datos llegan ya procesados al componente. No tenés que lidiar con estados de carga, manejo de errores o race conditions que aparecen cuando los datos se traen en el cliente mediante fetch o libraries como React Query.

  4. Seguridad mejorada
    Las credenciales de acceso a bases de datos o a servicios privados nunca llegan al navegador porque el código que las usa nunca se envía al cliente. Esto reduce la superficie de ataque.

  5. Compatibilidad con streaming y suspense
    Los Server Components pueden ser transmitidos progresivamente (streaming) mientras el servidor sigue procesando otras partes del árbol. Esto permite mostrar contenido útil al usuario antes de que la página esté completamente lista.

En el App Router, cada archivo dentro de la carpeta app que no contiene la directiva 'use client' se trata automáticamente como un Server Component. Esta convención por configuración simplifica el desarrollo: escribís tus componentes como siempre y, salvo que necesites interactividad, no tenés que hacer nada extra.

Ciclo de vida y renderizado en el App Router

Para entender mejor el flujo, veamos qué ocurre cuando se solicita una ruta como /posts/[id].

  1. Resolución de la ruta
    Next.js identifica el segmento app/posts/[id]/page.js (o .tsx) como el componente que representa la página.

  2. Determinación del tipo de componente
    Si el archivo no empieza con 'use client', se considera Server Component.

  3. Ejecución en el servidor

    • Se importa el módulo.
    • Se ejecuta la función del componente.
    • Durante la ejecución, se pueden realizar operaciones asíncronas (por ejemplo, await db.post.findUnique({ where: { id } })).
    • El resultado es un árbol de React que se serializa a HTML (o a un formato de transmisión especial llamado React Server Components Payload).
  4. Envío al cliente
    El HTML se envía primero para que el navegador pueda mostrar el contenido rápidamente (principio de First Contentful Paint). Luego, se envía el payload que contiene las referencias a los Client Components que necesitan hidratación.

  5. Hidratación en el cliente
    El cliente recibe el payload, reconstruye el árbol de React y, solo para los componentes marcados como 'use client', ejecuta su código JavaScript, inicializa estado, efectos y adjunta manejadores de eventos.

Este proceso explica por qué los Server Components no pueden usar useState: no hay un entorno de cliente donde mantener ese estado después del render inicial.

Cuando marcar un componente con 'use client'

A pesar de las ventajas de los Server Components, hay situaciones en las que necesitamos interactividad, acceso a APIs del navegador o el uso de hooks de estado. En esos casos, el componente debe ser un Client Component y se indica con la directiva al principio del archivo:

'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p>Contador: {count}</p>
      <button onClick={() => setCount(count + 1)}>Incrementar</button>
    </div>
  )
}

Algunos escenarios típicos que requieren 'use client':

  • Estado local (useState, useReducer).
  • Efectos side‑effect (useEffect, useLayoutEffect).
  • Acceso a APIs del navegador (window, document, navigator.geolocation, localStorage).
  • Uso de bibliotecas que asumen un entorno de cliente (por ejemplo, muchos componentes de UI como react‑modal, framer‑motion, o bibliotecas de gráficos como Chart.js).
  • Manejo de eventos del DOM (onClick, onSubmit, onKeyDown).

Es importante notar que la directiva 'use client' solo afecta al archivo en el que se encuentra. Los componentes importados desde ese archivo heredan su tipo según sus propias directivas. Por ejemplo, si un Server Component importa un Client Component, ese hijo se renderiza en el cliente, pero el padre sigue siendo Server Component.

Ejemplos prácticos

A continuación vamos a ver varios ejemplos que ilustran la diferencia entre Server y Client Components, incluyendo la salida esperada en cada caso.

Ejemplo 1: Server Component que lee datos de una base de datos

Supongamos que tenemos una tabla posts en una base de datos PostgreSQL accesible mediante Prisma. El siguiente componente se encuentra en app/blog/page.tsx:

// app/blog/page.tsx
import { prisma } from '@/lib/prisma'

export default async function BlogPage() {
  const posts = await prisma.post.findMany({
    select: { id: true, title: true, author: { select: { name: true } } },
    orderBy: { createdAt: 'desc' },
    take: 10,
  })

  return (
    <section>
      <h1>Últimos artículos</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>Por: {post.author?.name ?? 'Anónimo'}</p>
          </li>
          ))
        }
      </ul>
    </section>
  )
}

Qué ocurre:

  • Al renderizarse en el servidor, la consulta a Prisma se ejecuta allí mismo.
  • El resultado es un array de objetos que se transforma directamente en HTML.
  • No se envía ningún código de Prisma ni de la conexión a la base de datos al cliente.

Salida esperada (HTML simplificado):

<section>
  <h1>Últimos artículos</h1>
  <ul>
    <li>
      <h2>Primer post</h2>
      <p>Por: Juan Pérez</p>
    </li>
    <li>
      <h2>Segundo post</h2>
      <p>Por: María López</p>
    </li>
    <!-- ... hasta 10 elementos -->
  </ul>
</section>

Ejemplo 2: Client Component que usa estado y efectos

Agregaremos un componente interactivo que permite filtrar los posts del ejemplo anterior sin recargar la página. Este componente vive en app/blog/components/FilterBar.tsx:

// app/blog/components/FilterBar.tsx
'use client'

import { useState, useEffect } from 'react'

export default function FilterBar({ posts }: { posts: Array<{ id: number; title: string }> }) {
  const [search, setSearch] = useState('')
  const [filtered, setFiltered] = useState(posts)

  useEffect(() => {
    if (!search.trim()) {
      setFiltered(posts)
      return
    }
    const lower = search.toLowerCase()
    setFiltered(posts.filter(p => p.title.toLowerCase().includes(lower)))
  }, [search, posts])

  return (
    <div style={{ marginBottom: '1rem' }}>
      <input
        type="text"
        placeholder="Buscar por título…"
        value={search}
        onChange={e => setSearch(e.target.value)}
        style={{ padding: '0.5rem', fontSize: '1rem' }}
      />
      <ul>
        {filtered.map(p => (
          <li key={p.id}>{p.title}</li>
        ))}
      </ul>
    </div>
  )
}

Cómo usarlo desde el Server Component:

// app/blog/page.tsx (actualizado)
import { prisma } from '@/lib/prisma'
import FilterBar from './components/FilterBar'

export default async function BlogPage() {
  const posts = await prisma.post.findMany({
    select: { id: true, title: true },
    orderBy: { createdAt: 'desc' },
    take: 20,
  })

  return (
    <section>
      <h1>Blog</h1>
      <FilterBar posts={posts} />
    </section>
  )
}

Qué ocurre:

  • BlogPage sigue siendo Server Component; obtiene los datos y los pasa como prop a FilterBar.
  • FilterBar tiene 'use client', por lo que su código JavaScript se incluye en el bundle del cliente.
  • En el cliente, useState y useEffect gestionan el estado del filtro y vuelven a renderizar la lista sin necesidad de otra petición al servidor.

Salida esperada (primer render en el servidor):

<section>
  <h1>Blog</h1>
  <div>
    <input type="text" placeholder="Buscar por título…" value="" />
    <ul>
      <li>Primer post</li>
      <li>Segundo post</li>
      <!-- ... -->
    </ul>
  </div>
</section>

Después de que el cliente hidrate FilterBar, si el usuario escribe "seg" en el input, el DOM se actualiza a:

<section>
  <h1>Blog</h1>
  <div>
    <input type="text" placeholder="Buscar por título…" value="seg" />
    <ul>
      <li>Segundo post</li>
    </ul>
  </div>
</section>

Ejemplo 3: Acceso a una API del navegador (geolocalización)

Imaginemos un componente que muestra la ubicación del usuario. Debe ser un Client Component porque usa navigator.geolocation.

// app/components/GeoLocation.tsx
'use client'

import { useState, useEffect } from 'react'

export default function GeoLocation() {
  const [location, setLocation] = useState<{ lat: number; lng: number } | null>(null)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    if (!navigator.geolocation) {
      setError('Geolocalización no soportada por este navegador')
      return
    }

    navigator.geolocation.getCurrentPosition(
      pos => {
        setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude })
        setError(null)
      },
      err => {
        setError(err.message)
        setLocation(null)
      }
    )
  }, [])

  if (error) return <p style={{ color: 'red' }}>{error}</p>
  if (!location) return <p>Obteniendo ubicación…</p>

  return (
    <p>
      Tu ubicación es: lat {location.lat.toFixed(4)}, lng {location.lng.toFixed(4)}
    </p>
  )
}

Uso en una página (puede ser Server o Client, pero importamos el Client Component):

// app/about/page.tsx
import GeoLocation from '@/components/GeoLocation'

export default function AboutPage() {
  return (
    <section>
      <h2>Sobre mí</h2>
      <GeoLocation />
    </section>
  )
}

Salida esperada (primer render):

<section>
  <h2>Sobre mí</h2>
  <p>Obteniendo ubicación…</p>
</section>

Tras obtener la posición, el cliente actualiza el DOM a algo como:

<section>
  <h2>Sobre mí</h2>
  <p>Tu ubicación es: lat -34.6037, lng -58.3816</p>
</section>

Ejemplo 4: Composición de Server y Client Components con hijos

Un patrón poderoso es pasar hijos desde un Server Component a un Client Component. Los hijos se renderizan en el servidor, pero el contenedor cliente puede añadir interactividad alrededor de ellos.

Server Component que envía hijos:

// app/dashboard/page.tsx
import Card from '@/components/Card'

export default function DashboardPage() {
  return (
    <section>
      <h1>Panel de control</h1>
      <Card title="Resumen">
        <p>Este contenido se renderiza en el servidor.</p>
        <p>Se puede incluir cualquier JSX, incluso otros Server Components.</p>
      </Card>
    </section>
  )
}

Client Component que actúa como contenedor:

// app/components/Card.tsx
'use client'

import { useState } from 'react'

export default function Card({
  title,
  children,
}: {
  title: string
  children: React.ReactNode
}) {
  const [expanded, setExpanded] = useState(false)

  return (
    <div style={{
      border: '1px solid #ddd',
      borderRadius: '8px',
      padding: '1rem',
      marginBottom: '1rem',
    }}>
      <h3>
        {title}
        <button
          onClick={() => setExpanded(!expanded)}
          style={{ marginLeft: '0.5rem' }}
        >
          {expanded ? 'Ocultar' : 'Mostrar'}
        </button>
      </h3>
      {expanded && <div>{children}</div>}
    </div>
  )
}

Qué ocurre:

  • El contenido dentro de <Card> (<p>Este contenido se renderiza en el servidor…</p>) se genera como HTML en el servidor y se envía al cliente.
  • El Card cliente agrega un botón que alterna el estado expanded. Cuando el usuario hace clic, solo la parte interior del card se muestra u oculta, sin volver a solicitar datos al servidor.

Salida esperada (primer render):

<section>
  <h1>Panel de control</h1>
  <div>
    <h3>
      Resumen
      <button>Mostrar</button>
    </h3>
    <div>
      <p>Este contenido se renderiza en el servidor.</p>
      <p>Se puede incluir cualquier JSX, incluso otros Server Components.</p>
    </div>
  </div>
</section>

Tras hacer clic en "Mostrar" (que ya está visible) el botón cambia a "Ocultar" y el contenido interno se esconde.

Buenas prácticas y errores comunes

Buenas prácticas

  1. Mantener la mayor parte del árbol en Server Components
    Siempre que sea posible, obtené los datos y generá la markup en el servidor. Sólo subí al cliente lo que realmente necesite interactividad.

  2. Usar props para pasar datos desde Server a Client
    Los Server Components pueden leer datos directamente y pasarlos como props a Client Components. Evitá hacer fetch dentro de un Client Component si los mismos datos ya están disponibles en el servidor.

  3. Aprovechar el streaming y Suspense
    Si una parte de la página depende de una consulta lenta, envolvé ese Server Component en <Suspense fallback={...}>. Mientras se resuelve, el resto de la página ya puede enviarse al cliente.

  4. Evitar efectos innecesarios en Client Components
    Si un Client Component solo necesita leer una prop y mostrarla, no uses useEffect o useState. Un componente puro se renderiza más rápido y evita re‑renderizaciones.

  5. Componentes de biblioteca: verificá su compatibilidad
    Algunas bibliotecas de UI asumen que se ejecutan en el cliente. Si vas a usarlas dentro de un Server Component, envolvélas en un Client Component wrapper o buscá versiones que ofrezcan Server Component support (por ejemplo, @nextui-org/react tiene componentes que funcionan en ambos entornos).

  6. Aprovechar los metadata exports
    En el App Router, podés exportar metadata o generateMetadata desde un Server Component para definir títulos, descripciones y tags Open Graph sin afectar al bundle de cliente.

Errores comunes

  • Poner 'use client' en archivos que no lo necesitan
    Esto fuerza al bundler a incluir todo el código del archivo (y sus dependencias) en el bundle) en el cliente, aumentando el tamaño del JavaScript innecesariamente.

  • Intentar usar window o document dentro de un Server Component
    Esto lanzará un error en tiempo de ejecución porque esas variables no existen en el entorno de Node. Siempre verificá que el código que accede a APIs del navegador esté dentro de un Client Component.

  • Olvidarse de marcar como Client Component un hook que depende del estado
    Si importás useState en un archivo sin la directiva, obtendrás un error de compilación: "Invalid hook call. Hooks can only be called inside of the body of a function component." Esto ocurre porque el entorno de servidor no tiene el dispatcher de hooks de React.

  • Pasar objetos grandes o funciones como props de Server a Client
    Solo los datos serializables (JSON) pueden pasarse como props. Si intentás pasar una función o una instancia de clase, el cliente recibirá undefined o causará un error de serialización.

  • Sobrecargar el cliente con lógica que podría estar en el servidor
    Por ejemplo, hacer filtros, ordenamientos o cálculos pesados en el cliente cuando los datos ya están disponibles en el servidor. Esto degrada la experiencia en dispositivos de bajo rendimiento.

Conclusión

Los Server Components son la columna vertebral del nuevo modelo de renderizado de Next.js App Router. Al estar activados por defecto, nos empujan a pensar en la separación de responsabilidades: qué pertenece al servidor (obtención de datos, generación de HTML seguro, acceso a recursos privados) y qué pertenece al cliente (estado interactivo, acceso a APIs del navegador, manejo de eventos).

Entender por qué son predeterminados nos ayuda a aprovechar sus beneficios: menos JavaScript enviado al usuario, tiempos de carga inicial más rápidos, mayor seguridad y la posibilidad de utilizar el poder del servidor sin exponerlo. Cuando la interactividad es necesaria, la directiva 'use client' es la forma explícita y segura de marcar esos componentes, asegurando que solo el código realmente requerido viaje al navegador.

Con los ejemplos mostrados viste cómo:

  • Un Server Component puede leer directamente de una base de datos y enviar HTML listo para mostrar.
  • Un Client Component puede usar estado, efectos y APIs del navegador para crear experiencias dinámicas.
  • La composición entre ambos permite pasar contenido renderizado en el servidor a contenedores interactivos en el cliente.

Aplicando estas ideas en tus proyectos, podrás construir aplicaciones que sean tanto rápidas como ricas en funcionalidades, manteniendo un equilibrio óptimo entre carga del servidor y trabajo del cliente.

Próximos pasos

Si querés profundizar, te sugiero:

  • Leer la documentación oficial de Next.js sobre Server Components y Client Components.
  • Experimentar con <Suspense> y streaming para mejorar la percepción de velocidad.
  • Explorar el uso de React Server Components en combinación con Edge Functions para renderizar cerca del usuario.

¡Manos a la obra y que tus Next.js apps vuelen!

Comentarios (0)

Sé el primero en comentar.

Dejá tu comentario