skill · sumi-react · v0.6.1
Next.js App Router
Arquitectura del App Router de Next.js: Server Components por defecto, fronteras de cliente en las hojas, grupos de rutas y layouts anidados, archivos loading/error/not-found, Metadata API, route handlers, proxy (antes middleware), entorno y configuración, y convenciones de carpetas. Úsala al crear o reestructurar rutas, layouts o páginas; al tocar app/**, src/app/**, page.tsx, layout.tsx, route.ts, loading.tsx, error.tsx, not-found.tsx, proxy.ts o middleware.ts, next.config.*, .env*; al decidir dónde va una directiva 'use client'; o al revisar la estructura de un proyecto Next.js.
- Referencia
- sumi-react
- skill
- next-app-router
- source
- plugins/sumi-react/skills/next-app-router/SKILL.md
Objetivo: Next.js 16 + React 19.2, TypeScript en modo estricto. Los valores por defecto de abajo asumen solo App Router (sin pages/). Para datos y caché consulta next-data-caching; para el interior de los componentes consulta react-components.
Principios
- Servidor por defecto. Todo archivo de
app/es un Server Component mientras no se demuestre lo contrario. El código de servidor no pesa en el bundle; el código de cliente lo paga cada visitante. - Las fronteras de cliente son hojas.
'use client'marca un punto de entrada al grafo de cliente, no "este archivo usa React". Empújalo hacia abajo, hasta la isla interactiva más pequeña. - El sistema de archivos es el enrutador, no la arquitectura.
app/contiene archivos de enrutado y composición delgada. La lógica de dominio vive ensrc/features/*osrc/lib/*. - Cada segmento es dueño de sus estados. La carga, el error y el not-found se diseñan por segmento, no se añaden al final de forma global.
- Falla cerrado en el servidor. La autenticación, la validación y los secretos se aplican en código de servidor (los layouts no son una frontera de seguridad; mira más abajo).
Convenciones de carpetas
src/
app/
(marketing)/ # route group: own layout, no URL segment
layout.tsx
page.tsx
(app)/
layout.tsx # authed shell
dashboard/
page.tsx
loading.tsx
error.tsx
_components/ # private folder: colocated, never routable
api/webhooks/stripe/route.ts
layout.tsx # root: <html>, <body>, fonts, providers
not-found.tsx
global-error.tsx
features/<domain>/ # queries.ts, actions.ts, schema.ts, components/
lib/ # db, auth, env, utils (server-only where relevant)
components/ui/ # design-system primitives (tokens from DESIGN.md)
proxy.ts # only if truly needed
- Coloca los componentes exclusivos de una ruta en
_components/; promuévelos afeatures/cuando una segunda ruta los necesite. - Marca los módulos solo de servidor con
import 'server-only'(clientes de base de datos, firebase-admin, secretos). Marca los módulos solo de navegador conimport 'client-only'. - Árbol completo con su justificación:
references/structure.md.
Frontera servidor-cliente
ASÍ SÍ: mantén la página en el servidor y pasa datos serializables a una hoja de cliente pequeña:
// app/(app)/products/[id]/page.tsx (Server Component)
export default async function Page({ params }: PageProps<'/products/[id]'>) {
const { id } = await params; // params is async (Next 15+)
const product = await getProduct(id);
if (!product) notFound();
return (
<article>
<h1>{product.name}</h1>
<ProductGallery images={product.images} /> {/* server */}
<AddToCartButton productId={product.id} /> {/* 'use client' leaf */}
</article>
);
}
ASÍ NO: 'use client' en la página "porque un botón necesita onClick". Eso envía al navegador todo el árbol, sus dependencias y la lógica de su marcado.
- Pasa Server Components dentro de componentes de cliente mediante
childreno props de ranura; el componente de cliente nunca los importa. - Las props que cruzan la frontera deben ser serializables: nada de funciones (salvo Server Actions), instancias de clase, ni un
Dateque esperes que siga siendoDatesin cuidado. - Los proveedores de contexto son componentes de cliente: envuélvelos en un único
app/providers.tsxy renderízalo lo más profundo posible (no alrededor de<html>si solo(app)lo necesita). - Los ayudantes globales
PageProps/LayoutPropsvienen de las rutas tipadas; verifica su disponibilidad y el indicador (typedRoutes) en la documentación actual; si no, tipaparams: Promise<{ id: string }>.
Layouts, grupos, plantillas
- Los layouts persisten entre navegaciones y no se vuelven a renderizar; pon ahí la interfaz de la carcasa y los proveedores, nunca datos por petición en los que el hijo deba confiar.
- Los layouts no son puertas de autenticación. Una comprobación en un layout no se vuelve a ejecutar en la navegación de cliente entre hermanos, y a las páginas, acciones y route handlers se puede llegar directamente. Comprueba la autenticación en la capa de acceso a datos y en cada Server Action.
- Usa grupos de rutas
(name)para carcasas separadas (marketing frente a app) y para varios layouts raíz. - Usa
template.tsxsolo cuando necesites remontar en cada navegación (animaciones de entrada, reinicio del estado por página). - Las rutas paralelas (
@slot) necesitan undefault.tsxexplícito en Next 16. Úsalas para modales con rutas interceptoras(.)photo/[id], no para la composición corriente.
Loading, error, not-found
loading.tsx= una frontera de Suspense automática para el segmento. Hazlo un esqueleto fiel al layout (mismas dimensiones) para evitar CLS. Prefiere<Suspense>granulares dentro de la página para las partes lentas.error.tsxdebe ser'use client'; recibeerroryreset. Muestra un mensaje comprensible, registraerror.digest, y nunca muestres a los usuarios elerror.messagedel servidor.global-error.tsxreemplaza el layout raíz: incluye<html>y<body>.- Llama a
notFound()desdenext/navigationcuando falte un recurso; da estilo anot-found.tsxpor segmento donde el contexto importe. redirect()lanza una excepción: no lo envuelvas en untry/catchque se la trague.
Metadatos
export async function generateMetadata({ params }: PageProps<'/blog/[slug]'>): Promise<Metadata> {
const post = await getPost((await params).slug); // dedupe with React cache()
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: { images: [post.ogImage] },
};
}
- Define
metadataBasey untitle.templateen el layout raíz. - Usa las convenciones de archivo:
opengraph-image.tsx,icon.tsx,sitemap.ts,robots.ts,manifest.ts. viewport/themeColorvan enexport const viewport, no enmetadata.- Define
<html lang>en el layout raíz (sumi:a11y).
Route handlers
- Usa
route.tspara webhooks, APIs públicas, callbacks de OAuth y respuestas de archivos o flujos. No construyas una capa REST interna para tus propias páginas: los Server Components leen los datos directamente, y las mutaciones pasan por Server Actions. - Los manejadores
GETson dinámicos por defecto (desde la 15). Valida la entrada con zod, devuelveResponse.json()tipado y fija códigos de estado explícitos. - Verifica las firmas de los webhooks antes de tratar el cuerpo como confiable.
Proxy (antes middleware)
- Next 16 renombra
middleware.tsaproxy.ts(verifica el runtime y las opciones de matcher en la documentación actual). Resérvalo para: redirecciones y reescrituras, negociación de idioma i18n, comprobaciones baratas de presencia de cookies, cabeceras. - NO hagas ahí obtención de datos, verificación de autenticación pesada ni llamadas a la base de datos. Se ejecuta en cada petición que coincide. Define siempre un
matcherestrecho que excluya_next/static, imágenes y recursos. - Una redirección optimista en el proxy está bien; la autorización real sigue ocurriendo en el servidor, junto a los datos.
Entorno y configuración
- Un único
src/lib/env.tsvalidaprocess.envcon zod al arrancar; importa desde él y nunca leasprocess.envde forma improvisada. NEXT_PUBLIC_*se incrusta en los bundles de cliente en tiempo de compilación: nunca pongas secretos ahí; trata su valor como público.- Mantén
next.config.tstipado (NextConfig), mínimo y comentado cuando un indicador no sea obvio (cacheComponents,reactCompiler,images.remotePatterns). - Turbopack es el empaquetador por defecto en la 16; evita la configuración exclusiva de webpack salvo que sea necesaria.
- Firebase: el SDK de cliente solo en componentes de cliente;
firebase-admindetrás deserver-only. La verificación de sesión ocurre en el servidor.
Navegación
<Link>para la navegación interna (la precarga es automática en producción).useRoutersolo para casos imperativos tras un evento.useSearchParamsfuerza el renderizado en cliente hasta el Suspense más cercano: envuelve al consumidor en<Suspense>. En el servidor, leesearchParamsde las props de la página.- Usa
next/formpara los formularios de búsqueda o filtro que actualizan la URL.
Señales de relleno de IA
'use client'al principio de todos los archivos, incluidas páginas y layouts.useEffect(() => fetch('/api/...'))en un componente de cliente para cargar datos que el servidor podría haber renderizado.- Una ruta
app/api/*por cada consulta, llamada desde tus propios Server Components. - Autenticación comprobada solo en un layout o solo en el proxy.
- Leer
paramsde forma síncrona, oanypara las props de página. - Un único
layout.tsxgigante con todos los proveedores envolviendo<html>. loading.tsxcon un spinner centrado que desplaza todo el layout.process.env.X!esparcido por los componentes.
Lista de verificación
- Ningún
'use client'en páginas o layouts salvo que se justifique en un comentario; las islas de cliente son hojas. - Los módulos solo de servidor importan
server-only; sin secretos enNEXT_PUBLIC_*. - Cada segmento nuevo tiene un estado de carga, una frontera de error y una ruta not-found deliberados.
- Metadatos (título, descripción, canonical, OG) definidos;
langen<html>. - La autenticación y la autorización se aplican en el acceso a datos y en las acciones, no solo en el layout o el proxy.
-
params/searchParamscon await y tipados; sinany. - El proxy (si existe) tiene un matcher estrecho y no obtiene datos.
- Contrastado con
sumi:a11y,sumi:performance,sumi:js-ts.
Referencia de estructura del proyecto
Organización con criterio para una aplicación Next.js de escala freelance (sitio de marketing + aplicación autenticada), con backend Firebase o SQL.
.
├── next.config.ts
├── proxy.ts # optional; redirects/i18n only
├── src/
│ ├── app/
│ │ ├── layout.tsx # <html lang>, <body>, next/font, metadataBase
│ │ ├── not-found.tsx
│ │ ├── global-error.tsx # 'use client', includes <html>/<body>
│ │ ├── sitemap.ts
│ │ ├── robots.ts
│ │ ├── (marketing)/
│ │ │ ├── layout.tsx # header/footer, no auth providers
│ │ │ ├── page.tsx
│ │ │ └── blog/[slug]/
│ │ │ ├── page.tsx
│ │ │ └── opengraph-image.tsx
│ │ ├── (auth)/
│ │ │ ├── login/page.tsx
│ │ │ └── layout.tsx
│ │ ├── (app)/
│ │ │ ├── layout.tsx # app shell + providers.tsx
│ │ │ ├── providers.tsx # 'use client': theme, toasts, query client if any
│ │ │ └── projects/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx
│ │ │ ├── error.tsx
│ │ │ ├── [id]/page.tsx
│ │ │ └── _components/
│ │ │ ├── project-table.tsx
│ │ │ └── project-filters.tsx # 'use client'
│ │ └── api/
│ │ └── webhooks/stripe/route.ts
│ ├── features/
│ │ └── projects/
│ │ ├── schema.ts # zod schemas + inferred types (shared)
│ │ ├── queries.ts # 'server-only'; reads, auth-scoped, cached
│ │ ├── actions.ts # 'use server'; mutations
│ │ └── components/ # domain components reused across routes
│ ├── components/
│ │ └── ui/ # primitives (Button, Dialog...) built on Radix/React Aria + DESIGN.md tokens
│ ├── lib/
│ │ ├── env.ts # zod-validated env
│ │ ├── auth.ts # 'server-only'; getSession(), requireUser()
│ │ ├── db.ts | firebase-admin.ts# 'server-only'
│ │ ├── firebase-client.ts # 'client-only'
│ │ └── utils.ts # cn(), formatters
│ └── styles/globals.css # tokens + Tailwind layers (sumi:css-architecture)
└── tests/
└── e2e/ # Playwright
Reglas detrás de la organización
- Los archivos de
app/se mantienen delgados: obtén datos víafeatures/*/queries.ts, renderiza componentes de dominio, y listo. Una página de más de ~80 líneas es una señal de alarma. features/<domain>es la unidad de propiedad. Las importaciones entre funcionalidades pasan por los archivos públicos de la funcionalidad (queries.ts,actions.ts,components/index.ts), no por rutas profundas.queries.tses la capa de acceso a datos: cada función resuelve al usuario actual y autoriza antes de devolver datos. Devuelve DTOs (solo los campos que necesita la interfaz), nunca documentos crudos de la base de datos con campos internos.components/uino sabe nada de dominios. Los componentes de dominio no saben nada de enrutado.- Las pruebas viven junto al código (
*.test.ts(x)), excepto las E2E. - Alias de ruta:
@/*->src/*. Nada de../../../.
Nomenclatura
- Archivos: kebab-case (
project-table.tsx). Componentes: exportaciones en PascalCase. Un componente exportado por archivo; se permiten pequeños ayudantes privados. - Acciones: verbo primero (
createProject,archiveProject). Consultas:getX,listX. - Los grupos de rutas nombran la carcasa, no la funcionalidad:
(marketing),(app),(auth).
Esbozo de env.ts
import 'server-only';
import { z } from 'zod';
const schema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().min(1),
NEXT_PUBLIC_SITE_URL: z.string().url(),
});
export const env = schema.parse(process.env);
Los valores visibles para el cliente necesitan un módulo aparte que lea cada NEXT_PUBLIC_* de forma explícita (el acceso estático es necesario para la incrustación).