Cambiar tema

Next.js App Router + shadcn/ui: guía para mezclar componentes de servidor y cliente

Easton editorial illustration: cache waterfall instrument

En pantalla aparece un mensaje de error: Error: You're importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with "use client".

Ya añadiste "use client" en layout.tsx, ¿y sigue fallando?

Después de revisar la documentación un buen rato, descubres que el problema está en el límite entre importaciones de componentes. La frontera entre Server Components y Client Components en App Router es más compleja de lo que parece.

Es el escenario real que muchos desarrolladores encuentran al migrar a App Router. El framework trata todos los componentes como Server Components por defecto, pero las librerías de UI (como shadcn/ui) suelen requerir Client Components. ¿Cómo trazar esa frontera? ¿Cómo fluyen los datos? ¿Cómo optimizar el rendimiento?

Este artículo aclara esas dudas de forma definitiva.


Server Components vs Client Components: la diferencia fundamental

Empecemos por lo básico: en App Router, todos los componentes son Server Components por defecto.

¿Qué implica eso? Tu page.tsx y layout.tsx se renderizan en el servidor por defecto y no envían JavaScript al navegador.

Qué pueden hacer los Server Components

La ventaja principal de los Server Components es estar “más cerca de los datos”:

// app/products/page.tsx - Server Component (por defecto)
async function ProductsPage() {
  // Obtén datos directamente con await en el componente
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 } // caché de 1 hora
  }).then(res => res.json())

  return (
    <div>
      {products.map(p => (
        <div key={p.id}>{p.name} - ${p.price}</div>
      ))}
    </div>
  )
}

Sin useEffect, sin useState: un simple await basta para obtener datos. Esa es la característica de “componentes async” de los Server Components.

Casos de uso:

  • Obtención de datos (fetch, consultas a base de datos)
  • Acceso a APIs exclusivas del backend (headers(), cookies())
  • Librerías pesadas (por ejemplo, un parser de markdown de 100 KB+; con Server Component no se empaquetan para el navegador)
  • Manejo de información sensible (las API keys nunca se exponen al frontend)

Qué pueden hacer los Client Components

Los Client Components son los componentes React “tradicionales” que ya conoces. Solo hay que añadir "use client" al inicio del archivo:

// components/like-button.tsx
'use client'

import { useState } from 'react'

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false)
  const [count, setCount] = useState(0)

  const handleClick = () => {
    setLiked(!liked)
    setCount(prev => liked ? prev - 1 : prev + 1)
  }

  return (
    <button onClick={handleClick}>
      {liked ? '❤️' : '🤍'} {count}
    </button>
  )
}

Casos de uso:

  • Manejo de eventos (onClick, onChange, onSubmit)
  • React hooks (useState, useEffect, useRef, useContext)
  • APIs del navegador (localStorage, window, document)
  • Context Provider

Un detalle contraintuitivo: los Client Components también pre-renderizan HTML en el servidor. Después se hidratan en el navegador para recuperar la interactividad. En la primera visita el usuario ve contenido completo, sin pantalla en blanco esperando la carga de JS.


Regla central: quién puede importar a quién

Aquí es donde más errores se cometen.

La regla es simple, pero muchos la recuerdan al revés:

  1. Un Server Component puede importar Client Components
  2. Un Client Component no puede importar Server Components
  3. Un Server Component puede pasarse como children a un Client Component

La tercera regla puede resultar confusa; con código se entiende mejor:

// app/page.tsx - Server Component
import { ClientContainer } from './client-container'
import { ServerData } from './server-data'

export default function Page() {
  return (
    <ClientContainer>
      {/* ServerData se pasa como children */}
      <ServerData />
    </ClientContainer>
  )
}

// client-container.tsx
'use client'

export function ClientContainer({ children }) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}
    </div>
  )
}

// server-data.tsx - Server Component
async function ServerData() {
  const data = await fetch('/api/data').then(r => r.json())
  return <div>{data.title}</div>
}

Es un patrón muy habitual: Client Container gestiona la lógica interactiva y Server Data obtiene los datos. Se separan mediante children, sin importación directa.


Integración con shadcn/ui: por qué resulta “complicado”

shadcn/ui es una de mis librerías de UI favoritas, pero en App Router requiere cierto cuidado.

La razón principal: shadcn/ui se basa en Radix UI y la mayoría de componentes usan React hooks.

