Decisiones de Arquitectura en React y Next.js
Escenarios comunes donde los desarrolladores de React y Next.js deben elegir entre patrones, hooks o enfoques arquitectónicos. Cada decisión presenta la mejor, segunda y tercera opción, más la opción incorrecta que los desarrolladores comúnmente eligen.
Decisión 1: Fetching de datos para una página que muestra una lista de productos
Escenario: Necesitas cargar datos de productos en una página de Next.js.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Server Component asincronía | async function ProductsPage() { const products = await db.products.findMany(); return <ProductList products={products} />; } |
| 2da | Route handler + server fetch | Crea un route handler GET y fetching desde un Server Component. Funciona pero añade un salto de red innecesario. |
| 3ra | getServerSideProps (Pages Router) | Sigue funcionando si estás en Pages Router, pero pierdes beneficios de streaming y RSC. |
Opción incorrecta: Usar useEffect + fetch en un Client Component. Añade un waterfall cliente-servidor, expone tu API y daña SEO porque el contenido no está en el HTML inicial.
Por qué la mejor es la mejor: Los Server Components asincronía fetching de datos con cero JS del cliente, flujo HTML progresivamente, y acceden a la base de datos directamente sin una capa API.
Decisión 2: Gestión de envío de formulario con validación del lado del servidor
Escenario: Un usuario envía un formulario de contacto que necesita validación del servidor y visualización de errores.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Server Action + useActionState | Vincula un Server Action a <form action={...}> y usa useActionState para estado pendiente/error. |
| 2da | Server Action + estado manual | Llama la acción vía startTransition y gestiona tu propio estado con useState. Más boilerplate pero control total. |
| 3ra | API route + client fetch | POST a un route handler desde un manejador onSubmit. Funciona pero omite la mejora progresiva. |
Opción incorrecta: Validación solo del lado del cliente sin validación del servidor. Nunca confíes en el cliente -- los atacantes omiten tu UI.
Por qué la mejor es la mejor: useActionState te da estados pendiente, error y datos automáticamente, funciona sin JS (mejora progresiva), y mantiene la lógica de validación en el servidor.
Decisión 3: Compartir layout de UI entre múltiples rutas
Escenario: Tu dashboard tiene un sidebar y encabezado que deben persistir en /dashboard/analytics, /dashboard/settings, etc.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | layout.tsx anidado | Coloca un layout.tsx en app/dashboard/ con UI compartido. Las rutas hijo se renderizan dentro de {children}. |
| 2da | Archivo template | Usa template.tsx en su lugar. Mismo anidamiento, pero se remonta en navegación (útil para animaciones). |
| 3ra | Componente wrapper | Importa un wrapper <DashboardShell> en cada página. Funciona pero duplica el import y rompe la preservación automática de layout. |
Opción incorrecta: Poner lógica de layout en _app.tsx (Pages Router) o un proveedor de contexto que re-renderiza todo el árbol. Esto derrota el propósito de layouts anidados.
Por qué la mejor es la mejor: layout.tsx se preserva entre navegaciones -- el sidebar no se re-renderiza cuando cambias de pestaña, preservando el estado de desplazamiento y evitando trabajo innecesario.
Decisión 4: Mostrar estado de carga mientras se carga una página
Escenario: Tu página de dashboard fetching datos de análisis pesados y necesitas un esqueleto de carga.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | loading.tsx | Añade un archivo loading.tsx junto a page.tsx. Next.js auto-envuelve la página en un límite <Suspense> con tu UI de carga. |
| 2da | <Suspense> manual | Envuelve componentes asincronía específicos en <Suspense fallback={<Skeleton />}> para control granular. |
| 3ra | Estado de carga del lado del cliente | Usa useState(true) y establece false después de que el fetch de useEffect se complete. |
Opción incorrecta: No mostrar nada (pantalla en blanco) mientras se cargan datos. Los usuarios piensan que la app está rota después de ~300ms sin retroalimentación.
Por qué la mejor es la mejor: loading.tsx es automático, requiere cero JS del cliente, y permite navegación instantánea vía la arquitectura de streaming de React.
Decisión 5: Elegir entre useState y useReducer
Escenario: Un componente gestiona un formulario de múltiples pasos con 8 campos, estado de validación y navegación de pasos.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | useReducer | Define acciones como SET_FIELD, NEXT_STEP, VALIDATE. Las transiciones de estado son explícitas y testables. |
| 2da | useState con un objeto | const [form, setForm] = useState({...}). Más simple pero las actualizaciones basadas en spread se vuelven desordenadas con lógica compleja. |
| 3ra | Múltiples llamadas useState | Una por campo. Está bien para formularios simples pero 8+ llamadas useState son difíciles de coordinar. |
Opción incorrecta: Alcanzar una biblioteca de estado global (Redux, Zustand) para estado de formulario que solo vive en un componente. Massivo overkill.
Por qué la mejor es la mejor: useReducer centraliza transiciones de estado complejas en una función pura que puedes hacer pruebas unitarias independientemente de React.
Decisión 6: Pasar datos profundamente a través del árbol de componentes
Escenario: Tema, locale y preferencias del usuario deben ser accesibles 5+ niveles de profundidad.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | React Context | Crea un proveedor de contexto cerca de la parte superior. Los consumidores leen vía useContext o React 19's use(ThemeContext). |
| 2da | Composición de componentes | Pasa los datos como children o render props para evitar que componentes intermedios necesiten la prop. |
| 3ra | Prop drilling | Pasa props a través de cada nivel. Tedioso pero explícito y fácil de rastrear. |
Opción incorrecta: Instalar una biblioteca de gestión de estado solo para evitar prop drilling. Context es built-in y suficiente para datos de lectura pesada, raramente cambiantes como temas.
Por qué la mejor es la mejor: Context es cero dependencias, construido en React, y optimizado para datos que cambian con poca frecuencia pero se leen ampliamente.
Decisión 7: UI optimista para un botón de like
Escenario: El usuario hace clic en "like" y quieres que la UI se actualice instantáneamente antes de que el servidor confirme.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | useOptimistic | const [optimisticLikes, addOptimistic] = useOptimistic(likes, (state, newLike) => [...state, newLike]); |
| 2da | Optimista manual con useState | Establece estado inmediatamente, revierte en error en un try/catch. Más código, misma idea. |
| 3ra | onMutate de React Query | Actualización optimista vía callbacks de mutación. Bueno pero tira en una biblioteca para algo que React 19 hace nativamente. |
Opción incorrecta: Esperar respuesta del servidor antes de actualizar la UI. El retraso de 200-500ms hace que la app se sienta lenta.
Por qué la mejor es la mejor: useOptimistic automáticamente revierte al estado real cuando el Server Action se completa o falla -- sin lógica de rollback manual necesaria.
Decisión 8: Manejo de errores para un segmento de ruta específico
Escenario: La página /dashboard/billing puede fallar (errores de API de pago) y necesita un fallback elegante.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | error.tsx | Añade error.tsx junto a la página. Captura errores de renderizado y datos, ofrece un botón de reintentar vía reset(). |
| 2da | Componente error boundary | Envuelve en un <ErrorBoundary> personalizado para más control sobre tipos de error y reportes. |
| 3ra | Try/catch en Server Component | Captura errores en la función asincronía y renderiza UI fallback inline. Sin reintentos automáticos. |
Opción incorrecta: Dejar que los errores se propaguen a una página de error global que reemplaza todo el layout. El usuario pierde todo contexto y estado de navegación.
Por qué la mejor es la mejor: error.tsx está orientado al segmento de ruta -- el resto del layout permanece interactivo, y reset() reintenta renderizado sin recarga completa de página.
Decisión 9: Cuándo añadir "use client"
Escenario: Un componente muestra una tarjeta de producto con nombre, precio, imagen y un botón "Añadir al Carrito".
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Dividir: tarjeta Server + botón Client | Mantén ProductCard como un Server Component. Extrae <AddToCartButton> como un Client Component. |
| 2da | Tarjeta entera como Client Component | Añade "use client" a la tarjeta. Simple pero envía más JS del necesario. |
| 3ra | Server Component con wrapper de hidratación del lado del cliente | Envuelve la parte interactiva con un límite de hidratación genérico. Over-engineered para un botón. |
Opción incorrecta: Añadir "use client" a cada componente "solo para estar seguro." Esto derrota beneficios de RSC y envía JavaScript innecesario.
Por qué la mejor es la mejor: Empuja el límite "use client" lo más bajo posible. Solo el botón interactivo necesita JS del cliente -- la tarjeta, imagen y texto se renderizan con cero JS.
Decisión 10: Estrategia de caching y revalidación para un blog
Escenario: Los posts del blog se actualizan con poca frecuencia. Quieres cargas rápidas pero contenido fresco dentro de minutos.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | ISR con revalidate | export const revalidate = 60; en tu página o layout. Sirve HTML en caché, revalida en background. |
| 2da | Revalidación bajo demanda | Llama revalidatePath('/blog/[slug]') o revalidateTag('posts') desde un webhook CMS. Frescura instantánea. |
| 3ra | Totalmente estático (generateStaticParams) | Pre-renderiza todos los posts en construcción. Rápido pero obsoleto hasta el siguiente despliegue. |
Opción incorrecta: Hacer la página de blog completamente dinámica (export const dynamic = 'force-dynamic'). Sin caching significa cada solicitud golpea la base de datos -- lento y desperdiciador para contenido que apenas cambia.
Por qué la mejor es la mejor: ISR basado en tiempo es cero-config, sirve contenido obsoleto mientras revalida, y balancea frescura con rendimiento automáticamente.
Decisión 11: Protegiendo una ruta de usuarios no autenticados
Escenario: /dashboard solo debe ser accesible a usuarios logged-in.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Middleware | Verifica la sesión en middleware.ts y redirige antes de que la página se renderice. Cero flash de layout. |
| 2da | Verificación de Server Component | Verifica auth en el Server Component de la página y llama redirect('/login'). Funciona pero el layout puede parpadearse. |
| 3ra | Guard del lado del cliente | Verifica auth en useEffect y redirige. Muestra contenido protegido brevemente antes de redireccionar. |
Opción incorrecta: Solo ocultar el link de navegación a /dashboard. Seguridad por obscuridad -- cualquiera con la URL puede acceder a la página.
Por qué la mejor es la mejor: Middleware se ejecuta en el edge antes de cualquier renderizado. El usuario nunca ve un flash de contenido protegido, y la verificación está centralizada para todas las rutas protegidas.
Decisión 12: Almacenando estado global del cliente (carrito de compras, toggles de UI)
Escenario: Un carrito necesita persistir entre navegaciones de página y ser accesible desde el encabezado y páginas de productos.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Zustand | Ligero, sin boilerplate. const useCart = create((set) => ({ items: [], add: (item) => set(...) })). |
| 2da | React Context + useReducer | Built-in, sin dependencia. Bastante bueno para apps pequeñas pero re-renderiza todos los consumidores en cualquier cambio. |
| 3ra | Redux Toolkit | Poderoso pero pesado para un carrito. Vale la pena solo si la app ya usa Redux para otro estado. |
Opción incorrecta: Almacenar estado del carrito en un Server Component o enfoque solo-cookie sin reactividad del cliente. La UI no se actualizará cuando se añadan items sin recarga completa de página.
Por qué la mejor es la mejor: Zustand es ~1KB, no requiere proveedores, soporta selectores para evitar re-renders innecesarios, y funciona naturalmente con características concurrentes de React 18/19.
Decisión 13: Manejando fetches de datos paralelos en un dashboard
Escenario: Una página dashboard necesita datos de usuario, análisis y notificaciones -- tres llamadas API independientes.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Paralelo asincronía en Server Component | const [user, analytics, notifs] = await Promise.all([getUser(), getAnalytics(), getNotifs()]); |
| 2da | Límites <Suspense> paralelos | Envuelve cada sección en su propio <Suspense>. Se fluyen independientemente conforme se resuelven. |
| 3ra | Awaits secuenciales | const user = await getUser(); const analytics = await getAnalytics(); -- simple pero crea un waterfall. |
Opción incorrecta: Fetching de los tres en un único useEffect secuencialmente. Tres waterfalls más renderizado del lado del cliente hace que el dashboard se sienta dolorosamente lento.
Por qué la mejor es la mejor: Promise.all dispara las tres solicitudes simultáneamente. Tiempo total de espera = solicitud más lenta, no la suma de las tres.
Decisión 14: Enfoque de estilo para un nuevo proyecto Next.js
Escenario: Iniciando una app Next.js greenfield y eligiendo una solución de estilo.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Tailwind CSS | Utilidad-primero, cero runtime, funciona perfectamente con Server Components, ecosistema enorme (shadcn/ui). |
| 2da | CSS Modules | Estilos orientados, sin runtime, built-in a Next.js. Bueno para equipos que prefieren CSS tradicional. |
| 3ra | Vanilla Extract | CSS-in-TS type-safe con cero runtime. Más setup pero excelente para equipos de sistema de diseño. |
Opción incorrecta: Usar una biblioteca CSS-in-JS con runtime (styled-components, Emotion) con Server Components. Requieren renderizado del lado del cliente y rompen streaming RSC.
Por qué la mejor es la mejor: Tailwind tiene costo cero de runtime, funciona con RSC, y combinado con shadcn/ui proporciona componentes accesibles listos para producción fuera de la caja.
Decisión 15: Optimización de imágenes
Escenario: Tu sitio de e-commerce tiene cientos de imágenes de productos que necesitan ser rápidas y responsivas.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | next/image | <Image src={url} width={400} height={300} alt="..." /> -- auto-optimiza formato, tamaño, y lazy loads. |
| 2da | CDN con <picture> responsivo | <picture> manual con srcSet y un CDN como Cloudinary. Control total pero más trabajo. |
| 3ra | <img> plano con lazy loading | <img loading="lazy" /> -- sin optimización, sizing manual, CLS potencial. |
Opción incorrecta: Usar tags <img> no optimizados con imágenes de fuente resolución completa. Las páginas cargan megabytes de imágenes, matando Core Web Vitals.
Por qué la mejor es la mejor: next/image automáticamente sirve WebP/AVIF, cambia tamaño para ancho de dispositivo, lazy loads debajo del fold, y previene CLS con dimensiones requeridas.
Decisión 16: Patrón de acceso a base de datos en una app Next.js
Escenario: Tu app necesita consultar una base de datos PostgreSQL desde código del lado del servidor.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Prisma en Server Components/Actions | Consulta directamente: const users = await prisma.user.findMany(). Type-safe, sin capa API necesaria. |
| 2da | Drizzle ORM | Más ligero, sintaxis similar a SQL, excelente inferencia de TypeScript. Excelente para equipos que prefieren SQL. |
| 3ra | SQL crudo vía pg o postgres | Control máximo, sin overhead de ORM. Bueno para consultas complejas pero sin type safety sin herramientas extra. |
Opción incorrecta: Exponer consultas de base de datos a través de API routes y fetching desde Client Components. Añade latencia, complejidad, y superficie de ataque sin beneficio cuando RSC puede consultar directamente.
Por qué la mejor es la mejor: Prisma en Server Components significa consultas type-safe con cero exposición de cliente, pooling de conexión automático, y migraciones built-in.
Decisión 17: Estado de URL vs. estado de React para filtros
Escenario: Una página de listado de productos tiene filtros (categoría, rango de precio, orden) que los usuarios quieren compartir y marcar.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Parámetros de búsqueda (useSearchParams + Server Component) | Lee parámetros en un Server Component: searchParams.category. Filtra en el servidor, devuelve solo productos coincidentes. |
| 2da | Biblioteca nuqs | Gestión de estado URL type-safe con useQueryState. Maneja serialización y valores por defecto elegantemente. |
| 3ra | useState del lado del cliente | Actualizaciones rápidas de UI pero filtros se pierden en recarga, no pueden ser compartidos, y daña SEO. |
Opción incorrecta: Almacenar filtros en useState solo. Los usuarios no pueden compartir vistas filtradas, el botón atrás no funciona, y los motores de búsqueda no pueden indexar páginas filtradas.
Por qué la mejor es la mejor: Los parámetros de búsqueda de URL hacen filtros compartibles, bookmarkables, SSR-friendly, y el servidor puede optimizar la consulta de base de datos basada en filtros.
Decisión 18: Manejando un modal/diálogo
Escenario: Hacer clic en "Editar Perfil" debe abrir un overlay modal.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Ruta de intercepción + ruta paralela | Dos características de Next.js se combinan: una ruta paralela (@modal) renderiza un slot junto a la página, y una ruta de intercepción ((.)edit-profile) captura la navegación del lado del cliente y la renderiza dentro de ese slot como un modal en lugar de una página completa. Ver estructura de archivo y explicación abajo. |
| 2da | Client Component con <dialog> | Usa elemento <dialog> nativo con useRef. Accesible, sin biblioteca necesaria. |
| 3ra | Headless UI / Radix Dialog | Foco trap, animaciones y accesibilidad gestionadas por biblioteca. Confiable pero añade una dependencia. |
Cómo funciona el patrón ruta de intercepción + ruta paralela:
app/
layout.tsx ← renderiza {children} Y {modal}
@modal/
default.tsx ← devuelve null (sin modal por defecto)
(.)edit-profile/
page.tsx ← versión modal de edit-profile
edit-profile/
page.tsx ← versión página completa de edit-profile
-
Ruta paralela
@modal— La carpeta@modaldefine un "slot" nombrado. Enlayout.tsxlo renderizas como una prop:export default function Layout({ children, modal }) { return <>{children}{modal}</>; }. Por defecto renderizadefault.tsx(que devuelvenull— sin modal visible). -
Ruta de intercepción
(.)edit-profile— El prefijo(.)significa "intercepta esta ruta al mismo nivel." Cuando el usuario hace clic en un<Link href="/edit-profile">(navegación soft/del lado del cliente), Next.js coincide la ruta de intercepción dentro de@modalen lugar de la página real/edit-profile. Lo renderizas como un overlay modal. -
Navegación dura (URL directa, recarga) — Si alguien pega
yoursite.com/edit-profileen el navegador o recarga, el interceptor no se activa. Next.js renderiza elapp/edit-profile/page.tsxcompleto como una página regular. El slot modal permanecenull.
El resultado: Hacer clic en "Editar Perfil" abre un modal (rápido, sin cambio de página). Compartir la URL da al destinatario la página completa. Recargando la URL del modal también muestra la página completa. Una URL, dos presentaciones.
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}
// app/@modal/(.)edit-profile/page.tsx — versión modal
export default function EditProfileModal() {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="rounded-lg bg-white p-6 shadow-xl">
<h2>Editar Perfil</h2>
{/* campos de formulario */}
</div>
</div>
);
}
// app/edit-profile/page.tsx — fallback página completa
export default function EditProfilePage() {
return (
<main className="mx-auto max-w-lg p-8">
<h1>Editar Perfil</h1>
{/* mismo formulario, layout página completa */}
</main>
);
}Opción incorrecta: Un div con display: none/block toggled vía estado. Sin foco trapping, sin manejo de tecla escape, no accesible para lectores de pantalla, y el modal sin URL no puede ser compartido o marcado.
Por qué la mejor es la mejor: Las rutas de intercepción dan al modal una URL real -- los usuarios pueden compartirla, recargando muestra una página completa, y el modal del lado del cliente evita una navegación completa. Es el único patrón que proporciona dos presentaciones (modal vs. página) desde una única URL con cero gestión de estado extra.
Decisión 19: Búsqueda del lado del servidor vs. del lado del cliente
Escenario: Tu app necesita una característica de búsqueda para un catálogo de productos con 50,000+ items.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Búsqueda del lado del servidor vía parámetros de búsqueda | Debounce de entrada, empuja a URL: router.push(?q=term). Server Component consulta la base de datos con ILIKE o búsqueda full-text. |
| 2da | Servicio de búsqueda dedicado | Usa Algolia, Meilisearch, o Elasticsearch vía un Server Action. Mejor relevancia y tolerancia de typos. |
| 3ra | Filtro del lado del cliente con useMemo | Carga todos los productos y filtra del lado del cliente. Solo viable para conjuntos de datos pequeños (bajo 500 items). |
Opción incorrecta: Cargar 50,000 productos en memoria del cliente y filtrar con .filter(). Cuelga navegadores móviles y desperdicia ancho de banda.
Por qué la mejor es la mejor: La búsqueda del lado del servidor aprovecha índices de base de datos, envía solo resultados coincidentes por la red, y mantiene la consulta de búsqueda en la URL para compartir.
Decisión 20: Cuándo usar useMemo y useCallback
Escenario: Un componente renderiza una lista filtrada derivada de props y pasa un manejador a componentes hijo.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Usa React Compiler (React 19) | Habilita el React Compiler -- auto-memoiza. Sin useMemo/useCallback manual necesario. |
| 2da | useMemo/useCallback orientado | Memoiza solo la computación cara y el callback pasado a hijos envueltos en memo(). |
| 3ra | Memoizar todo | Envuelve cada valor derivado y manejador. Añade overhead de memoria y complejidad para ganancias marginales. |
Opción incorrecta: Nunca memoizar, incluso cuando has medido un problema de rendimiento. Si perfilado muestra re-render de 200ms desde un filtro caro, useMemo es la corrección correcta.
Por qué la mejor es la mejor: El React Compiler analiza estáticamente tu código e inserta memoización exactamente donde se necesita -- sin esfuerzo de desarrollador, sin oportunidades perdidas, sin sobre-memoización.
Decisión 21: Manejando configuración específica del ambiente
Escenario: Tu app necesita diferentes URLs de API para desarrollo, staging y producción.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | .env.local + prefijo NEXT_PUBLIC_ | Variables solo-servidor en .env.local. Variables expuestas al cliente prefijadas con NEXT_PUBLIC_. Next.js maneja el resto. |
| 2da | Variables de ambiente de plataforma | Establece variables en dashboard Vercel/AWS. Misma convención, gestionada externamente. |
| 3ra | Archivo de config con switch de ambiente | config.ts con verificaciones process.env.NODE_ENV. Funciona pero duplica lo que archivos .env ya hacen. |
Opción incorrecta: Hardcodificar URLs de API o cometer archivos .env con secretos a git. Los secretos escapan, el switch de ambiente se rompe.
Por qué la mejor es la mejor: La convención .env de Next.js es built-in, soporta sobrescrituras por-ambiente (.env.production), y el prefijo NEXT_PUBLIC_ hace el límite servidor/cliente explícito.
Decisión 22: Implementando paginación
Escenario: Una tabla de admin muestra 10,000 registros de usuario y necesita paginación.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Paginación del lado del servidor vía parámetros de búsqueda | Página y límite viven en la URL (?page=2&limit=20). El Server Component los lee, consulta la base de datos con OFFSET/LIMIT (o equivalente), y devuelve solo esa página de filas. Ver explicación y código abajo. |
| 2da | Paginación basada en cursor | Usa ?cursor=abc123 para paginación estable en datos que cambian frecuentemente. Mejor para feeds en tiempo real. |
| 3ra | Paginación del lado del cliente | Fetching de todos los datos, paginación en memoria. Solo funciona para conjuntos de datos pequeños. |
Cómo funciona paginación del lado del servidor vía parámetros de búsqueda:
-
La URL es la fuente de verdad — La página actual y tamaño de página son parámetros de búsqueda (
?page=2&limit=20). Esto significa el estado de paginación es compartible, bookmarkable, y sobrevive recarga. Los botones atrás/adelante navegan páginas gratis. -
Server Component lee parámetros y consulta — El
page.tsxrecibesearchParamscomo prop. CalculaOFFSETyLIMIT, consulta solo ese slice de la base de datos, y también fetching el conteo total para controles de página. -
Client Component maneja navegación — Una pequeña barra de paginación
"use client"usauseRoutero<Link>para actualizar parámetros de búsqueda. Sin lógica de fetching de datos en el cliente. -
Streaming funciona automáticamente — Porque el fetch de datos está en un Server Component, envolviéndolo en
<Suspense>muestra un esqueleto de carga mientras la consulta se ejecuta. Navegar entre páginas fluye el nuevo contenido.
// app/admin/users/page.tsx — Server Component
import { prisma } from "@/lib/db";
import { UserTable } from "./user-table";
import { PaginationBar } from "./pagination-bar";
interface Props {
searchParams: Promise<{ page?: string; limit?: string }>;
}
export default async function UsersPage({ searchParams }: Props) {
const { page: pageStr, limit: limitStr } = await searchParams;
const page = Math.max(1, parseInt(pageStr ?? "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(limitStr ?? "20", 10)));
const offset = (page - 1) * limit;
// Consulta única para esta página + conteo total
const [users, total] = await Promise.all([
prisma.user.findMany({ skip: offset, take: limit, orderBy: { createdAt: "desc" } }),
prisma.user.count(),
]);
const totalPages = Math.ceil(total / limit);
return (
<div>
<h1>Usuarios ({total})</h1>
<UserTable users={users} />
<PaginationBar currentPage={page} totalPages={totalPages} />
</div>
);
}// app/admin/users/pagination-bar.tsx — Client Component
"use client";
import Link from "next/link";
interface PaginationBarProps {
currentPage: number;
totalPages: number;
}
export function PaginationBar({ currentPage, totalPages }: PaginationBarProps) {
return (
<div className="flex items-center gap-2 py-4">
<Link
href={`?page=${currentPage - 1}`}
className={`rounded border px-3 py-1 ${currentPage <= 1 ? "pointer-events-none opacity-50" : ""}`}
>
Anterior
</Link>
<span className="text-sm">
Página {currentPage} de {totalPages}
</span>
<Link
href={`?page=${currentPage + 1}`}
className={`rounded border px-3 py-1 ${currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}`}
>
Siguiente
</Link>
</div>
);
}Puntos clave: La base de datos solo devuelve 20 filas por solicitud (no 10,000). La URL te dice exactamente qué página estás en. Cambiar páginas es una navegación del servidor -- sin código de fetching de datos del lado del cliente. <Link> con parámetros de búsqueda te da prefetching y streaming gratis.
Opción incorrecta: Scroll infinito que fetching todos los 10,000 registros en memoria. El navegador se cuelga, pesadilla de accesibilidad, y los usuarios no pueden saltar a página 50.
Por qué la mejor es la mejor: La paginación del lado del servidor pone estado de página en la URL (compartible, bookmarkable), fetching solo el slice necesario de la base de datos, fluye HTML vía RSC, y requiere cero lógica de fetching de datos del lado del cliente.
Decisión 23: Elegir entre Server Actions y API Routes
Escenario: Un formulario necesita crear un nuevo registro en la base de datos.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Server Action | Función "use server" llamada desde <form action={...}>. Type-safe, mejorada progresivamente, sin endpoint manual. |
| 2da | API Route (Route Handler) | app/api/records/route.ts con un manejador POST. Mejor cuando clientes externos (apps móviles, webhooks) necesitan el endpoint. |
| 3ra | tRPC | Type-safety end-to-end con contrato cliente/servidor. Poderoso pero pesado si Server Actions cubren tus necesidades. |
Opción incorrecta: Crear rutas API solo para mutaciones usadas por tu propio frontend Next.js. Server Actions eliminan el boilerplate de gestión de endpoints, serialización y manejo de errores.
Por qué la mejor es la mejor: Los Server Actions están co-localizados con tu UI, automáticamente manejan serialización, soportan mejora progresiva, e integran con useActionState para estados pendiente/error.
Decisión 24: Estructurando un proyecto Next.js grande
Escenario: Tu app tiene 30+ rutas entre múltiples áreas de características (auth, dashboard, settings, marketing público).
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Grupos de ruta + co-localización de características | app/(auth)/login, app/(dashboard)/analytics, app/(marketing)/pricing. Layouts compartidos por grupo. |
| 2da | Carpetas de características fuera de app/ | src/features/auth/, src/features/dashboard/ con componentes, hooks y utils. app/ solo tiene archivos de ruta delgados. |
| 3ra | Estructura app/ plana | Todas las rutas en nivel superior. Funciona para apps pequeñas pero se vuelve inmanejable en 30+ rutas. |
Opción incorrecta: Organizar por tipo de archivo (components/, hooks/, utils/) en lugar de característica. Los desarrolladores saltan entre 5 directorios para trabajar en una característica.
Por qué la mejor es la mejor: Los grupos de ruta te dejan compartir layouts y middleware por área de característica, mantener archivos relacionados juntos, y los nombres entre paréntesis no afectan la URL.
Decisión 25: Actualizaciones en tiempo real (p. ej., chat, notificaciones)
Escenario: Tu app necesita mostrar notificaciones en vivo conforme llegan.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | WebSockets (Socket.io o nativo) | Conexión persistente, bidireccional, baja latencia. Usa un Client Component para gestionar la conexión. |
| 2da | Server-Sent Events (SSE) | Empuje unidireccional servidor-a-cliente. Más simple que WebSockets, auto-reconexión, funciona a través de proxies. |
| 3ra | Polling con setInterval | Fetching cada N segundos. Simple pero desperdicia ancho de banda y tiene latencia inherente. |
Opción incorrecta: Polling cada segundo con useEffect + fetch. Golpea el servidor, drena baterías móviles, y aún tiene hasta 1 segundo de latencia.
Por qué la mejor es la mejor: WebSockets entregan mensajes instantáneamente con una única conexión persistente, soportando envío y recepción sin overhead HTTP repetido.
Decisión 26: Estrategia de pruebas para un componente React
Escenario: Un componente <CheckoutForm> maneja validación, envío y visualización de errores.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | React Testing Library + Vitest | Prueba comportamiento del usuario: rellena campos, envía, afirma que aparecen mensajes de error. render(<CheckoutForm />). |
| 2da | Playwright/Cypress E2E | Prueba el flujo completo en un navegador real. Más lento pero captura problemas de integración en la pila. |
| 3ra | Pruebas de snapshot | expect(tree).toMatchSnapshot(). Captura cambios inesperados pero no verifica comportamiento. |
Opción incorrecta: Pruebar detalles de implementación (estado interno, llamadas de método, clases CSS). Las pruebas se rompen en cada refactor incluso cuando el comportamiento es sin cambios.
Por qué la mejor es la mejor: Las pruebas RTL prueban lo que los usuarios ven y hacen -- si la prueba pasa, el componente funciona. Refactorizar internals no rompe pruebas, así que actualmente mantienen confianza.
Decisión 27: Manejando metadatos y SEO
Escenario: Cada página de producto necesita título, descripción y tags Open Graph únicos.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Función generateMetadata | Exporta async function generateMetadata({ params }) que fetching datos de producto y devuelve { title, description, openGraph }. |
| 2da | Export metadata estático | export const metadata = { title: '...' }. Bueno para páginas con metadatos fijos. |
| 3ra | <Head> desde next/head | Solo Pages Router. Del lado del cliente, no funciona con RSC. |
Opción incorrecta: No establecer metadatos en absoluto o usar el mismo título en cada página. Los motores de búsqueda no pueden diferenciar tus páginas, y los compartidos sociales se ven rotos.
Por qué la mejor es la mejor: generateMetadata se ejecuta en el servidor, puede fetching datos para generar títulos dinámicos, y se deduplica con el fetch de datos de la página vía caché fetch.
Decisión 28: Manejando cargas de archivo
Escenario: Los usuarios cargan avatares de perfil (máx 5MB) a tu app.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Carga de URL pre-firmada a S3/R2 | Server Action genera una URL pre-firmada. Cliente carga directamente a almacenamiento. Sin presión de memoria del servidor. |
| 2da | Server Action con FormData | const file = formData.get('avatar'). Servidor recibe el archivo y lo reenvía a almacenamiento. Más simple pero limpio por memoria. |
| 3ra | API Route con análisis estilo multer | Enfoque clásico. Más control pero más código y configuración. |
Opción incorrecta: Almacenar archivos cargados en el sistema de archivos del servidor Next.js. Contenedores efímeros (Vercel, Docker) pierden archivos en redespliegue. Los archivos deben ir a almacenamiento duradero.
Por qué la mejor es la mejor: Las URLs pre-firmadas permiten al cliente cargar directamente a S3/R2 -- el servidor nunca toca los bytes, así que maneja cualquier tamaño de archivo sin presión de memoria.
Decisión 29: Internacionalización (i18n)
Escenario: Tu sitio de marketing necesita soportar inglés, español y japonés.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | next-intl con App Router | Middleware detecta locale, grupos de ruta por idioma [locale]/, traducciones del lado del servidor. |
| 2da | next-i18next (Pages Router) | Maduro, bien documentado. Mejor opción si aún estás en Pages Router. |
| 3ra | Enrutamiento locale manual | Construye tu propio segmento dinámico [locale] y cargador de traducción. Control total pero reinventa la rueda. |
Opción incorrecta: Traducción solo del lado del cliente (cargando bundles JSON en useEffect). El contenido brilla en el idioma predeterminado antes de cambiar, y los motores de búsqueda solo ven el predeterminado.
Por qué la mejor es la mejor: next-intl se integra con middleware y Server Components del App Router, así que el contenido traducido está en el HTML inicial -- sin brillo, SEO completo, y detección locale automática.
Decisión 30: Desplegando una aplicación Next.js
Escenario: Tu equipo está listo para desplegar una app Next.js de producción.
| Rango | Opción | Enfoque |
|---|---|---|
| Mejor | Vercel | Despliegue cero-config, funciones edge, soporte ISR, análisis, despliegues de vista previa por PR. |
| 2da | Autoalojado con next start | Ejecuta el servidor de producción Next.js en cualquier máquina que controles. Control total de infraestructura, pero gestionas todo tú mismo. Ver explicación abajo. |
| 3ra | Contenedor Docker | Construye una imagen Docker con salida standalone. Bueno para Kubernetes o infraestructura de contenedor existente. |
Cómo funciona autoalojamiento con next start:
Next.js incluye un servidor de producción Node.js built-in. Construyes la app una vez, luego la ejecutas como un proceso de vida larga en cualquier servidor con Node.js instalado.
-
Paso de construcción — Ejecuta
next builden tu CI o en el servidor. Esto compila todas las páginas, genera activos estáticos, y produce el directorio de salida.next/. Los Server Components se pre-renderizan, bundles de cliente están optimizados y divididos por código. -
Paso de inicio — Ejecuta
next start(predeterminado a puerto 3000). Esto lanza un servidor HTTP Node.js que maneja enrutamiento, SSR, Server Actions, revalidación ISR y middleware -- todas las características que obtienes en Vercel, solo ejecutándose en tu máquina. -
Lo que necesitas gestionar tú mismo:
| Inquietud | Lo que Vercel hace por ti | Lo que haces autoalojado |
|---|---|---|
| Gestión de procesos | Automático | Usa PM2 o systemd para mantener el proceso vivo y reiniciar en fallo |
| HTTPS / TLS | Automático | Pon Nginx o Caddy en frente como proxy inverso con terminación TLS |
| Escalado | Auto-escalas | Ejecuta múltiples instancias detrás de un balanceador de carga (ALB, Nginx upstream) |
| CDN / activos estáticos | CDN edge built-in | Configura CloudFront, Cloudflare, o sirve desde Nginx |
| Caché ISR | Caché distribuida gestionada | Funciona de la caja en un servidor único; para multi-servidor, configura un manejador de caché compartido |
| Variables de ambiente | UI de dashboard | Establece en .env.production, archivo de unidad systemd, o tu herramienta de despliegue |
| Despliegues cero-tiempo de inactividad | Automático | Despliegues azul-verde o rodantes vía tus propios scripts |
# Despliegue autoalojado típico en una instancia EC2/DigitalOcean
# 1. Construir (a menudo hecho en CI, luego artefactos se copian al servidor)
npm ci
next build
# 2. Inicio con PM2 para gestión de procesos
pm2 start npm --name "my-app" -- start
# o directamente:
pm2 start node_modules/.bin/next --name "my-app" -- start -p 3000
# 3. Proxy inverso Nginx (simplificado)
# /etc/nginx/sites-available/my-app
# server {
# listen 443 ssl;
# server_name myapp.com;
# location / {
# proxy_pass http://localhost:3000;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_cache_bypass $http_upgrade;
# }
# }Cuándo elegir autoalojado sobre Vercel: Necesitas permanecer dentro de un proveedor de nube específico (p. ej., todo en AWS VPC), la política de tu empresa prohíbe hosting de terceros, necesitas middleware de servidor personalizado (p. ej., actualizaciones de WebSocket), o el costo es una inquietud en volúmenes de tráfico alto donde el precio de Vercel excede un servidor dedicado.
Opción incorrecta: Exportar como HTML estático (next export / output: 'export') cuando tu app usa Server Components, middleware o ISR. Estas características requieren un runtime Node.js -- la exportación estática silenciosamente las abandona.
Por qué la mejor es la mejor: Vercel está construido por el equipo Next.js -- características como ISR, middleware y Server Actions funcionan de la caja sin configuración de infraestructura cero. El autoalojamiento es la mejor opción 2da cuando necesitas ese control de infraestructura, pero espera ser dueño de la carga de operaciones.