---
name: nextjs-experto
description: >
  Next.js App Router: Server Components, Client Components, Server Actions,
  streaming con Suspense, ISR, route handlers y middleware. Cargar cuando se
  implementen páginas Next.js, data fetching, mutaciones o rutas de API.
version: "1.1.1"
evolved: true
evolved-from: "1.1.0"
evolved-at: "2026-05-11"
evolved-by: "aprender"
evolved-note: "L-108 SIGM Opción C: cache .next/ confirmado x2 (Opción B + Opción C) — patrón consolidado para refactors de tipos masivos. Sin cambios en otros gotchas."
herramientasPermitidas: [Read]
exclusiones:
  - "No cargar para proyectos Next.js con Pages Router (pages/index.tsx) — el modelo de getServerSideProps y getStaticProps es diferente al App Router; este skill solo cubre App Router."
  - "No cargar para errores de compilación next build — para esos cargar `build-errors-nextjs` que cubre hydration mismatch, Turbopack y errores de RSC."
  - "No cargar cuando la pregunta es sobre patrones de arquitectura de App Router (nested layouts, parallel routes, caching strategies) — para diseño de arquitectura cargar `nextjs-patrones`."
  - "No cargar para testing de componentes Next.js (Vitest, RTL, Playwright) — para escribir tests cargar `nextjs-testing`."
evolvable: true  # default para skill estandar
---
# Next.js Experto — App Router Moderno

Next.js 13+ con App Router cambia el modelo mental: los componentes son Server
Components por defecto. El cliente solo recibe HTML renderizado y los fragments
de JS necesarios para interactividad. Este skill cubre los patrones del App Router,
data fetching con cache granular, Server Actions y middleware.

## Cuándo cargar este skill

Invoca `Skill("nextjs-experto")` cuando:

- Se implementen páginas, layouts o rutas en App Router
- Se configure data fetching con cache y revalidación
- Se implementen Server Actions para mutaciones
- Se creen route handlers (API routes en App Router)
- Se configure middleware para autenticación o i18n
- Se use `generateStaticParams` para SSG dinámico

## Cuándo NO cargar

- El proyecto usa Pages Router (`pages/index.tsx`, `getServerSideProps`, `getStaticProps`) — ese modelo es incompatible con App Router; las APIs de cache y Server Actions no aplican.
- La pregunta es sobre errores de compilación o hydration mismatch — esos son errores de plataforma; cargar `build-errors-nextjs`.
- La pregunta es sobre diseño de arquitectura (nested layouts, parallel routes, estrategias de cache) — para decisiones de diseño cargar `nextjs-patrones`.
- La pregunta es sobre escribir tests (Vitest, Playwright, RTL) — para testing cargar `nextjs-testing`.

## Conceptos clave

### Server Components vs Client Components

Server Components se ejecutan en el servidor, pueden ser async, acceden a BD
directamente, no tienen hooks ni eventos. Client Components se marcan con
`"use client"`, tienen acceso a hooks, browser APIs y estado local.

**Regla de oro**: usar Server Component por defecto. Agregar `"use client"` solo
cuando se necesita interactividad, hooks o browser APIs.

### Cache y Revalidación

`fetch()` en Next.js está extendido con opciones de cache. `no-store` deshabilita
cache (siempre fresco). `revalidate: N` revalida cada N segundos (ISR).
`tags` permite invalidación selectiva. Los Server Actions pueden llamar
`revalidateTag()` o `revalidatePath()` para invalidar después de mutaciones.

### Server Actions

Funciones marcadas con `"use server"` que se ejecutan en el servidor. Se pueden
llamar directamente desde formularios (`action={miAction}`) o desde Client
Components. Eliminan la necesidad de route handlers para mutaciones simples.

## Reglas obligatorias

### "use client" solo cuando sea absolutamente necesario