Button, Dialog, Dropdown Menu y similares llevan useState o useEffect internamente, así que deben ser Client Components.

Ejemplo incorrecto: usar shadcn/ui directamente en un Server Component

// ❌ Error: Server Component importa Client Component
import { Button } from '@/components/ui/button'

async function ProductPage() {
  const product = await fetchProduct()

  return (
    <div>
      <h1>{product.name}</h1>
      {/* Esto fallará: Button necesita "use client" */}
      <Button onClick={() => addToCart(product.id)}>
        Add to Cart
      </Button>
    </div>
  )
}

El error indicará que Button usa useState y debe marcarse con "use client".

Solución correcta 1: extraer la parte interactiva como Client Component

El enfoque más habitual y sencillo:

// app/product/page.tsx - Server Component
import { ProductInfo } from './product-info'
import { AddToCartButton } from './add-to-cart-button'

async function ProductPage({ params }) {
  const product = await fetchProduct(params.id)

  return (
    <div>
      {/* Server Component: presentación de datos */}
      <ProductInfo product={product} />

      {/* Client Component: interactividad */}
      <AddToCartButton productId={product.id} />
    </div>
  )
}

// product-info.tsx - Server Component
export function ProductInfo({ product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>${product.price}</span>
    </div>
  )
}

// add-to-cart-button.tsx - Client Component
'use client'

import { Button } from '@/components/ui/button'
import { useState } from 'react'

export function AddToCartButton({ productId }) {
  const [loading, setLoading] = useState(false)

  const handleAdd = async () => {
    setLoading(true)
    await addToCart(productId)
    setLoading(false)
  }

  return (
    <Button onClick={handleAdd} disabled={loading}>
      {loading ? 'Adding...' : 'Add to Cart'}
    </Button>
  )
}

La idea clave: extrae la parte interactiva como nodo hoja y deja el resto como Server Component.

Solución correcta 2: patrón de composición (Server pasa datos al Client)

Si el Client Component necesita datos iniciales:

// app/dashboard/page.tsx - Server Component
import { DataTable } from './data-table'

async function DashboardPage() {
  const users = await fetchUsers() // el Server Component obtiene los datos

  return <DataTable data={users} /> // los pasa al Client Component
}

// data-table.tsx - Client Component
'use client'

import { Table } from '@/components/ui/table'
import { useState } from 'react'

