Skip to content
Search
See every article
Next.js App Router

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.

tsx
// 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.

tsx
// 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 Component
import { 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.

typescript
// 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.

tsx
// app/dashboard/page.tsx
import { 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.

typescript
import { unstable_cache } from 'next/cache';
​
// Cache de 5 minutos para una consulta que no es fetch
const 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.

What you can build with this

Three projects you can finish in an afternoon, each ending in something that runs.

  1. 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.

    Why it's worth it — Entender en la práctica cómo Server Components reemplazan el fetch-en-useEffect y cómo una Server Action reemplaza un endpoint POST completo.

  2. 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.

    Why it's worth it — Ver con los propios ojos la diferencia entre esperar todo junto y dejar que cada sección aparezca cuando está lista.

  3. 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.

    Why it's worth it — Aprender a controlar explícitamente qué se refresca y qué no, en vez de depender de un cache automático que en Next 15 ya no existe por default.

What you take away

  • 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.