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').
Los Server Components se ejecutan en el servidor y envían HTML renderizado al cliente:
// 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>
)
} 'use client')Los Client Components se ejecutan en el navegador y permiten interactividad:
'use client' al inicio del archivo// 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''use client'La directiva 'use client' marca un componente para ejecutarse en el cliente:
// 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>
)
} 'use server'La directiva 'use server' marca funciones para ejecutarse exclusivamente en el servidor:
// 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 }
} // 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>
)
} 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 es una técnica (muy usada en React, Next.js, Vue, etc.) para dividir tu código en partes más pequeñas (“chunks”) que se cargan solo cuando se necesitan, en lugar de enviar todo el bundle al inicio. Next.js automáticamente hace code splitting por rutas, pero puedes optimizar más:
// 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 permite cargar componentes o módulos de forma perezosa, solo cuando se necesitan. Esto es útil para componentes pesados que no se necesitan inmediatamente.
// 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>
)
} Next.js permite dividir tu aplicación en múltiples bundles basados en las rutas. Esto significa que solo se cargan los componentes necesarios para cada ruta, optimizando el tiempo de carga.
// 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>
)
} El code splitting reduce el JavaScript inicial, mejorando el tiempo de carga. Usa dynamic imports para componentes pesados que no se necesitan inmediatamente.
Combina Server y Client Components estratégicamente:
// 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>
)
} Componente que recibe datos del servidor pero permite interactividad:
// 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>
)
} | Escenario | Usar | Razón |
|---|---|---|
| Fetch de datos | Server Component | Mejor SEO y performance |
| Interactividad (clicks, forms) | Client Component | Necesita event handlers |
| useState, useEffect | Client Component | Hooks solo funcionan en cliente |
| Acceso a localStorage | Client Component | API del navegador |
| Contenido estático | Server Component | Reduce bundle size |
Usa Server Components por defecto y Client Components solo cuando necesites interactividad. Esto optimiza automáticamente tu aplicación.
// 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.