```tsx
// MAL — todo marcado como client component sin necesidad
"use client";
export default function ListaProductos({ productos }) {
    return <ul>{productos.map(p => <li key={p.id}>{p.nombre}</li>)}</ul>;
}

// BIEN — Server Component, sin "use client"
export default function ListaProductos({ productos }: { productos: Producto[] }) {
    return <ul>{productos.map(p => <li key={p.id}>{p.nombre}</li>)}</ul>;
}
```

### fetch con error handling SIEMPRE en data fetching

```tsx
// MAL — no maneja errores
async function obtenerProducto(id: string) {
    const res = await fetch(`/api/productos/${id}`);
    return res.json();
}

// BIEN — maneja HTTP errors y serializa correctamente
async function obtenerProducto(id: string): Promise<Producto> {
    const res = await fetch(`${process.env.API_URL}/productos/${id}`, {
        next: { revalidate: 3600, tags: [`producto-${id}`] },
    });
    if (!res.ok) throw new Error(`Error ${res.status}: ${res.statusText}`);
    return res.json() as Promise<Producto>;
}
```

### Server Actions con validación antes de mutar

```tsx
"use server";

export async function crearProducto(formData: FormData) {
    // 1. Validar con zod antes de cualquier mutación
    const resultado = ProductoSchema.safeParse({
        nombre: formData.get('nombre'),
        precio: Number(formData.get('precio')),
    });
    if (!resultado.success) {
        return { error: resultado.error.flatten().fieldErrors };
    }
    // 2. Verificar autenticación
    const sesion = await auth();
    if (!sesion) redirect('/login');
    // 3. Mutar
    await db.producto.create({ data: resultado.data });
    // 4. Invalidar cache
    revalidateTag('productos');
}
```

### Suspense boundaries para streaming

```tsx
// Envolver fetches lentos en Suspense para no bloquear la página completa
export default function PaginaProducto({ params }: { params: { id: string } }) {
    return (
        <main>
            <h1>Producto</h1>
            <Suspense fallback={<EsqueletoDetalle />}>
                <DetalleProducto id={params.id} />
            </Suspense>
            <Suspense fallback={<EsqueletoResenas />}>
                <ResenasProducto id={params.id} />
            </Suspense>
        </main>
    );
}
```

## Patrones recomendados

### Convención de archivos App Router

```
app/
├── layout.tsx          # Layout raíz (html, body)
├── page.tsx            # Página /
├── loading.tsx         # Skeleton mientras carga la página
├── error.tsx           # Boundary de error ("use client")
├── not-found.tsx       # 404 personalizado
├── (auth)/             # Route group — no afecta la URL
│   ├── login/page.tsx
│   └── registro/page.tsx
└── productos/
    ├── page.tsx              # /productos
    ├── [id]/
    │   └── page.tsx          # /productos/[id]
    └── route.ts              # API route GET /productos
```

### generateStaticParams para SSG con rutas dinámicas

```tsx
export async function generateStaticParams() {
    const productos = await db.producto.findMany({ select: { id: true } });
    return productos.map(p => ({ id: p.id.toString() }));
}

export default async function PaginaProducto({ params }: { params: { id: string } }) {
    const producto = await obtenerProducto(params.id);
    // ...
}
```

### Route Handler con tipado

```tsx
// app/api/productos/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
    const searchParams = request.nextUrl.searchParams;
    const pagina = Number(searchParams.get('pagina') ?? 1);

    const productos = await db.producto.findMany({
        skip: (pagina - 1) * 20,
        take: 20,
    });

    return NextResponse.json({ data: productos, pagina });
}
```

### Middleware para autenticación

```tsx
// middleware.ts (raiz del proyecto)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
    const token = request.cookies.get('session')?.value;
    const esRutaProtegida = request.nextUrl.pathname.startsWith('/dashboard');

    if (esRutaProtegida && !token) {
        return NextResponse.redirect(new URL('/login', request.url));
    }
    return NextResponse.next();
}

export const config = {
    matcher: ['/dashboard/:path*', '/api/privada/:path*'],
};
```

