🎯 Meta: llevar React al servidor: renderizado SSR/SSG, Server Components, Server Actions, el nuevo modelo de caché y despliegue real. Next.js es el meta-framework de React dominante y el puente natural entre tus conocimientos de backend y el frontend.
Versiones: Next.js 16.2 (LTS, jul-2026) · React 19.2 · Turbopack (bundler por defecto, estable) · Node.js 20.9+ requerido (usa el 24 LTS del libro).
⚠️ Si vienes de tutoriales viejos (Next 13-15): en Next 16
middleware.tspasa a serproxy.ts,params/cookies()/headers()son async (hay queawait), Turbopack sustituye a Webpack, y el caché se vuelve explícito con Cache Components ("use cache"). Muchos snippets antiguos de internet ya no compilan tal cual.
18.1 · Por qué existe Next.js
Una SPA pura (cap. 17) envía un HTML vacío + un bundle JS; el usuario ve blanco hasta que React arranca, y Google indexa poco. Next.js renderiza React en el servidor:
SPA (Vite + React) Next.js
┌────────────────────────────┐ ┌──────────────────────────────┐
│ GET / ◀── <div id=root> │ │ GET / ◀── HTML COMPLETO ya │
│ + bundle.js 300KB │ │ renderizado │
│ …espera…parse…ejecuta… │ │ (el JS "hidrata" después │
│ fetch a la API… │ │ solo lo interactivo) │
│ POR FIN se ve contenido │ │ Contenido visible al instante │
└────────────────────────────┘ └──────────────────────────────┘Estrategias de render que Next mezcla por página e incluso por componente:
| Estrategia | Cuándo se genera el HTML | Uso típico |
|---|---|---|
| SSG (estático) | En build | Landing, blog, docs |
| ISR (incremental) | En build + se regenera cada N seg | Catálogo de tienda |
| SSR (dinámico) | En cada petición | Dashboard con datos del usuario |
| PPR (parcial) | Shell estático + huecos dinámicos en streaming | Lo mejor de ambos (modelo por defecto con Cache Components) |
18.2 · Crear el proyecto
npx create-next-app@latest tienda-next
# ✔ TypeScript ✔ ESLint ✔ Tailwind (opcional) ✔ App Router ✔ Turbopack
cd tienda-next
npm run dev # http://localhost:3000tienda-next/
├── app/ ← App Router: CADA CARPETA ES UNA RUTA
│ ├── layout.tsx ← layout raíz (html/body) — obligatorio
│ ├── page.tsx ← página de "/"
│ ├── globals.css
│ └── productos/
│ ├── page.tsx ← "/productos"
│ ├── loading.tsx ← UI de carga automática (Suspense)
│ ├── error.tsx ← UI de error automática (error boundary)
│ └── [id]/
│ └── page.tsx ← "/productos/42" (params dinámico)
├── proxy.ts ← (opcional) el antiguo middleware
├── next.config.ts
└── package.jsonArchivos especiales del App Router (los aprendes una vez, valen para todo):
| Archivo | Rol |
|---|---|
page.tsx | La página (hace la ruta pública) |
layout.tsx | Envoltorio persistente que no se re-renderiza al navegar entre hijos |
loading.tsx | Fallback de Suspense automático para el segmento |
error.tsx | Error boundary del segmento ('use client' obligatorio) |
not-found.tsx | 404 del segmento |
route.ts | Endpoint API (en vez de página) |
18.3 · Server Components vs Client Components
El concepto más importante de Next moderno. Por defecto, todo componente en app/ es un React Server Component (RSC): se ejecuta solo en el servidor, puede ser async, tocar la base de datos, y no envía su JS al navegador.
// app/productos/page.tsx — SERVER component (por defecto)
// Puede hacer async/await directamente. Cero JS enviado al cliente.
import { db } from '@/lib/db';
export default async function PaginaProductos() {
const productos = await db.producto.findMany(); // ¡SQL directo, sin fetch!
return (
<main>
<h1>Productos</h1>
<ul>
{productos.map((p) => <li key={p.id}>{p.nombre} — {p.precio} €</li>)}
</ul>
<BotonFavorito /> {/* isla interactiva → client component */}
</main>
);
}// app/productos/BotonFavorito.tsx — CLIENT component
'use client'; // ← directiva en la PRIMERA línea
import { useState } from 'react';
export function BotonFavorito() {
const [fav, setFav] = useState(false);
return <button onClick={() => setFav(!fav)}>{fav ? '★' : '☆'}</button>;
}Reglas para decidir:
¿Usa useState / useEffect / onClick / APIs del navegador? ──▶ 'use client'
¿Solo pinta datos (aunque los cargue de la BD)? ──▶ Server (defecto)🧠 Modelo mental: el árbol es servidor por defecto y marcas islas de interactividad con
'use client'. Todo lo que importe un client component se vuelve cliente también — por eso las islas se ponen en las hojas del árbol, no en el layout raíz.⚠️ Las props de servidor→cliente deben ser serializables (nada de funciones ni clases; las Server Actions de 18.6 son la excepción).
18.4 · Rutas dinámicas y navegación
// app/productos/[id]/page.tsx
interface Props { params: Promise<{ id: string }> } // ⚠️ Next 16: params es Promise
export default async function Detalle({ params }: Props) {
const { id } = await params; // hay que await
const producto = await db.producto.findUnique({ where: { id: Number(id) } });
if (!producto) notFound(); // renderiza not-found.tsx
return <h1>{producto.nombre}</h1>;
}
// SSG de rutas dinámicas: pre-genera estas páginas en build
export async function generateStaticParams() {
const productos = await db.producto.findMany({ select: { id: true } });
return productos.map((p) => ({ id: String(p.id) }));
}// Navegación: <Link> con prefetch automático al entrar en viewport
import Link from 'next/link';
<Link href={`/productos/${p.id}`}>{p.nombre}</Link>
// En client components: hooks de navegación
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
const router = useRouter(); // router.push('/productos'), router.refresh()Otros patrones de carpeta: (grupo)/ agrupa sin afectar la URL, [...slug] atrapa segmentos múltiples, @modal slots paralelos. Empieza con lo básico; añade cuando lo necesites.
18.5 · Datos y caché: Cache Components ("use cache")
Next 16 hace el caché explícito (antes era implícito y confuso, la mayor queja de Next 13-15). Lo dinámico es dinámico salvo que TÚ marques qué se cachea:
// Componente/función cacheada: se pre-renderiza y se comparte entre usuarios
// (requiere activar cacheComponents en next.config.ts)
async function ListaCategorias() {
'use cache';
cacheLife('hours'); // perfil de vida: seconds/minutes/hours/days
cacheTag('categorias'); // etiqueta para invalidar a demanda
const cats = await db.categoria.findMany();
return <ul>{cats.map((c) => <li key={c.id}>{c.nombre}</li>)}</ul>;
}
// Página con PPR automático: shell cacheado + huecos dinámicos en streaming
export default function Page() {
return (
<main>
<ListaCategorias /> {/* estático, instantáneo */}
<Suspense fallback={<Spinner />}>
<RecomendacionesUsuario /> {/* dinámico, streaming */}
</Suspense>
</main>
);
}Invalidación desde mutaciones:
import { revalidateTag, updateTag, refresh } from 'next/cache';
revalidateTag('categorias'); // marca la etiqueta como caducada (próxima visita regenera)
updateTag('categorias'); // regenera YA y actualiza al usuario actual (Next 16)
refresh(); // refresca los datos dinámicos de la vista actualfetch en servidor también se integra: fetch(url, { next: { revalidate: 3600, tags: ['x'] } }).
💡 Con tu API del libro (caps. 03-08), un Server Component puede llamarla con
fetchinterno y cachearla con tags. Next hace de BFF (Backend For Frontend): el navegador nunca ve tu API interna ni sus tokens.
18.6 · Mutaciones: Server Actions
Funciones que se ejecutan en el servidor pero se invocan desde formularios/botones del cliente. Next genera el endpoint por ti: adiós a escribir rutas API para cada form.
// app/productos/actions.ts
'use server';
import { db } from '@/lib/db';
import { revalidateTag } from 'next/cache';
import { z } from 'zod';
const Esquema = z.object({
nombre: z.string().min(2),
precio: z.coerce.number().positive(),
});
export async function crearProducto(_prev: unknown, formData: FormData) {
// ⚠️ Una Server Action es un ENDPOINT PÚBLICO: valida y autoriza SIEMPRE
const sesion = await verificarSesion();
if (!sesion) return { error: 'No autorizado' };
const datos = Esquema.safeParse(Object.fromEntries(formData));
if (!datos.success) return { error: 'Datos inválidos' };
await db.producto.create({ data: datos.data });
revalidateTag('productos');
return { ok: true };
}// app/productos/FormProducto.tsx
'use client';
import { useActionState } from 'react';
import { crearProducto } from './actions';
export function FormProducto() {
const [estado, action, pendiente] = useActionState(crearProducto, null);
return (
<form action={action}>
<input name="nombre" required />
<input name="precio" type="number" step="0.01" required />
<button disabled={pendiente}>{pendiente ? 'Creando…' : 'Crear'}</button>
{estado?.error && <p className="error">{estado.error}</p>}
</form>
);
}El mismo useActionState/useOptimistic del cap. 17 — pero la función corre en el servidor. Funciona incluso sin JavaScript cargado (mejora progresiva, como HTMX).
18.7 · Route Handlers y proxy.ts
Route Handlers: APIs dentro de Next
// app/api/productos/route.ts → GET/POST /api/productos
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q') ?? '';
const productos = await db.producto.findMany({ where: { nombre: { contains: q } } });
return NextResponse.json(productos);
}
export async function POST(req: NextRequest) {
const body = await req.json();
// validar con zod…
const p = await db.producto.create({ data: body });
return NextResponse.json(p, { status: 201 });
}Úsalos para: webhooks, endpoints para móviles/terceros, OAuth callbacks. Para tus propios formularios, mejor Server Actions.
proxy.ts (el ex-middleware)
Corre antes de cada petición que matchee. En Next 16 corre en runtime Node por defecto:
// proxy.ts (raíz del proyecto)
import { NextRequest, NextResponse } from 'next/server';
export function proxy(req: NextRequest) {
const sesion = req.cookies.get('sesion');
if (!sesion && req.nextUrl.pathname.startsWith('/admin')) {
return NextResponse.redirect(new URL('/login', req.url));
}
return NextResponse.next();
}
export const config = { matcher: ['/admin/:path*'] };⚠️ Seguridad: el proxy es UX (redirigir rápido), no tu única barrera. Hubo CVEs históricos de bypass de middleware: la autorización real va en cada Server Action / handler / página (defensa en profundidad, apéndice C).
Node Runtime vs Edge Runtime
Cada Route Handler y proxy.ts corre en uno de dos entornos, y la elección tiene consecuencias reales:
| Node Runtime (por defecto en Next 16) | Edge Runtime | |
|---|---|---|
| Dónde corre | Tu servidor/contenedor Node de siempre | Red de edge del proveedor (más cerca del usuario) |
| APIs disponibles | Todo Node (fs, drivers de BD con TCP, SDKs pesados) | Subconjunto Web (fetch, Request/Response, sin fs) |
| Arranque en frío | Normal | Casi instantáneo (V8 isolates, no un proceso completo) |
| Uso típico | La mayoría de tu app: BD, lógica de negocio | proxy.ts ligero (redirects, headers, feature flags) |
// Forzar edge en un Route Handler puntual (no en proxy.ts: ese ya es edge-friendly por diseño)
export const runtime = 'edge';
export async function GET() {
// OJO: aquí NO puedes usar un driver de PostgreSQL por TCP (pg, prisma con conexión directa) —
// el Edge Runtime no tiene sockets TCP crudos. Usa un driver HTTP (Neon, PlanetScale) o Node.
return Response.json({ ok: true });
}🧠 Regla práctica: deja tu API y tus páginas en Node Runtime (por defecto) salvo que midas un problema real de latencia global en un endpoint muy específico y ligero.
proxy.tsya está pensado para edge — evita meterle lógica pesada (consultas a BD, llamadas a APIs lentas) ahí, precisamente porque el entorno edge no tiene todas las APIs de Node para hacerlo bien.
18.8 · Los básicos que dan puntos gratis
// SEO — metadata estática o dinámica por página
export const metadata = { title: 'Tienda', description: 'Los mejores periféricos' };
export async function generateMetadata({ params }: Props) {
const { id } = await params;
const p = await db.producto.findUnique({ where: { id: Number(id) } });
return { title: `${p?.nombre} — Tienda` };
}
// Imágenes — optimización automática (resize, webp/avif, lazy, sin CLS)
import Image from 'next/image';
<Image src={p.imagen} alt={p.nombre} width={400} height={300} />
// Fuentes — self-hosted sin layout shift
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });Variables de entorno: igual que siempre en servidor (process.env.DB_URL); al navegador solo llegan las prefijadas NEXT_PUBLIC_ (misma advertencia que en 15.5: eso es público).
18.9 · Despliegue
npm run build # build de producción con Turbopack
npm run start # servidor Node de producción en :3000Docker (conecta con el cap. 12) — usa output: 'standalone':
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = { output: 'standalone', cacheComponents: true };
export default config;FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]Detrás de tu Nginx del cap. 13 como reverse proxy, o en Vercel (los creadores de Next: deploy con git push, sin Dockerfile). Autoalojado funciona perfectamente — no estás obligado a Vercel.
⚠️ Mantente al día en seguridad: Next publica parches de seguridad con regularidad (SSRF, cache poisoning, bypass…). Suscríbete a sus advisories y actualiza los patch releases — como haces con tu backend.
18.10 · Tests
- Unit/componentes: Vitest + Testing Library, igual que el cap. 17 (los Server Components async se testean mejor a nivel e2e).
- E2E (lo más valioso en Next): Playwright contra
next start.
// e2e/productos.spec.ts
import { test, expect } from '@playwright/test';
test('flujo de compra completo', async ({ page }) => {
await page.goto('/productos');
await expect(page.getByRole('heading', { name: 'Productos' })).toBeVisible();
await page.getByRole('link', { name: 'Teclado mecánico' }).click();
await expect(page).toHaveURL(/\/productos\/\d+/);
await page.getByRole('button', { name: 'Comprar' }).click();
await expect(page.getByText('Añadido al carrito')).toBeVisible();
});18.11 · Buenas prácticas Next.js
- Server por defecto,
'use client'en las hojas. Cada isla cliente que evitas son KB que el usuario no descarga. - Datos en Server Components; mutaciones en Server Actions. Route Handlers solo para consumidores externos.
- Valida y autoriza CADA Server Action y handler — son endpoints públicos aunque no los veas en una lista de rutas.
- Caché explícito:
"use cache"+cacheTag+revalidateTag/updateTag. Si no sabes por qué algo se cachea, no actives caché sobre ello. loading.tsxyerror.tsxen cada segmento con datos — streaming y errores gratis.<Image>,<Link>,next/fontsiempre — es rendimiento gratis (Core Web Vitals, 15.6).- No dupliques backend: si ya tienes tu API (caps. 03-08), Next es el BFF que la consume en servidor; no migres la lógica de negocio a Server Actions por moda.
- Fija versiones y aplica parches de seguridad de Next con la misma disciplina que en el backend.
✅ Ejercicio del capítulo
La tienda, versión fullstack:
1. create-next-app (TS + App Router + Turbopack) con cacheComponents activado.
2. /productos: Server Component que consume TU API del libro con fetch + tags;
loading.tsx con skeleton y error.tsx con retry.
3. /productos/[id]: detalle con generateStaticParams + generateMetadata dinámico.
4. Alta y borrado con Server Actions (zod + auth) + useActionState y useOptimistic.
5. Buscador client-side (useSearchParams) con la URL como estado compartible.
6. /admin protegido con proxy.ts + verificación de sesión en cada action (doble barrera).
7. Imágenes con <Image>, fuente con next/font, metadata en todas las páginas.
8. Dockerfile standalone detrás de tu Nginx del cap. 13. Lighthouse > 95.
9. E2E con Playwright: listar → detalle → crear → borrar.Ya dominas el ecosistema React. Último framework del libro: Vue, la alternativa más querida — y verás cuántos conceptos ya conoces.
💡 Pistas de la solución (abre solo si te atascas)
- El punto 3:
generateStaticParamscorre en build time y necesita poder llamar a tu API o BD directamente (es un Server Component) — no usesfetchrelativo a tu propio dominio, que aún no existe durante el build. - Para el punto 4, recuerda validar Y autorizar DENTRO de la Server Action, no solo en el formulario del cliente — la action es un endpoint público aunque no aparezca en una lista de rutas (18.6).
- El error típico del punto 6: proteger
/adminsolo conproxy.tsy no verificar de nuevo la sesión dentro de cada Server Action que toque/admin— el proxy es UX, no la única barrera (18.7). - Si Lighthouse no llega a 95, revisa primero si estás usando
<img>en vez de<Image>en algún sitio, o si faltanext/fonten alguna página con texto.
🧠 Autoevaluación
Si el componente necesita useState, useEffect, manejadores de eventos del navegador o APIs del cliente, va marcado 'use client'. Si solo pinta datos (aunque los cargue de una base de datos con await), se queda como Server Component por defecto — sin JS enviado al navegador.
Una Server Action es, técnicamente, un endpoint HTTP más: Next genera la ruta internamente y cualquiera puede invocarla directamente sin pasar por tu formulario. La validación y autorización reales tienen que vivir dentro de la propia action, igual que en cualquier controlador de API (cap. 20).
revalidateTag marca el caché como caducado: la próxima visita regenerará esos datos. updateTag regenera inmediatamente y refleja el cambio al usuario actual sin esperar a la siguiente petición — útil cuando quien acaba de mutar el dato necesita verlo actualizado ya.
El proxy corre antes de la petición y es rápido para redirigir a un usuario sin sesión, pero es una capa de UX, no la única defensa: ha habido CVEs históricos de bypass de middleware. La autorización real debe repetirse dentro de cada Server Action y página que toque datos sensibles — defensa en profundidad, la misma idea del apéndice C.
El Edge Runtime expone un subconjunto de APIs Web (fetch, Request/Response) pero no sockets TCP crudos ni el resto de la API de Node (fs, net) que esos drivers necesitan para abrir una conexión persistente a la base de datos. Para acceder a datos desde edge hace falta un driver sobre HTTP, o simplemente dejar ese handler en Node Runtime (el valor por defecto).
Siguiente: 19-vuejs.md — el framework progresivo.