Server Components vs Client Components en Next.js

Next.js 13+ introdujo React Server Components, cambiando completamente cómo pensamos sobre el renderizado. Ahora tienes Server Components (por defecto) y Client Components (con 'use client').

Diferencia entre Server Components y Client Components


Server Components (Por defecto)

Los Server Components se ejecutan en el servidor y envían HTML renderizado al cliente:

  Características de Server Components

  • Se ejecutan en el servidor durante el build o request
  • No tienen acceso a APIs del navegador (localStorage, window, etc.)
  • Pueden acceder directamente a bases de datos y APIs del servidor
  • Reducen el bundle size del JavaScript enviado al cliente
  • Mejoran el SEO al renderizar contenido en el servidor

tsx
// app/products/page.tsx - Server Component (por defecto)
import { db } from '@/lib/db'

export default async function ProductsPage() {
// Acceso directo a la base de datos
const products = await db.product.findMany()

return (
  <div>
    <h1>Productos</h1>
    {products.map((product) => (
      <div key={product.id}>
        <h2>{product.name}</h2>
        <p>${product.price}</p>
      </div>
    ))}
  </div>
)
}

Client Components (Con ‘use client’)

Los Client Components se ejecutan en el navegador y permiten interactividad:

  Características de Client Components

  • Se ejecutan en el navegador después de la hidratación
  • Tienen acceso completo a APIs del navegador (useState, useEffect, etc.)
  • Permiten interactividad (clicks, formularios, animaciones)
  • Aumentan el bundle size pero proporcionan funcionalidad del cliente
  • Requieren la directiva 'use client' al inicio del archivo

tsx
// components/AddToCartButton.tsx - Client Component
'use client'

import { useState } from 'react'

interface Props {
productId: string
}

export default function AddToCartButton({ productId }: Props) {
const [isLoading, setIsLoading] = useState(false)

const handleAddToCart = async () => {
  setIsLoading(true)
  // Lógica para agregar al carrito
  await fetch('/api/cart', {
    method: 'POST',
    body: JSON.stringify({ productId })
  })
  setIsLoading(false)
}

return (
  <button 
    onClick={handleAddToCart}
    disabled={isLoading}
    className="bg-blue-600 text-white px-4 py-2 rounded"
  >
    {isLoading ? 'Agregando...' : 'Agregar al Carrito'}
  </button>
)
}

’use client’ y ‘use server’


1'use client'

La directiva 'use client' marca un componente para ejecutarse en el cliente:

tsx
// components/SearchBar.tsx
'use client'

import { useState, useEffect } from 'react'

export default function SearchBar() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])