### Metadata API para SEO

```tsx
// Estatica
export const metadata: Metadata = {
    title: 'Tienda — Inicio',
    description: 'Los mejores productos...',
};

// Dinámica por página
export async function generateMetadata({ params }: Props): Promise<Metadata> {
    const producto = await obtenerProducto(params.id);
    return {
        title: producto.nombre,
        openGraph: { images: [producto.imagen] },
    };
}
```

## Anti-patrones conocidos

### useEffect para data fetching — NUNCA en App Router

```tsx
// MAL — patrón de Pages Router que no aplica en App Router
"use client";
export default function Productos() {
    const [productos, setProductos] = useState([]);
    useEffect(() => {
        fetch('/api/productos').then(r => r.json()).then(setProductos);
    }, []);
}

// BIEN — Server Component con fetch directo
export default async function Productos() {
    const productos = await obtenerProductos();
    return <ListaProductos productos={productos} />;
}
```

### Layout para datos altamente dinámicos (tiempo real)

Los layouts se cachean agresivamente. Para datos que cambian por request,
usar `page.tsx` con `export const dynamic = 'force-dynamic'` o `no-store`.

### Dynamic imports para reducir bundle del cliente

```tsx
// Cargar componentes pesados solo cuando se necesitan
const Mapa = dynamic(() => import('@/components/Mapa'), {
    loading: () => <p>Cargando mapa...</p>,
    ssr: false, // no renderizar en servidor si usa browser APIs
});
```

### Hydration mismatch — causas comunes y solución

El servidor y el cliente deben producir HTML idéntico en el primer render.
Causas frecuentes de divergencia:

```tsx
// MAL — Date.now() produce valor distinto en servidor vs cliente
export default function Banner() {
    return <p>Generado: {Date.now()}</p>; // hydration mismatch
}

// MAL — Math.random() diverge entre servidor y cliente
<div style={{ order: Math.random() }}>...</div>

// MAL — typeof window produce HTML condicional
{typeof window !== 'undefined' && <ComponenteSoloCliente />}

// MAL — localStorage no existe en el servidor
<p>Tema: {localStorage.getItem('theme')}</p>
```

Solución: mover valores dinámicos del cliente a un `useEffect` con estado
inicial neutro, o usar `suppressHydrationWarning` solo en nodos de texto que
intencionalmente difieren (timestamps mostrados al usuario):

```tsx
"use client";
export default function Reloj() {
    const [hora, setHora] = useState<string>('');
    useEffect(() => setHora(new Date().toLocaleTimeString()), []);
    return <time suppressHydrationWarning>{hora}</time>;
}
```

## Gotchas / Errores comunes no obvios

**`cookies()` y `headers()` hacen opt-out del cache completo de la ruta**: llamar a `cookies()` o `headers()` en cualquier Server Component de una ruta convierte toda la ruta a dinámica (`force-dynamic`), incluso si el componente que los llama es un hijo no relacionado con el dato cacheado. Causa: Next.js marca la ruta entera como dinámica en el momento del análisis estático. Fix: aislar el componente que necesita cookies/headers en un Server Component dedicado e importarlo solo donde se use, o usar `export const dynamic = 'force-static'` en las páginas que no deben ser afectadas.

**Server Action llamada desde un formulario no revalida la UI después de éxito**: el formulario se envía y la acción ejecuta, pero la página no muestra los datos nuevos. Causa: la mutación se hizo pero no se llamó `revalidatePath()` o `revalidateTag()` dentro de la action. Fix: agregar `revalidatePath('/ruta-de-la-pagina')` al final de la Server Action, después de confirmar que la mutación fue exitosa.

**`"use client"` en un componente padre hace que todos sus imports sean Client Components**: si un layout o componente padre tiene `"use client"`, todos los componentes que importa son arrastrados al bundle del cliente aunque sean Server Components sin estado. Causa: el límite `"use client"` se aplica al subárbol completo. Fix: mover el estado o los event handlers a un componente hijo dedicado con `"use client"` y mantener el padre como Server Component.

