Skip to content

🎯 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.ts pasa a ser proxy.ts, params/cookies()/headers() son async (hay que await), 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:

EstrategiaCuándo se genera el HTMLUso típico
SSG (estático)En buildLanding, blog, docs
ISR (incremental)En build + se regenera cada N segCatálogo de tienda
SSR (dinámico)En cada peticiónDashboard con datos del usuario
PPR (parcial)Shell estático + huecos dinámicos en streamingLo mejor de ambos (modelo por defecto con Cache Components)

18.2 · Crear el proyecto

bash
npx create-next-app@latest tienda-next
# ✔ TypeScript ✔ ESLint ✔ Tailwind (opcional) ✔ App Router ✔ Turbopack
cd tienda-next
npm run dev        # http://localhost:3000
tienda-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.json

Archivos especiales del App Router (los aprendes una vez, valen para todo):

ArchivoRol
page.tsxLa página (hace la ruta pública)
layout.tsxEnvoltorio persistente que no se re-renderiza al navegar entre hijos
loading.tsxFallback de Suspense automático para el segmento
error.tsxError boundary del segmento ('use client' obligatorio)
not-found.tsx404 del segmento
route.tsEndpoint 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.

tsx
// 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>
  );
}
tsx
// 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

tsx
// 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) }));
}
tsx
// 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:

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

typescript
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 actual

fetch 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 fetch interno 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.

typescript
// 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 };
}
tsx
// 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

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

typescript
// 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 correTu servidor/contenedor Node de siempreRed de edge del proveedor (más cerca del usuario)
APIs disponiblesTodo Node (fs, drivers de BD con TCP, SDKs pesados)Subconjunto Web (fetch, Request/Response, sin fs)
Arranque en fríoNormalCasi instantáneo (V8 isolates, no un proceso completo)
Uso típicoLa mayoría de tu app: BD, lógica de negocioproxy.ts ligero (redirects, headers, feature flags)
typescript
// 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.ts ya 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

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

bash
npm run build       # build de producción con Turbopack
npm run start       # servidor Node de producción en :3000

Docker (conecta con el cap. 12) — usa output: 'standalone':

typescript
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = { output: 'standalone', cacheComponents: true };
export default config;
dockerfile
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.
typescript
// 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

  1. Server por defecto, 'use client' en las hojas. Cada isla cliente que evitas son KB que el usuario no descarga.
  2. Datos en Server Components; mutaciones en Server Actions. Route Handlers solo para consumidores externos.
  3. Valida y autoriza CADA Server Action y handler — son endpoints públicos aunque no los veas en una lista de rutas.
  4. Caché explícito: "use cache" + cacheTag + revalidateTag/updateTag. Si no sabes por qué algo se cachea, no actives caché sobre ello.
  5. loading.tsx y error.tsx en cada segmento con datos — streaming y errores gratis.
  6. <Image>, <Link>, next/font siempre — es rendimiento gratis (Core Web Vitals, 15.6).
  7. 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.
  8. 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: generateStaticParams corre en build time y necesita poder llamar a tu API o BD directamente (es un Server Component) — no uses fetch relativo 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 /admin solo con proxy.ts y 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 falta next/font en 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.