useEffect(() => {
  if (query.length > 2) {
    // Búsqueda en tiempo real
    fetch(\`/api/search?q=\${query}\`)
      .then(res => res.json())
      .then(setResults)
  }
}, [query])

return (
  <div>
    <input
      type="text"
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Buscar productos..."
      className="border rounded px-3 py-2"
    />
    {results.length > 0 && (
      <ul className="mt-2 border rounded">
        {results.map(product => (
          <li key={product.id} className="p-2 hover:bg-gray-100">
            {product.name}
          </li>
        ))}
      </ul>
    )}
  </div>
)
}

2'use server'

La directiva 'use server' marca funciones para ejecutarse exclusivamente en el servidor:

tsx
// lib/actions.ts
'use server'

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
const price = parseFloat(formData.get('price') as string)

// Esta función solo se ejecuta en el servidor
const newProduct = await db.product.create({
  data: { name, price }
})

// Revalidar la página de productos
revalidatePath('/products')

return { success: true, product: newProduct }
}
tsx
// components/ProductForm.tsx
import { createProduct } from '@/lib/actions'

export default function ProductForm() {
return (
  <form action={createProduct}>
    <input 
      name="name" 
      placeholder="Nombre del producto" 
      required 
    />
    <input 
      name="price" 
      type="number" 
      placeholder="Precio" 
      required 
    />
    <button type="submit">
      Crear Producto
    </button>
  </form>
)
}
  Server Actions

Las Server Actions permiten ejecutar código del servidor directamente desde formularios y eventos del cliente, sin necesidad de crear API routes separadas.

Code Splitting


Next.js automáticamente hace code splitting por rutas, pero puedes optimizar más:

Code Splitting Automático

tsx
// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserProfile from '@/components/UserProfile'
import Analytics from '@/components/Analytics'

export default function DashboardPage() {
return (
  <div>
    <h1>Dashboard</h1>
    
    {/* Cada componente se carga por separado */}
    <Suspense fallback={<div>Cargando perfil...</div>}>
      <UserProfile />
    </Suspense>
    
    <Suspense fallback={<div>Cargando analytics...</div>}>
      <Analytics />
    </Suspense>
  </div>
)
}

Dynamic Imports para Client Components

tsx
// components/LazyModal.tsx
'use client'

import { useState } from 'react'
import dynamic from 'next/dynamic'

// Carga el modal solo cuando se necesita
const Modal = dynamic(() => import('./Modal'), {
loading: () => <p>Cargando modal...</p>,
ssr: false // No renderizar en el servidor
})

export default function LazyModal() {
const [showModal, setShowModal] = useState(false)

return (
  <div>
    <button onClick={() => setShowModal(true)}>
      Abrir Modal
    </button>
    
    {showModal && (
      <Modal onClose={() => setShowModal(false)}>
        <p>Contenido del modal</p>
      </Modal>
    )}
  </div>
)
}

Code Splitting por Rutas

tsx
// app/admin/page.tsx
import dynamic from 'next/dynamic'

// Carga componentes pesados solo en rutas específicas
const AdminDashboard = dynamic(() => import('@/components/AdminDashboard'), {
loading: () => <div>Cargando dashboard...</div>
})

const UserManagement = dynamic(() => import('@/components/UserManagement'))

export default function AdminPage() {
return (
  <div>
    <h1>Panel de Administración</h1>
    <AdminDashboard />
    <UserManagement />
  </div>
)
}
  Optimización de Bundle

El code splitting reduce el JavaScript inicial, mejorando el tiempo de carga. Usa dynamic imports para componentes pesados que no se necesitan inmediatamente.

Implementar Componentes


1Patrón de Composición

Combina Server y Client Components estratégicamente:

tsx
// app/products/[id]/page.tsx - Server Component
import { db } from '@/lib/db'
import AddToCartButton from '@/components/AddToCartButton'
import ProductReviews from '@/components/ProductReviews'

interface Props {
params: { id: string }
}

export default async function ProductPage({ params }: Props) {
// Datos del servidor
const productData = await db.product.findUnique({
  where: { id: params.id },
  include: { reviews: true }
})

if (!productData) return <div>Producto no encontrado</div>

return (
  <div>
    {/* Contenido estático del servidor */}
    <h1>{productData.name}</h1>
    <p>{productData.description}</p>
    <p className="text-2xl font-bold">${productData.price}</p>
    
    {/* Componente interactivo del cliente */}
    <AddToCartButton productId={productData.id} />
    
    {/* Componente híbrido */}
    <ProductReviews 
      initialReviews={productData.reviews}
      productId={productData.id}
    />
  </div>
)
}

2Componente Híbrido

Componente que recibe datos del servidor pero permite interactividad:

tsx
// components/ProductReviews.tsx
'use client'

import { useState } from 'react'

interface Review {
id: string
rating: number
comment: string
author: string
}

interface Props {
initialReviews: Review[]
productId: string
}

export default function ProductReviews({ initialReviews, productId }: Props) {
const [reviews, setReviews] = useState(initialReviews)
const [newReview, setNewReview] = useState('')
const [rating, setRating] = useState(5)

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault()
  
  const response = await fetch('/api/reviews', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      productId,
      comment: newReview,
      rating
    })
  })
  
  if (response.ok) {
    const review = await response.json()
    setReviews([review, ...reviews])
    setNewReview('')
    setRating(5)
  }
}

return (
  <div className="mt-8">
    <h3 className="text-xl font-semibold mb-4">Reseñas</h3>
    
    {/* Formulario interactivo */}
    <form onSubmit={handleSubmit} className="mb-6 p-4 border rounded">
      <div className="mb-3">
        <label>Calificación:</label>
        <select 
          value={rating} 
          onChange={(e) => setRating(Number(e.target.value))}
          className="ml-2 border rounded px-2 py-1"
        >
          {[1,2,3,4,5].map(num => (
            <option key={num} value={num}>{num} estrella{num > 1 && 's'}</option>
          ))}
        </select>
      </div>
      
      <textarea
        value={newReview}
        onChange={(e) => setNewReview(e.target.value)}
        placeholder="Escribe tu reseña..."
        className="w-full border rounded px-3 py-2 mb-3"
        rows={3}
        required
      />
      
      <button 
        type="submit"
        className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
      >
        Enviar Reseña
      </button>
    </form>
    
    {/* Lista de reseñas */}
    <div className="space-y-4">
      {reviews.map(review => (
        <div key={review.id} className="border-b pb-4">
          <div className="flex items-center mb-2">
            <span className="font-semibold">{review.author}</span>
            <span className="ml-2 text-yellow-500">
              {Array(review.rating).fill('★').join('')}
              {Array(5-review.rating).fill('☆').join('')}
            </span>
          </div>
          <p className="text-gray-700">{review.comment}</p>
        </div>
      ))}
    </div>
  </div>
)
}

3Mejores Prácticas

EscenarioUsarRazón
Fetch de datosServer ComponentMejor SEO y performance
Interactividad (clicks, forms)Client ComponentNecesita event handlers
useState, useEffectClient ComponentHooks solo funcionan en cliente
Acceso a localStorageClient ComponentAPI del navegador
Contenido estáticoServer ComponentReduce bundle size
  Regla de Oro

Usa Server Components por defecto y Client Components solo cuando necesites interactividad. Esto optimiza automáticamente tu aplicación.

Ejemplo Completo: E-commerce

tsx
// app/shop/page.tsx - Server Component principal
import { db } from '@/lib/db'
import ProductGrid from '@/components/ProductGrid'
import SearchFilters from '@/components/SearchFilters'

export default async function ShopPage() {
const products = await db.product.findMany({
  include: { category: true }
})

const categories = await db.category.findMany()

return (
  <div className="container mx-auto px-4">
    <h1 className="text-3xl font-bold mb-8">Tienda</h1>
    
    <div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
      {/* Filtros interactivos - Client Component */}
      <aside>
        <SearchFilters categories={categories} />
      </aside>
      
      {/* Grid de productos - Híbrido */}
      <main className="lg:col-span-3">
        <ProductGrid initialProducts={products} />
      </main>
    </div>
  </div>
)
}

Con esta arquitectura tendrás una aplicación Next.js optimizada que aprovecha lo mejor de Server y Client Components.