Frontend
React Server Components para veteranos de SPA
Juan Gómez Dev.to (EN Zone)
5 views
React Server Components para veteranos de SPA
Ya sabes construir una SPA. Has publicado varias. Usas useState, usas useEffect, montas un fetch, y a estas alturas escribes esa estructura casi sin pensarla.
Los Server Components rompen ese automatismo. Casi todas las explicaciones que vas a encontrar arrancan desde el lado del framework: el routing, el bundling, el formato en que viajan los datos. Para alguien que ya tiene un modelo mental armado y funcionando, ese orden es el peor posible. Así que este artículo empieza por tu lado: el código que escribes hoy, qué lo reemplaza y qué te cuesta el cambio.
Todos los ejemplos asumen React 19 y Next.js 16. En esas versiones los Server Components y el App Router ya son estables en producción.
Ese useEffect lo has escrito una docena de veces
Esta es la búsqueda de productos de Aurora Coffee Co., escrita como la escribe una SPA. No es un ejemplo debilitado a propósito: es la versión correcta, con las partes que casi todo el mundo olvida.
'use client'
import { useEffect, useState } from 'react'
type Product = {
id: number
name: string
origin: string
}
export function ProductSearch({ query }: { query: string }) {
const [products, setProducts] = useState<Product[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setIsLoading(true)
setError(null)
fetch(`/api/products?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((response) => {
if (!response.ok) throw new Error(`La búsqueda falló: ${response.status}`)
return response.json() as Promise<Product[]>
})
.then(setProducts)
.catch((cause: unknown) => {
// Una petición cancelada no es un fallo que haya que mostrar.
if (cause instanceof DOMException && cause.name === 'AbortError') return
setError(cause instanceof Error ? cause.message : 'La búsqueda falló')
})
.finally(() => setIsLoading(false))
return () => controller.abort()
}, [query])
if (isLoading) return <p>Buscando…</p>
if (error) return <p role="alert">{error}</p>
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} — {product.origin}
</li>
))}
</ul>
)
}
Treinta y pico de líneas. Tres piezas de estado, un abort controller y una comprobación de DOMException cuya única función es evitar que una petición cancelada se muestre como error. Quita el AbortController, escribe "ethiopia" lo bastante rápido y vas a ver los resultados de "ethiopi", porque la petición anterior, más lenta, llegó al final. Ese bug lo escribimos todos una vez. Nadie lo escribe a propósito.
Y en algún otro punto del repositorio hay un route handler en /api/products con exactamente un consumidor en el mundo: el componente de arriba.
La misma funcionalidad como Server Component:
import { searchProducts } from '@/lib/products'
export async function ProductResults({ query }: { query: string }) {
const products = await searchProducts(query)
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} — {product.origin}
</li>
))}
</ul>
)
}
Sin estado. Sin efecto. Sin flag de carga, sin flag de error, sin abort controller y sin endpoint /api/products: searchProducts consulta la base de datos directamente, porque esta función se ejecuta en un servidor y ahora eso está permitido.
Antes de que suene a folleto publicitario, lo que cuesta. Ese componente no puede responder a un clic, no puede guardar estado y no se vuelve a renderizar. Si la persona escribe una búsqueda nueva, algo más tiene que hacer que se ejecute otra vez. Eso no es un detalle al pie. Buena parte de este artículo trata de eso.
Qué cambia de verdad: componentes que se ejecutan una sola vez
La frase que conviene interiorizar:
Un Server Component se ejecuta una sola vez, en el servidor, y su resultado viaja al navegador como datos. Nunca se vuelve a ejecutar por algo que haga la persona que usa la página.
Todas las restricciones salen de ese único hecho, y por eso vale más deducirlas que memorizarlas.
Nada de useState, porque el estado existe para que un componente se renderice otra vez con un valor distinto, y aquí no hay otra vez. Nada de useEffect, porque los efectos existen para sincronizar algo después del render, y la vida de este componente ya terminó. Nada de onClick, porque una función no se puede enviar por la red a un navegador que no tiene la memoria de tu servidor.
A cambio, el cuerpo del componente pasa a ser una función async normal. Puedes hacer await de una consulta a la base de datos. Puedes leer process.env. Puedes importar un parser de Markdown de 300 KB, usarlo, y no enviar ni un byte de él al navegador, porque el navegador solo recibe el resultado ya renderizado.
La inversión mental que conviene asimilar es esta: en una SPA el servidor es una API de datos y el cliente renderiza. Con Server Components el servidor renderiza y el cliente es la excepción que eliges a propósito. Tu handler de /api/products nunca fue una API pública. Era el andamio que tapaba el hueco entre esas dos máquinas, y esto cierra el hueco.
En Next.js 16 todo componente dentro de app/ es un Server Component salvo que digas lo contrario. El valor por defecto cambió de bando, y eso agarra desprevenida a mucha gente.
Primero levántalo en tu máquina
Los Server Components dejan de ser abstractos en cuanto uno consulta una base de datos de verdad, así que vamos a tener una. Postgres en Docker, sin necesidad de cuenta en ningún proveedor.
services:
postgres:
image: postgres:17-alpine
container_name: aurora-db
environment:
POSTGRES_USER: aurora
POSTGRES_PASSWORD: aurora_local_dev
POSTGRES_DB: aurora
ports:
- "5432:5432"
volumes:
- ./seed.sql:/docker-entrypoint-initdb.d/01-seed.sql:ro
- aurora-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aurora -d aurora"]
interval: 5s
timeout: 3s
retries: 10
volumes:
aurora-pgdata:
El seed.sql que va al lado crea la tabla y la llena. El índice merece una explicación aparte. Una búsqueda con ILIKE '%texto%' no puede aprovechar un índice normal: el comodín va al principio, así que Postgres no tiene por dónde empezar y termina leyendo la tabla entera. La extensión pg_trgm resuelve eso partiendo cada valor en secuencias de tres caracteres — trigramas (trigrams) — e indexándolas, de modo que Postgres busca las coincidencias en el índice. Con seis productos da lo mismo; con seis mil deja de darlo:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
roast TEXT NOT NULL,
origin TEXT NOT NULL,
price_cents INTEGER NOT NULL CHECK (price_cents > 0),
tasting_notes TEXT NOT NULL,
in_stock BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX products_search_idx
ON products USING GIN (name gin_trgm_ops, origin gin_trgm_ops);
INSERT INTO products (name, roast, origin, price_cents, tasting_notes) VALUES
('Yirgacheffe Reserve', 'light', 'Ethiopia', 1850, 'jazmín, bergamota, fruta de hueso'),
('Huila Comunitario', 'medium', 'Colombia', 1600, 'manzana roja, nib de cacao, caramelo'),
('Antigua Valley Lot 7', 'medium', 'Guatemala', 1725, 'toffee, cáscara de naranja, almendra'),
('Sidamo Natural', 'light', 'Ethiopia', 1900, 'arándano, azúcar de caña, jazmín'),
('Cerrado Dark', 'dark', 'Brazil', 1400, 'chocolate negro, nuez, melaza'),
('Nyeri Peaberry AA', 'light', 'Kenya', 2100, 'grosella negra, hoja de tomate, pomelo');
Levántalo y confirma que está sano antes de apuntarle una aplicación:
docker compose up -d
docker compose ps
La aplicación es un proyecto Next.js 16 estándar con una dependencia extra, el driver de Postgres:
{
"name": "aurora-storefront",
"private": true,
"engines": { "node": ">=24" },
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^16.0.0",
"pg": "^8.13.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/pg": "^8.11.0",
"@types/react": "^19.0.0",
"typescript": "^5.7.0"
}
}
Un pool de conexiones por proceso, no un cliente por petición:
// lib/db.ts
import { Pool } from 'pg'
// En desarrollo Next.js reevalúa los módulos con cada edición, y cada recarga
// abriría un pool nuevo hasta que Postgres empezara a rechazar conexiones.
// Fijarlo en globalThis sobrevive al hot reload; en producción el módulo se
// carga una sola vez.
const globalForDb = globalThis as unknown as { auroraPool?: Pool }
export const pool =
globalForDb.auroraPool ??
new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
})
if (process.env.NODE_ENV !== 'production') {
globalForDb.auroraPool = pool
}
Y la consulta, parametrizada: el texto que escribe la persona entra como valor enlazado, nunca concatenado dentro del SQL.
// lib/products.ts
import { cache } from 'react'
import { pool } from './db'
export type Product = {
id: number
name: string
roast: string
origin: string
priceCents: number
tastingNotes: string
}
type ProductRow = {
id: number
name: string
roast: string
origin: string
price_cents: number
tasting_notes: string
}
// cache() de React memoiza durante una sola petición, así que dos componentes
// que hagan la misma pregunta consultan Postgres una vez.
export const searchProducts = cache(
async (query: string): Promise<Product[]> => {
const { rows } = await pool.query<ProductRow>(
`SELECT id, name, roast, origin, price_cents, tasting_notes
FROM products
WHERE in_stock
AND ($1 = '' OR name ILIKE '%' || $1 || '%' OR origin ILIKE '%' || $1 || '%')
ORDER BY name
LIMIT 50`,
[query],
)
return rows.map((row) => ({
id: row.id,
name: row.name,
roast: row.roast,
origin: row.origin,
priceCents: row.price_cents,
tastingNotes: row.tasting_notes,
}))
},
)
Ese rows.map no es ceremonia. Vuelve a él en la sección de tropiezos, porque hace más de lo que parece.
La página de búsqueda: la URL es tu estado
Ahora, la pregunta que dejó abierta la primera sección. Si el Server Component no se vuelve a renderizar, ¿qué hace que ocurra una búsqueda nueva?
Una navegación. Y el estado que la dispara vive en la URL.
// app/products/page.tsx
import { Suspense } from 'react'
import { SearchInput } from './search-input'
import { ProductResults } from './product-results'
import { ResultsSkeleton } from './results-skeleton'
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
}) {
const { q = '' } = await searchParams
return (
<main>
<h1>Aurora Coffee Co.</h1>
<SearchInput initialQuery={q} />
<Suspense key={q} fallback={<ResultsSkeleton />}>
<ProductResults query={q} />
</Suspense>
</main>
)
}
Hay dos detalles ahí que merecen una pausa.
searchParams es una promesa y hay que esperarla con await. Esto sorprende a quien viene de código más antiguo del App Router, donde era un objeto normal. Pasó a ser promesa en Next.js 15 para que el framework pueda empezar a renderizar las partes estáticas de la página antes de conocer la query string. Next.js 16 además genera un helper PageProps — PageProps<'/products'> tipa params y searchParams a partir de la ruta —, pero el tipo explícito de arriba es el que sobrevive a un copiar y pegar en un proyecto recién creado.
Y useState desapareció de la página. La búsqueda vive en ?q=, y de ahí salen gratis varias cosas: el botón de atrás funciona, la URL se puede compartir, recargar devuelve los mismos resultados y el servidor puede renderizar la página para un crawler sin que intervenga JavaScript. En una SPA cada una de esas cosas se construye a mano. Aquí son consecuencia de dónde pusiste el estado.
El componente de resultados es el asíncrono del principio, con el estado vacío ya resuelto:
// app/products/product-results.tsx
import { searchProducts } from '@/lib/products'
export async function ProductResults({ query }: { query: string }) {
const products = await searchProducts(query)
if (products.length === 0) {
return <p>Nada coincide con esa búsqueda. Prueba con un origen, como Ethiopia.</p>
}
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<strong>{product.name}</strong> — {product.origin}, tueste {product.roast}
<span>{(product.priceCents / 100).toFixed(2)} USD</span>
<p>{product.tastingNotes}</p>
</li>
))}
</ul>
)
}
El límite entre servidor y cliente, y qué significa "use client" en realidad
Escribir en un campo de búsqueda es interacción, y la interacción es trabajo del cliente. Así que un componente sí tiene que ejecutarse en el navegador:
// app/products/search-input.tsx
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
export function SearchInput({ initialQuery }: { initialQuery: string }) {
const router = useRouter()
const searchParams = useSearchParams()
const [value, setValue] = useState(initialQuery)
useEffect(() => {
// Ya están sincronizados; esto evita además una navegación de más al montar.
if (value === (searchParams.get('q') ?? '')) return
// Con retardo, para no lanzar una navegación por cada tecla.
const timeout = setTimeout(() => {
const next = new URLSearchParams(searchParams)
if (value) next.set('q', value)
else next.delete('q')
router.replace(`?${next.toString()}`, { scroll: false })
}, 300)
return () => clearTimeout(timeout)
}, [value, router, searchParams])
return (
<input
type="search"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Busca por nombre u origen"
aria-label="Buscar productos"
/>
)
}
Sí, todavía hay un useEffect. Es el único que queda en toda la funcionalidad, y fíjate en lo que hace: sincroniza el estado local del input con la URL. No pide datos, no lleva el control de la carga, y no hay carrera que perder, porque la navegación más nueva es la que se renderiza.
Esa es la forma honesta de una aplicación con RSC. No es "cero componentes de cliente": son menos, y hacen menos.
No significa "solo en el cliente"
La directiva lleva el nombre de la mitad de su comportamiento que no describe. Un componente marcado con "use client" igual se renderiza a HTML en el servidor para la carga inicial, y después se hidrata en el navegador. No es solo-cliente, es también-cliente.
Y eso tiene una consecuencia práctica: el código de un componente de cliente igual se ejecuta una vez en un proceso de Node donde window no existe. Tocar localStorage a nivel de módulo va a romper el build igual que lo rompía en la época de getServerSideProps.
Es un límite, no una marca de archivo
Este es el error más caro, y las costumbres de SPA llevan directo a él.
"use client" no marca un archivo. Marca un punto de entrada, y todo lo que se importe desde ahí hacia abajo entra al bundle del cliente con él. Pon la directiva arriba de un componente que importa otros tres y los cuatro pasan a ser componentes de cliente, estuvieran marcados o no.
Así que el impulso de pegarla en todos los archivos "por si acaso" convierte el árbol entero otra vez en una SPA, con un paso extra de renderizado en servidor y ninguna de las ventajas. El build sigue pasando. Nada te avisa. Simplemente no obtienes aquello por lo que actualizaste.
La regla que lo evita: empuja la directiva hacia abajo, lo más cerca posible de la interacción. SearchInput es un campo de texto y nada más, y por eso es su propio archivo.
Los archivos barril merecen una advertencia aparte. Un "use client" arriba de un index.ts que reexporta veinte componentes cruza los veinte al lado del cliente, incluidos los quince que solo pintan marcado estático.
Cómo mantener un Server Component dentro de un árbol de cliente
Este es el patrón de composición que disuelve lo que parecía una restricción rígida, y es lo más útil de todo el artículo.
Un componente de cliente no puede importar un Server Component. Pero sí puede renderizar uno que le llegue como prop. El hijo se renderiza primero en el servidor, y lo que se entrega es el resultado terminado.
// app/products/collapsible-panel.tsx
'use client'
import { useState, type ReactNode } from 'react'
export function CollapsiblePanel({
title,
children,
}: {
title: string
children: ReactNode
}) {
const [isOpen, setIsOpen] = useState(false)
return (
<section>
<button onClick={() => setIsOpen((open) => !open)} aria-expanded={isOpen}>
{title}
</button>
{isOpen ? children : null}
</section>
)
}
Usado desde un Server Component:
// app/products/page.tsx (fragmento)
import { CollapsiblePanel } from './collapsible-panel'
import { ProductResults } from './product-results'
export default async function Page() {
return (
<CollapsiblePanel title="Orígenes de Etiopía">
{/* Se renderiza en el servidor. El panel recibe el resultado, no el componente. */}
<ProductResults query="ethiopia" />
</CollapsiblePanel>
)
}
CollapsiblePanel guarda estado y atiende clics. ProductResults consulta Postgres. Ninguno de los dos tiene que ceder, y el driver de la base de datos jamás se acerca al bundle del navegador. Una vez que entiendes esto, la mayoría de los problemas del tipo "necesito interactividad alrededor de mis datos" se resuelven con un prop children.
Streaming: el estado de carga que no escribes
Vuelve al <Suspense> de la página. Hace bastante más que mostrar un spinner.
Sin él, el servidor retiene la respuesta entera hasta que termina la consulta a Postgres, y la persona se queda mirando una pestaña en blanco. Con él, Next.js envía de inmediato el armazón — el título, el campo de búsqueda, el esqueleto —, mantiene la conexión abierta y envía los resultados cuando la consulta responde. Una sola petición, entregada por partes.
// app/products/results-skeleton.tsx
export function ResultsSkeleton() {
return (
<ul aria-busy="true">
{Array.from({ length: 5 }, (_, index) => (
<li key={index} className="skeleton-row" />
))}
</ul>
)
}
La key del boundary de Suspense es la parte que se pasa por alto. Sin ella, el fallback aparece en la primera carga y nunca más: las búsquedas siguientes dejan los resultados anteriores en pantalla, quietos, hasta que llegan los nuevos a reemplazarlos. Poner como key la búsqueda actual vuelve a montar el boundary, y así cada búsqueda muestra el esqueleto. Es un arreglo de una palabra para un bug que llega al tablero descrito como "la página se siente rota".
Cuatro tropiezos típicos de quien viene de SPA
Los props que cruzan el límite tienen que ser serializables. Todo lo que un Server Component pasa a un componente de cliente se serializa y viaja por la red. Cruzan las cadenas, los números, los objetos planos, los arrays, Date, Map, Set y las promesas. No cruzan las funciones, las instancias de clase ni nada que sostenga una conexión a la base de datos. Por eso searchProducts convierte las filas en objetos planos en vez de devolver las del driver tal cual: una fila de pg se parece lo suficiente a un objeto plano como para pasarla sin pensarlo, y la instancia de un modelo de ORM no, y ese "se parece lo suficiente" es justo donde viven los errores confusos en tiempo de ejecución.
La cascada no desapareció, cambió de sitio. Los await en secuencia dentro de un Server Component son una cascada del lado del servidor: más rápida que la del navegador, porque la base de datos está al lado, pero siguen siendo dos idas y vueltas donde bastaba una.
// En secuencia: la segunda consulta espera a que termine la primera.
const product = await getProduct(id)
const related = await getRelatedProducts(id)
// En paralelo: las dos salen antes de esperar ninguna.
const productPromise = getProduct(id)
const relatedPromise = getRelatedProducts(id)
const [product, related] = await Promise.all([productPromise, relatedPromise])
Esas cascadas simplemente dejaron de verse en la pestaña de red, que es exactamente lo que hace fácil publicarlas sin notarlas.
El estado del cliente sobrevive a un render del servidor. Pasar de ?q=ethiopia a ?q=kenya vuelve a renderizar la página en el servidor y aplica el resultado encima, pero el estado de los componentes de cliente que están dentro de ese árbol sobrevive, porque React reconcilia en vez de recargar. Casi siempre es lo que quieres. Sorprende la primera vez que un panel de filtros conserva su estado abierto entre búsquedas, y sorprende más el día en que contabas con que no lo hiciera.
Ahora puedes leer secretos, que es el objetivo y también el riesgo nuevo. Leer process.env.DATABASE_URL dentro de un Server Component es correcto y seguro. Esa misma línea en un archivo que más adelante gana una directiva "use client", o que termina importado por uno, es una credencial dentro de un bundle de JavaScript. Next.js detecta los casos evidentes al compilar; lo que no puede detectar es un secreto que pasaste como prop. Mantén el acceso a la base de datos en módulos que un componente de cliente no tenga motivo para importar y la pregunta deja de aparecer.
Cuándo encajan los Server Components, y cuándo la SPA sigue teniendo razón
Rinden cuando la página es sobre todo contenido armado con datos tuyos: catálogos, dashboards, documentación, resultados de búsqueda, cualquier cosa donde importe el primer pintado o donde un crawler tenga que ver HTML real. Rinden todavía más cuando tu capa de API existe solo para alimentar tu propio frontend, porque esa capa deja de hacer falta y se lleva consigo su serialización, su versionado y sus discusiones sobre la forma de los errores.
Son la herramienta equivocada en varios casos, y conviene decirlo. Un editor de canvas, una hoja de cálculo, un tablero de arrastrar y soltar: cualquier cosa cuyo estado cambie decenas de veces por segundo pertenece al cliente, y envolverla en un armazón de servidor no aporta nada. Si una aplicación móvil o una integración de terceros ya consume tu API, esa API no es andamio y borrarla no está sobre la mesa. Las aplicaciones offline-first necesitan que el cliente sea la fuente de verdad, por definición. Y una exportación estática sin runtime de Node no puede renderizar bajo demanda, lo que descarta el modelo por completo.
La respuesta real más frecuente no es ninguna de las dos, sino las dos juntas: un armazón renderizado en el servidor alrededor de una isla genuinamente interactiva. Para eso sirve el patrón de children de más arriba, y se vive mejor ahí que en cualquiera de los dos extremos.
Falta algo a propósito: las mutaciones. Escribir datos de vuelta — Server Actions, envío de formularios, actualizaciones optimistas — es un modelo aparte, con sus propios modos de fallo, y merece su propio artículo en vez de tres párrafos al final de este.
Puntos clave
Un Server Component se ejecuta una vez, en el servidor, y nunca se vuelve a renderizar. Todas las restricciones — sin estado, sin efectos, sin manejadores de eventos — salen de ese único hecho. Dedúcelas de ahí en vez de memorizar una lista.
El estado de la página va en la URL. Cambiar useState por searchParams es lo que hace que ocurra un render nuevo, y de paso te regala enlaces que se pueden compartir, un botón de atrás que funciona y páginas que un crawler puede leer.
La directiva "use client" marca un límite, no un archivo. Todo lo que se importe por debajo entra al bundle del cliente. Pegarla en todas partes por precaución reconstruye tu SPA con pasos de más, y nada te avisa.
Pasa los Server Components a través de los componentes de cliente como children. Un componente de cliente no puede importar uno, pero sí puede renderizar el que le entreguen. Eso resuelve la mayoría de los casos de "necesito interactividad alrededor de mis datos".
Las cascadas y los secretos filtrados no se fueron, cambiaron de lugar. Los await en secuencia siguen siendo cascadas, ahora invisibles en la pestaña de red, y una credencial sigue siendo una credencial en cuanto un archivo con "use client" la importa.
Read original: https://dev.to/jgomezdev/react-server-components-para-veteranos-de-spa-g0l
← Previous
My Baseline Browser
Next →
How to prepare your website for Black Friday: the 12-week plan
Related
Preparing a Markdown document for a technical slide deck with Gamma App
Frontend
0
DEV Community
Upgrade Skill Development Kamu dengan Menjelajahi Fitur Keren di Tencent EdgeOne Makers
Frontend
0
DEV Community
Two and a half months with an intruder in our repositories
Frontend
1
DEV Community
12 Pitfalls I Hit Auto-Logging Claude Code Subagents with a Stop Hook (and How the Numbers Cut My Weekly Cost 15–20%)
Frontend
3
Dev.to (EN Zone)
Comments0
No comments yet — be the first