export function DataTable({ data }) {
  const [selectedRows, setSelectedRows] = useState([])

  return (
    <Table>
      {/* componente Table de shadcn/ui */}
      <TableBody>
        {data.map(user => (
          <TableRow
            key={user.id}
            selected={selectedRows.includes(user.id)}
            onClick={() => toggleSelection(user.id)}
          >
            <TableCell>{user.name}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Así aprovechas la obtención de datos en el servidor y conservas la interactividad en el cliente.


Dónde colocar el Context Provider

Otra duda frecuente: ¿dónde poner Context Providers globales como ThemeProvider o AuthProvider?

La respuesta: deben estar en un Client Component, pero “cuanto más profundo, mejor”.

// app/layout.tsx - Server Component (root layout)
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {/* No coloques Providers aquí */}
        {children}
      </body>
    </html>
  )
}

// app/providers.tsx - Client Component
'use client'

import { ThemeProvider } from 'next-themes'
import { AuthProvider } from './auth-context'

export function Providers({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        {children}
      </AuthProvider>
    </ThemeProvider>
  )
}

// app/dashboard/layout.tsx - Server Component
import { Providers } from '../providers'

export default function DashboardLayout({ children }) {
  return (
    <Providers>
      {children}
    </Providers>
  )
}

¿Por qué “más profundo”? Porque un Provider convierte en subárbol de Client Component todo lo que envuelve. Si lo pones en el root layout, toda la aplicación se verá forzada al renderizado en cliente.

Colocarlo en un nivel más profundo (por ejemplo, el layout de una ruta concreta) minimiza el alcance del Provider.


Flujo de datos: de Server a Client

Las props son la forma más simple y fiable:

// Server Component obtiene los datos
const data = await fetchData()

// los pasa al Client Component
<ClientComponent initialData={data} />

Un punto de optimización: la función React.cache().

Si varios Server Components necesitan los mismos datos, cache evita peticiones duplicadas:

// lib/get-user.ts
import { cache } from 'react'

export const getUser = cache(async (id: string) => {
  return await db.query('SELECT * FROM users WHERE id = ?', [id])
})

// app/layout.tsx
async function Layout() {
  const user = await getUser('123') // primera petición
  return <header>{user.name}</header>
}

// app/page.tsx
async function Page() {
  const user = await getUser('123') // mismos parámetros, sin petición duplicada
  return <main>Welcome {user.name}</main>
}

cache deduplica automáticamente las llamadas con los mismos parámetros en un mismo ciclo de renderizado.


Cuatro errores más frecuentes

Error 1: abusar de “use client” en niveles altos

// ❌ app/layout.tsx con "use client"
'use client'

export default function Layout({ children }) {
  return <div>{children}</div>
}

Todo el subárbol de la aplicación pasa a ser Client Component y pierdes las ventajas de rendimiento de los Server Components.

Corrección: añade "use client" solo en componentes que realmente necesiten interactividad, y mantenlo en nodos hoja.

Error 2: usar hooks en un Server Component

// ❌ Server Component con useState
async function Page() {
  const [count, setCount] = useState(0) // ¡error!
  return <div>{count}</div>
}

Corrección: extrae la parte que necesita hooks como Client Component.

Error 3: usar headers()/cookies() en un Client Component

// ❌ Client Component con APIs del servidor
'use client'

import { headers } from 'next/headers'

function UserProfile() {
  const headersList = headers() // ¡error! solo válido en Server Component
  return <div>...</div>
}

Corrección: obtén los datos en el Server Component y pásalos al Client Component:

// Server Component obtiene headers
async function Page() {
  const userAgent = headers().get('user-agent')
  return <UserProfile userAgent={userAgent} />
}

// Client Component recibe los datos
'use client'
function UserProfile({ userAgent }) {
  return <div>Browser: {userAgent}</div>
}

Error 4: componentes de terceros sin marcar “use client”

// ❌ Server Component importa componente de terceros sin marcar
import { AcmeCarousel } from 'acme-carousel'

async function Page() {
  return <AcmeCarousel /> // ¡error! AcmeCarousel usa hooks internamente
}

Corrección: crea un wrapper:

// components/carousel-wrapper.tsx
'use client'

import { AcmeCarousel } from 'acme-carousel'

export function CarouselWrapper(props) {
  return <AcmeCarousel {...props} />
}

// page.tsx - Server Component
import { CarouselWrapper } from './carousel-wrapper'

async function Page() {
  return <CarouselWrapper /> // funciona correctamente
}

Recomendaciones de optimización de rendimiento

Algunos consejos prácticos finales:

1. Client Components en nodos hoja

Esta regla puede reducir un 70 % del JavaScript del cliente.

En una página de listado de productos, por ejemplo:

  • Cuadrícula de productos: Server Component
  • Cada tarjeta de producto: Server Component
  • Selector de cantidad en la tarjeta: Client Component (única parte interactiva)

2. Renderizado en streaming con Suspense

// app/page.tsx
import { Suspense } from 'react'
import { ProductList } from './product-list'
import { Recommendations } from './recommendations'

export default function Page() {
  return (
    <div>
      {/* Muestra primero el skeleton; se reemplaza cuando llegan los datos */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductList />
      </Suspense>

      {/* Contenido secundario con streaming independiente */}
      <Suspense fallback={<RecSkeleton />}>
        <Recommendations />
      </Suspense>
    </div>
  )
}

El usuario ve primero el marco de la página y los datos se van completando. Mucho mejor que esperar a que cargue todo.

3. Estrategia de caché en fetch

// Datos estáticos (obtenidos en build)
await fetch(url, { cache: 'force-cache' })

// ISR: revalidación cada hora
await fetch(url, { next: { revalidate: 3600 } })

// Datos dinámicos (obtenidos en cada petición)
await fetch(url, { cache: 'no-store' })

Elige la estrategia de caché adecuada y evita un renderizado excesivamente dinámico.


Resumen

En pocas palabras, lo esencial:

  1. Usa Server Components por defecto; Client Components solo cuando necesites interactividad
  2. Server puede importar Client, pero Client no puede importar Server
  3. Pasa datos mediante children o props y mantén límites claros
  4. Coloca “use client” en nodos hoja; no lo abuses en niveles altos
  5. Extrae los componentes de shadcn/ui por separado; no los mezcles dentro de Server Components

La frontera Server/Client en App Router está pensada para acercarte a los datos y alejarte del navegador. Con eso claro, muchas dudas se resuelven solas.

Te recomendamos empezar con páginas sencillas: escribe primero un Server Component para obtener datos y añade la interactividad poco a poco. Si aparece un error, no entres en pánico: suele ser un problema de límites — revisa las relaciones de importación y lo localizarás rápido.


Serie: Este artículo forma parte de la serie Guía completa de Next.js (artículo 46). Si estás aprendiendo Next.js App Router, echa un vistazo al resto de la serie. Para más trucos prácticos con shadcn/ui, consulta la serie Guía práctica de Tailwind y shadcn/ui.

Mezclar correctamente Server y Client Components

Mejores prácticas para integrar shadcn/ui en un proyecto Next.js App Router

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Identificar el tipo de componente necesario

    Evalúa si cada componente necesita interactividad:

    • Manejo de eventos (onClick, onChange) → Client Component
    • React hooks (useState, useEffect) → Client Component
    • APIs del navegador (localStorage, window) → Client Component
    • Solo presentación de datos, sin interactividad → Server Component (por defecto)
  2. 2

    Step 2: Extraer la parte interactiva como nodo hoja

    Separa la parte que requiere interactividad en un Client Component:

    • Crea un archivo nuevo con 'use client' al inicio
    • Importa componentes de shadcn/ui (Button, Dialog, etc.)
    • Importa ese Client Component desde el Server Component
    • Pasa los datos mediante props
  3. 3

    Step 3: Diseñar el flujo de datos

    El Server Component obtiene los datos y los pasa al Client Component:

    • Usa async/await en el Server Component para obtener datos
    • Pasa los datos al Client Component mediante props
    • Si varios lugares necesitan los mismos datos, usa React.cache() para evitar peticiones duplicadas
    • Evita usar headers()/cookies() directamente en Client Components
  4. 4

    Step 4: Colocar el Context Provider

    El Provider debe ser Client Component, pero colócalo en un layout profundo:

    • Crea providers.tsx y márcalo con 'use client'
    • Envuelve ThemeProvider, AuthProvider, etc.
    • Impórtalo en el layout.tsx de una ruta concreta (no en el root layout)
    • Minimiza el alcance del subárbol de Client Components
  5. 5

    Step 5: Verificar y optimizar

    Comprueba que los límites entre componentes sean correctos:

    • Asegúrate de que 'use client' solo esté en nodos hoja
    • Verifica que ningún Client Component importe Server Components
    • Envuelve componentes asíncronos con Suspense
    • Configura una estrategia de caché razonable para fetch

FAQ

¿Por qué los componentes de shadcn/ui deben ser Client Components?
shadcn/ui se basa en Radix UI; la mayoría de componentes usan React hooks internamente (como useState, useContext) para gestionar estado y manejar eventos. Esos hooks solo pueden ejecutarse en el navegador, por lo que hay que marcar 'use client'.
¿Pueden importarse mutuamente Server Components y Client Components?
Un Server Component puede importar Client Components, pero un Client Component no puede importar Server Components. Sin embargo, puedes pasar un Server Component como contenido a un Client Component mediante la prop children, y así ambos pueden trabajar juntos.
¿Cómo evitar peticiones duplicadas del mismo dato en varios Server Components?
Usa la función React.cache() para envolver la lógica de obtención de datos. Las llamadas con los mismos parámetros se deduplican automáticamente en un mismo ciclo de renderizado, evitando consultas repetidas a la base de datos o a la API.
¿Dónde debe colocarse el Context Provider?
El Provider debe ser Client Component (porque depende de React Context), pero no lo pongas en el root layout. Se recomienda importarlo en el layout.tsx de una ruta concreta para minimizar el alcance del subárbol de Client Components y conservar las ventajas de Server Components.
¿Qué hacer si aparece el error 'useEffect solo puede usarse en Client Component'?
Revisa la cadena de importación del componente que falla: localiza el que usa hooks o manejo de eventos y añade 'use client' al inicio del archivo. Si un componente de terceros no está marcado, crea un wrapper con 'use client' e impórtalo desde ahí.
¿Cómo decidir si un componente debe ser Server o Client Component?
Regla simple: necesita onClick, onChange u otra interactividad → Client; necesita useState, useEffect u otros hooks → Client; necesita localStorage, window u otras APIs del navegador → Client; en el resto de casos, Server Component por defecto.

10 min de lectura · Publicado el: 31 mar 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog