Server Components en Next.js: HTML que llega sin JavaScript
El App Router de Next.js cambia dónde corre tu código: por defecto todo se ejecuta en el servidor y llega al browser como HTML, no como bundle. Server Actions y streaming son las piezas que hacen que esto sea usable en producción, no solo en la demo.

El antes y después: de bundle gigante a HTML puro
Antes, para mostrar una lista de productos armabas un endpoint API, hacías fetch desde useEffect, esperabas el loading spinner y mandabas todo ese código al browser aunque el usuario nunca tocara nada interactivo. Con Server Components, el componente es una función async que le pega directo a la base de datos, calcula lo que necesite, y el browser recibe el resultado ya renderizado. No hay bundle de React Query, no hay endpoint intermedio, no hay estado de loading para algo que ya vino resuelto. La diferencia se nota en el peso de la página antes de escribir una sola línea de optimización.
// app/products/page.tsx — Server Component (default)import { db } from '@/lib/database';import { SearchFilter } from '@/components/SearchFilter';export default async function ProductsPage({ searchParams,}: { searchParams: Promise<{ q?: string }>;}) { const { q } = await searchParams; // Acceso directo a la base de datos, sin API route const products = await db.product.findMany({ where: { name: q ? { contains: q, mode: 'insensitive' } : undefined }, orderBy: { createdAt: 'desc' }, }); return ( <main> <SearchFilter initialQuery={q} /> <ul> {products.map(p => <li key={p.id}>{p.name}</li>)} </ul> </main> );}'use client' es una puerta, no una pared
El error más común es pensar que 'use client' marca un componente aislado. En realidad marca un punto de entrada: todo lo que ese archivo importe se va con él al bundle del browser. Si pones la directiva demasiado arriba en el árbol, arrastrás componentes enteros que podrían haber quedado en el servidor. La solución es empujar 'use client' lo más abajo posible y, cuando necesites mezclar un widget interactivo con contenido pesado renderizado en servidor, pasar ese contenido como children en lugar de importarlo.
// components/Collapsible.tsx'use client';import { useState } from 'react';export function Collapsible({ children }: { children: React.ReactNode }) { const [open, setOpen] = useState(false); return ( <div> <button onClick={() => setOpen(o => !o)}>Toggle</button> {open && children} </div> );}// app/page.tsx — Server Componentimport { Collapsible } from '@/components/Collapsible';import { HeavyServerReport } from '@/components/HeavyServerReport';export default function Page() { return ( <Collapsible> <HeavyServerReport /> </Collapsible> );}Server Actions: mutaciones sin escribir un endpoint
Una Server Action es una función marcada con 'use server' que podés invocar directo desde un formulario o desde un Client Component, sin definir una ruta API. Next.js se encarga de convertirla en la llamada de red por debajo. El detalle que no podés ignorar: esa función queda expuesta como un endpoint POST público, así que cualquiera puede invocarla con cualquier dato. Por eso la validación con Zod y las chequeos de stock no son un extra prolijo, son la línea que separa una función cómoda de un agujero de seguridad.
// app/actions/cart.ts'use server';import { db } from '@/lib/database';import { revalidatePath } from 'next/cache';import { z } from 'zod';const AddToCartSchema = z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(99),});export async function addToCart(formData: FormData) { const parsed = AddToCartSchema.safeParse({ productId: formData.get('productId'), quantity: Number(formData.get('quantity')), }); if (!parsed.success) { return { error: 'Invalid input' }; } const { productId, quantity } = parsed.data; const product = await db.product.findUnique({ where: { id: productId } }); if (!product || product.stock < quantity) { return { error: 'Insufficient stock' }; } await db.cartItem.upsert({ where: { sessionId_productId: { sessionId: 'demo', productId } }, update: { quantity: { increment: quantity } }, create: { sessionId: 'demo', productId, quantity }, }); revalidatePath('/cart'); return { success: true, message: product.name + ' agregado' };}Streaming: la página aparece antes de que estén los datos lentos
Sin streaming, la página espera a que la consulta más lenta termine antes de mandar un solo pixel al browser. Con Suspense boundaries, Next.js manda el layout de inmediato y va rellenando cada sección a medida que su fetch resuelve, en paralelo. El truco visual es real: el usuario ve el dashboard con su estructura completa en milisegundos, y los números de revenue aparecen dos segundos después sin que nada se haya trabado. Ojo con el orden: si encadenás awaits uno detrás de otro dentro del mismo componente, sumás latencias en cascada; separá el trabajo en boundaries independientes para que corran en paralelo.
// app/dashboard/page.tsximport { Suspense } from 'react';export default function DashboardPage() { return ( <main> <h1>Dashboard</h1> <Suspense fallback={<p>Cargando revenue...</p>}> <RevenueChart /> </Suspense> <Suspense fallback={<p>Cargando ordenes...</p>}> <RecentOrders /> </Suspense> </main> );}async function RevenueChart() { const data = await fetch('https://analytics.internal/revenue', { next: { revalidate: 300 }, }).then(r => r.json()); return <div>Revenue: {data.total}</div>;}Cache explícito: en Next 15 nada se guarda por default
Este es el cambio que más rompe intuiciones si venís de versiones anteriores: los fetch ya no se cachean solos. Tenés que decidir explícitamente cuánto tiempo vive cada dato con next.revalidate, o envolver consultas a base de datos con unstable_cache. Cuando algo cambia por una mutación, revalidatePath y revalidateTag te dejan invalidar justo esa página o ese tag, sin tirar abajo todo el cache del sitio. Pensarlo así: el cache pasa de ser un comportamiento oculto a una decisión que tomás línea por línea.
import { unstable_cache } from 'next/cache';// Cache de 5 minutos para una consulta que no es fetchconst getCachedUser = unstable_cache( async (userId: string) => { return db.user.findUnique({ where: { id: userId }, include: { orders: { take: 5 } }, }); }, ['user'], { revalidate: 300, tags: ['users'] });// Invalidación quirúrgica dentro de una Server Action'use server';import { revalidateTag, revalidatePath } from 'next/cache';export async function updateProduct(id: string, data: unknown) { await db.product.update({ where: { id }, data }); revalidateTag('products'); revalidatePath('/products/' + id);}Cuándo el App Router no es la respuesta
No todo proyecto necesita esta máquina. Si estás armando una app de estado pesado en el cliente -un editor de dibujo, una planilla, un juego en tiempo real- vas a estar peleando contra el modelo en lugar de aprovecharlo. Lo mismo si tenés que desplegar en un host puramente estático sin runtime de servidor, o si tu equipo recién está aprendiendo React y la frontera server-client va a frenar más de lo que va a acelerar. La regla honesta: Server Components ganan en páginas cargadas de contenido y datos; cuanto más te alejás de ese perfil, menos conviene el trade-off.
Qué puedes construir con esto
Tres proyectos que se hacen en una tarde y terminan en algo que corre.
Catálogo de productos sin API routes propias
Una página de productos con búsqueda server-side, filtro interactivo mínimo y un botón de agregar al carrito resuelto con Server Action.
Dashboard que aparece a pedazos
Un dashboard con tres bloques -revenue, órdenes recientes, alertas de inventario- cada uno con su propio Suspense boundary y su propia velocidad de respuesta.
Editor de productos con cache quirúrgica
Una página de detalle de producto editable donde guardar un cambio invalida solo esa página y el listado, usando revalidateTag y revalidatePath.
Qué te llevas
Server Components corren en el servidor por defecto y no mandan JavaScript al browser; 'use client' es la excepción, no la regla.
Una Server Action es una función cómoda pero también un endpoint público: validá y autorizá dentro de ella siempre.
El streaming con Suspense boundaries separa el layout de los datos lentos, así que diseñá cada boundary para que corra en paralelo, no en cascada.
En Next.js 15 el cache es opt-in: si no le pones next.revalidate o unstable_cache, no se está guardando nada.