**Variables de entorno sin `NEXT_PUBLIC_` no disponibles en el cliente en runtime**: `process.env.MI_VAR` en un Client Component retorna `undefined` en el browser aunque esté definida en `.env.local`. Causa: Next.js solo serializa al bundle del cliente las variables con prefijo `NEXT_PUBLIC_`. Fix: renombrar a `NEXT_PUBLIC_MI_VAR` si debe estar en el cliente, o leerla en un Server Component/Action y pasarla como prop.

**`useSearchParams()` requiere `<Suspense>` boundary durante prerender estático**: build de producción falla con `⨯ useSearchParams() should be wrapped in a suspense boundary at page "/X". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout`. Causa: el prerender estático no puede ejecutar el hook → CSR bailout → build aborta. Fix: en `page.tsx` envolver el componente que usa `useSearchParams()` en `<Suspense fallback={null}>` (o un esqueleto). El shell se prerenderiza vacío; el componente hidrata en cliente con los search params disponibles. Mismo patrón aplica a `useRouter()` y `usePathname()` cuando se usan en componentes prerenderizados. Caso real: SIGM agregó `useSearchParams()` a `useLoginForm.ts` para honrar `?siguiente=`; tsc + vitest pasaban pero `npm run build` falló hasta envolver `<LoginForm />` en `<Suspense>`.

**Cache `.next/` puede ocultar errores TypeScript reales tras refactor de tipos masivo** [CONFIRMADO x2 — Opción B 2026-05-10, Opción C 2026-05-11]: `npx tsc --noEmit` local pasa pero CI falla con errores TS2724/TS2305 sobre tipos eliminados. Causa: `.next/types/*.d.ts` y `tsconfig.tsbuildinfo` cachean los types del estado anterior; CI clona limpio y ve los errores reales. Fix: cuando se modifican imports/exports masivos en `lib/*/tipos.ts` (refactor de tipos, eliminación de interfaces, codegen openapi-typescript), borrar cache antes de validar local: `cd frontend && rm -rf .next && npx tsc --noEmit`. Si CI falla con TSC y local pasa: 90% de las veces es cache stale. Reforzado masivamente en F2/F3 de SIGM Opción C — 6+ archivos generados con tipos backend-first detectaron diff que el cache local ocultaba.

**Override de `brace-expansion@^5` rompe minimatch (eslint depende)**: `npm install` aplica el override pero `eslint . --ext .ts,.tsx` falla con `TypeError: expand is not a function` en `@eslint/config-array → minimatch → brace-expansion`. Causa: brace-expansion v3+ cambió la API (de export default `expand` function a export con shape distinto); minimatch espera la API v2. Fix: para CVE-2025-5889 (ReDoS), usar `"brace-expansion": "^2.0.2"` (parcheado desde 2.0.2 — NO requiere v5). Validación pre-override: `npm ls brace-expansion` para revisar consumidores conocidos antes de bumpear major. Mismo patrón aplicable a otros overrides de transitives — validar cadena de consumers antes de bumpear major.

## Checklist de verificación

- [ ] "use client" solo en componentes con hooks, eventos o browser APIs
- [ ] fetch con manejo de errores HTTP y tipado de respuesta
- [ ] Server Actions con validación Zod antes de mutar
- [ ] Suspense boundaries alrededor de fetches independientes
- [ ] Metadata generada para páginas públicas (SEO)
- [ ] Middleware en rutas protegidas
- [ ] generateStaticParams para rutas dinámicas conocidas en build time

## Referencias

- [Next.js App Router Docs](https://nextjs.org/docs/app)
- [Next.js Caching](https://nextjs.org/docs/app/building-your-application/caching)

---
*Skill creado con swl:crear-skill el 2026-03-31. Versión 1.0.0.*
