Módulo 25: Arquitectura y despliegue
Objetivos del módulo
- Organizar un proyecto por funcionalidades y hacer cumplir sus fronteras con el linter
- Elegir entre
children, slots, render props, hooks y componentes compuestos - Escribir compound components accesibles con Context
- Distinguir lo que se fija en la compilación de lo que se fija al arrancar, y no filtrar secretos
- Entender qué genera
vite build: hash, trozos, source maps y la opciónbase - Empaquetar la aplicación en una imagen Docker de dos etapas servida por Nginx
- Configurar Nginx para una SPA:
try_files, caché, compresión, proxy a la API y cabeceras - Sobrevivir a un despliegue con pestañas abiertas
- Publicar en GitHub Pages sin que las rutas internas den un 404 visible
- Montar integración continua con GitHub Actions
- Repasar la seguridad de una aplicación React y capturar los errores que ocurren en producción
💡 Consejo: Casi todo este módulo es independiente de la versión de React: Vite, Docker, Nginx y GitHub Actions no dependen de ella. Solo llevan marca tres apartados. El proyecto de ejemplo usa React Router 8, que exige React 19.2.7 o superior. Todo lo que ves aquí se ha ejecutado: la imagen se ha construido y probado con Docker, y las salidas son reales. El único paso que no se ha ejecutado en la propia plataforma es el flujo de GitHub Actions, como se explica en la sección 9.
1. Arquitectura: organizar por funcionalidades
Con diez componentes da igual dónde guardes los ficheros. Con doscientos, la estructura decide cuánto tardas en encontrar algo y cuántos ficheros tocas para hacer un cambio. Este módulo gira en torno a un proyecto pequeño pero completo, empresa-web, con listado de empleados, ficha, departamentos y una página con los datos de la versión desplegada.
Prepara el proyecto
Crea el proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:
npm create vite@latest empresa-web -- --template react-ts
cd empresa-web
npm install react-router zod
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom @types/node
Descarga la API de ejemplo y déjala arrancada en otra terminal con node server.mjs. Queda en http://localhost:3000/api.
Por tipo de fichero o por funcionalidad
La primera organización que se le ocurre a casi todo el mundo agrupa por tipo:
src/
components/ 60 ficheros de 12 pantallas distintas
hooks/
services/
types/
pages/
Funciona al principio, pero tiene un defecto: lo que cambia junto vive separado. Para añadir un campo al empleado abres types/, services/, hooks/, components/ y pages/. Nada te dice qué fichero pertenece a qué pantalla, ni qué puedes borrar sin romper otra cosa.
La alternativa agrupa por funcionalidad (feature): todo lo que necesita una parte del negocio vive en una carpeta.
empresa-web/
src/
main.tsx arranque
index.css
vite-env.d.ts
app/ lo que une la aplicación
router.tsx
Disposicion.tsx
PaginaAcerca.tsx
PaginaError.tsx
features/
departamentos/
index.ts la cara pública de la funcionalidad
api.ts
rutas.ts
PaginaDepartamentos.tsx
empleados/
index.ts
api.ts
rutas.ts
PaginaEmpleados.tsx
PaginaEmpleado.tsx
shared/ lo que no sabe nada del negocio
api/cliente.ts
config/entorno.ts
errores/errores.ts
ui/Pestanas.tsx
ui/TablaDatos.tsx
pruebas/setup.ts
| Criterio | Por tipo | Por funcionalidad |
|---|---|---|
| Añadir un campo a una entidad | Cinco carpetas | Una carpeta |
| Borrar una funcionalidad | Hay que rastrear ficheros | Se borra una carpeta |
| Saber quién usa qué | Hay que buscar | Lo dice index.ts |
| Dividir el trabajo entre equipos | Conflictos constantes | Cada equipo en su carpeta |
| Proyecto de tres pantallas | Suficiente | Exagerado |
Tres reglas que dan sentido a la estructura
Las carpetas por sí solas no arreglan nada. Lo que hace que esta organización funcione son tres reglas.
sharedno importa defeaturesni deapp. Si un componente “genérico” necesita saber qué es un empleado, no es genérico.featuresno importa deapp. El router conoce las funcionalidades, y no al revés.- Una funcionalidad solo usa de otra lo que exporte su
index.ts. Ese fichero es su contrato. Todo lo demás se puede reorganizar por dentro sin avisar a nadie.
Figura 1. Las dependencias van en una sola dirección. Entre funcionalidades, solo a través de index.ts.
Aquí está la cara pública de cada funcionalidad. empleados solo publica sus rutas; departamentos publica además una función que empleados necesita:
export { rutasEmpleados } from './rutas';
export { obtenerDepartamento } from './api';
export { rutasDepartamentos } from './rutas';
export type { Departamento } from './api';
Un alias para no contar ../../../
Las importaciones entre carpetas lejanas se leen mejor con un alias, como @/shared/ui/TablaDatos. Hay que declararlo en dos sitios, porque TypeScript y Vite resuelven por separado. En tsconfig.app.json:
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
Y en Vite basta con pedirle que lea esas rutas, con resolve: { tsconfigPaths: true }. Lo verás en la configuración completa de la sección 4.
Hacer cumplir las fronteras con el linter
Una regla que depende de la memoria de cada persona se rompe en la primera entrega con prisas. Conviene que falle sola. La regla no-restricted-imports de oxlint, que ya usas desde el módulo 01, rechaza importar ficheros internos de una funcionalidad. Este es el .oxlintrc.json completo:
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"react",
"typescript",
"oxc"
],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": [
"warn",
{
"allowConstantExport": true,
"allowExportNames": [
"loader",
"Component"
]
}
],
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"@/features/*/*"
],
"message": "Importa la funcionalidad por su index.ts, no por sus ficheros internos."
}
]
}
]
},
"ignorePatterns": [
"api",
"dist"
]
}
allowExportNames evita el aviso de Fast Refresh en las páginas, que exportan loader junto al componente. ignorePatterns deja fuera dist y la carpeta api, donde pondrás la API en la sección 6.
Con esta regla, import { obtenerDepartamento } from '@/features/departamentos/api' da un error de no-restricted-imports, mientras que from '@/features/departamentos' pasa.
💡 Consejo: La regla
groupprotege la frontera entre funcionalidades, y dentro de una funcionalidad se usan rutas relativas (./api), que no le afectan. Por eso escribe@/features/*/*y no prohíbe nada dentro de la propia carpeta.
Las rutas también pertenecen a la funcionalidad
Cada funcionalidad declara sus rutas con carga perezosa, como viste en el módulo 18. El módulo importado exporta loader y Component:
import type { RouteObject } from 'react-router';
export const rutasEmpleados: RouteObject[] = [
{ path: 'empleados', lazy: () => import('./PaginaEmpleados') },
{ path: 'empleados/:id', lazy: () => import('./PaginaEmpleado') },
];
import type { RouteObject } from 'react-router';
export const rutasDepartamentos: RouteObject[] = [
{ path: 'departamentos', lazy: () => import('./PaginaDepartamentos') },
];
Y app/router.tsx solo las reúne. Fíjate en la opción basename: la necesitarás en la sección 8.
import { createBrowserRouter, Navigate } from 'react-router';
import { rutasDepartamentos } from '@/features/departamentos';
import { rutasEmpleados } from '@/features/empleados';
import { Disposicion } from './Disposicion';
import { PaginaAcerca } from './PaginaAcerca';
import { PaginaError } from './PaginaError';
export const router = createBrowserRouter(
[
{
path: '/',
element: <Disposicion />,
errorElement: <PaginaError />,
hydrateFallbackElement: <p>Cargando...</p>,
children: [
{ index: true, element: <Navigate to="/empleados" replace /> },
...rutasEmpleados,
...rutasDepartamentos,
{ path: 'acerca', element: <PaginaAcerca /> },
],
},
],
{ basename: import.meta.env.BASE_URL },
);
hydrateFallbackElement es lo que se ve mientras llega el código de la primera ruta. Sin él, al entrar directamente en una ruta perezosa, React Router escribe en la consola No HydrateFallback element provided. Lo hemos visto en la prueba real de la imagen.
⚠️ Importante: El fichero
index.tstiene una trampa. Si además reexportas la página (export { Component as PaginaEmpleados } from './PaginaEmpleados') y alguien la importa de forma estática, la división de código desaparece. Lo hemos probado: el ficheroPaginaEmpleados-*.jsdeja de generarse, y Vite avisa conINEFFECTIVE_DYNAMIC_IMPORT: el fichero se importa a la vez de forma dinámica y estática, así que elimport()ya no sirve de nada. Por eso elindex.tspublicarutasEmpleados, que solo contiene funcionesimport(), y nunca las páginas.
El resto del código compartido
Para que el proyecto se pueda ejecutar, aquí van los ficheros de apoyo. El cliente de la API usa la configuración de la sección 4:
import { config } from '@/shared/config/entorno';
export type Pagina<T> = {
contenido: T[];
pagina: number;
tamano: number;
total: number;
paginas: number;
};
export class ErrorApi extends Error {
readonly status: number;
constructor(status: number, mensaje: string) {
super(mensaje);
this.name = 'ErrorApi';
this.status = status;
}
}
export async function pedirJson<T>(ruta: string, signal?: AbortSignal): Promise<T> {
const respuesta = await fetch(`${config.apiUrl}${ruta}`, { signal });
if (!respuesta.ok) {
throw new ErrorApi(respuesta.status, `GET ${ruta}: ${respuesta.status}`);
}
return (await respuesta.json()) as T;
}
La disposición general y las dos páginas de app/:
import { NavLink, Outlet } from 'react-router';
import { config } from '@/shared/config/entorno';
export function Disposicion() {
return (
<>
<header className="cabecera">
<strong>{config.nombreApp}</strong>
<nav aria-label="Principal">
<NavLink to="/empleados">Empleados</NavLink>{' '}
<NavLink to="/departamentos">Departamentos</NavLink>{' '}
<NavLink to="/acerca">Acerca de</NavLink>
</nav>
</header>
<main>
<Outlet />
</main>
</>
);
}
import { config } from '@/shared/config/entorno';
export function PaginaAcerca() {
return (
<section className="tarjeta">
<h1>Acerca de esta versión</h1>
<dl>
<dt>Versión</dt>
<dd>{config.version}</dd>
<dt>Commit</dt>
<dd>{config.commit}</dd>
<dt>Modo</dt>
<dd>{import.meta.env.MODE}</dd>
<dt>API</dt>
<dd>{config.apiUrl}</dd>
</dl>
</section>
);
}
import { isRouteErrorResponse, Link, useRouteError } from 'react-router';
export function PaginaError() {
const error = useRouteError();
const titulo = isRouteErrorResponse(error)
? `${error.status}: ${error.statusText}`
: 'Algo ha ido mal';
return (
<main>
<section className="tarjeta" role="alert">
<h1>{titulo}</h1>
<p>
<Link to="/">Volver al inicio</Link>
</p>
</section>
</main>
);
}
Sustituye también src/index.css por estas reglas, y borra src/App.tsx y src/App.css:
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
}
.cabecera {
display: flex;
gap: 1rem;
align-items: baseline;
padding: 0.75rem 1.25rem;
background: #1a237e;
color: #fff;
}
.cabecera strong {
font-size: 1.1rem;
}
.cabecera a {
color: #c5cae9;
}
.cabecera a.active {
color: #fff;
font-weight: 600;
}
main {
padding: 1.25rem;
max-width: 760px;
}
.tarjeta {
background: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
margin: 0 0 1rem;
}
.tarjeta h1 {
margin: 0 0 0.75rem;
font-size: 1.25rem;
color: #1a237e;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid #eceff1;
}
input,
button {
font: inherit;
padding: 0.35rem 0.6rem;
border: 1px solid #3f51b5;
border-radius: 6px;
}
button {
background: #fff;
color: #283593;
cursor: pointer;
}
.barra {
height: 0.5rem;
background: #3f51b5;
border-radius: 4px;
}
[role='tablist'] {
display: flex;
gap: 0.25rem;
border-bottom: 2px solid #c5cae9;
margin-bottom: 0.75rem;
}
[role='tab'] {
border: 0;
border-radius: 6px 6px 0 0;
}
[role='tab'][aria-selected='true'] {
background: #3f51b5;
color: #fff;
}
2. Patrones de composición
La composición es la herramienta principal de React para reutilizar. Un síntoma de que falta composición es el componente con una lista de props que no para de crecer:
<Tarjeta
titulo="Empleados"
iconoTitulo="personas"
mostrarBoton
textoBoton="Añadir"
alPulsarBoton={abrir}
botonDeshabilitado={!puedeCrear}
pie="24 resultados"
pieAlineado="derecha"
/>
Cada necesidad nueva añade una prop y un if dentro. A los pocos meses nadie se atreve a tocarlo. El patrón children y sus variantes resuelven esto dejando que quien usa el componente decida qué va dentro.
Composición con children y slots
Con children, el contenedor deja de saber qué contiene. Cuando hay más de un hueco, cada hueco es una prop que recibe JSX. Eso se llama slot:
type PropsTarjeta = {
titulo: ReactNode;
acciones?: ReactNode;
pie?: ReactNode;
children: ReactNode;
};
function Tarjeta({ titulo, acciones, pie, children }: PropsTarjeta) {
return (
<section className="tarjeta">
<header>
<h1>{titulo}</h1>
{acciones}
</header>
{children}
{pie && <footer>{pie}</footer>}
</section>
);
}
<Tarjeta titulo="Empleados" acciones={<button onClick={abrir}>Añadir</button>} pie="24 resultados">
<ListaEmpleados />
</Tarjeta>;
Además de ser más flexible, evita el prop drilling: ListaEmpleados se crea donde están sus datos, y Tarjeta no tiene que pasarle nada.
Render props: cuando el padre necesita datos del hijo
Un slot normal se escribe antes de que el componente se ejecute. A veces quien usa el componente necesita decidir qué pintar con un dato que solo el componente conoce. Para eso se pasa una función. TablaDatos no sabe qué es un empleado: sabe recorrer filas, y pregunta a cada columna cómo se pinta su celda.
import type { Key, ReactNode } from 'react';
export type Columna<T> = {
titulo: string;
celda: (fila: T) => ReactNode;
};
type Props<T> = {
filas: T[];
columnas: Columna<T>[];
clave: (fila: T) => Key;
vacio?: ReactNode;
};
export function TablaDatos<T>({ filas, columnas, clave, vacio = 'Sin resultados' }: Props<T>) {
if (filas.length === 0) return <p>{vacio}</p>;
return (
<table>
<thead>
<tr>
{columnas.map((columna) => (
<th key={columna.titulo} scope="col">
{columna.titulo}
</th>
))}
</tr>
</thead>
<tbody>
{filas.map((fila) => (
<tr key={clave(fila)}>
{columnas.map((columna) => (
<td key={columna.titulo}>{columna.celda(fila)}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Así se usa en la funcionalidad de empleados. Primero su acceso a datos:
import { pedirJson } from '@/shared/api/cliente';
import type { Pagina } from '@/shared/api/cliente';
export type Empleado = {
empId: number;
nombre: string;
apellidos: string;
email: string;
telefono: string;
oficio: string;
salario: number;
deptId: number;
};
export function listarEmpleados(busqueda: string, signal?: AbortSignal) {
const parametros = new URLSearchParams({ q: busqueda, orden: '-salario', tamano: '8' });
return pedirJson<Pagina<Empleado>>(`/empleados?${parametros}`, signal);
}
export function obtenerEmpleado(id: number, signal?: AbortSignal) {
return pedirJson<Empleado>(`/empleados/${id}`, signal);
}
Y la página, con columnas que devuelven un enlace, un texto o una barra:
import { Form, Link, useLoaderData, useSearchParams } from 'react-router';
import type { LoaderFunctionArgs } from 'react-router';
import { TablaDatos } from '@/shared/ui/TablaDatos';
import { listarEmpleados } from './api';
import type { Empleado } from './api';
export function loader({ request }: LoaderFunctionArgs) {
const busqueda = new URL(request.url).searchParams.get('q') ?? '';
return listarEmpleados(busqueda, request.signal);
}
export function Component() {
const { contenido, total } = useLoaderData<typeof loader>();
const [parametros] = useSearchParams();
const salarioMaximo = Math.max(...contenido.map((empleado) => empleado.salario), 1);
return (
<section className="tarjeta">
<h1>Empleados ({total})</h1>
<Form role="search">
<input name="q" aria-label="Buscar" defaultValue={parametros.get('q') ?? ''} />{' '}
<button type="submit">Buscar</button>
</Form>
<TablaDatos<Empleado>
filas={contenido}
clave={(empleado) => empleado.empId}
columnas={[
{
titulo: 'Nombre',
celda: (empleado) => (
<Link to={`/empleados/${empleado.empId}`}>
{empleado.nombre} {empleado.apellidos}
</Link>
),
},
{ titulo: 'Oficio', celda: (empleado) => empleado.oficio },
{
titulo: 'Salario',
celda: (empleado) => (
<div
className="barra"
title={`${empleado.salario} €`}
style={{ width: `${(empleado.salario / salarioMaximo) * 100}%` }}
/>
),
},
]}
/>
</section>
);
}
El genérico de TablaDatos<Empleado> ya lo viste en el módulo 13. Gracias a él, empleado está tipado en cada columna sin escribirlo.
La funcionalidad de departamentos sigue el mismo esquema:
import { pedirJson } from '@/shared/api/cliente';
import type { Pagina } from '@/shared/api/cliente';
export type Departamento = {
deptId: number;
nombre: string;
localidad: string;
presupuesto: number;
};
export function listarDepartamentos(signal?: AbortSignal) {
return pedirJson<Pagina<Departamento>>('/departamentos?orden=nombre', signal);
}
export function obtenerDepartamento(id: number, signal?: AbortSignal) {
return pedirJson<Departamento>(`/departamentos/${id}`, signal);
}
import { useLoaderData } from 'react-router';
import type { LoaderFunctionArgs } from 'react-router';
import { TablaDatos } from '@/shared/ui/TablaDatos';
import { listarDepartamentos } from './api';
import type { Departamento } from './api';
export function loader({ request }: LoaderFunctionArgs) {
return listarDepartamentos(request.signal);
}
export function Component() {
const { contenido } = useLoaderData<typeof loader>();
return (
<section className="tarjeta">
<h1>Departamentos</h1>
<TablaDatos<Departamento>
filas={contenido}
clave={(departamento) => departamento.deptId}
columnas={[
{ titulo: 'Nombre', celda: (departamento) => departamento.nombre },
{ titulo: 'Localidad', celda: (departamento) => departamento.localidad },
]}
/>
</section>
);
}
Hooks personalizados: lógica sin interfaz
Si lo que quieres reutilizar es comportamiento y no marcado, la respuesta es un hook, como en el módulo 12. Muchas render props antiguas, del tipo <Raton>{(pos) => ...}</Raton>, hoy son simplemente useRaton().
Llevado al extremo, esto da los componentes sin interfaz (headless): una biblioteca aporta el estado, el teclado y la accesibilidad, y tú pones el marcado y los estilos.
Higher-Order Components
Un HOC es una función que recibe un componente y devuelve otro con algo añadido:
function conPermiso<P extends object>(Componente: ComponentType<P>, rol: string) {
return function ConPermiso(props: P) {
const usuario = useUsuario();
if (usuario.rol !== rol) return <p>No tienes permiso</p>;
return <Componente {...props} />;
};
}
Lo verás en código antiguo y en alguna biblioteca. Hoy casi siempre es mejor un hook o un componente envoltorio: con varios HOC anidados no se ve de dónde viene cada prop, y sus tipos son incómodos.
Cómo elegir
| Necesidad | Patrón | Ejemplo |
|---|---|---|
| Un hueco para contenido libre | children | <Tarjeta>...</Tarjeta> |
| Varios huecos con nombre | Slots (props con JSX) | acciones={<button />} |
| El contenido depende de datos del componente | Render prop | celda={(fila) => ...} |
| Reutilizar lógica sin marcado | Hook | usePestanas() |
| Varias piezas que comparten estado implícito | Compound components | <Pestanas.Lista> |
| Añadir comportamiento a un componente ajeno | HOC, solo si no hay otra salida | conPermiso(Panel) |
3. Compound components
Piensa en un <select> con sus <option>. Por separado no sirven de mucho, pero juntos forman un control completo y comparten un estado que tú no ves: cuál está seleccionado. Eso es un componente compuesto (compound component): un grupo de piezas pensadas para usarse juntas, que se comunican por dentro.
El problema de configurarlo todo con props
La versión ingenua de unas pestañas recibe un array:
<Pestanas
pestanas={[
{ id: 'puesto', titulo: 'Puesto', contenido: <Puesto /> },
{ id: 'contacto', titulo: 'Contacto', contenido: <Contacto />, deshabilitada: true },
]}
/>
Funciona hasta que alguien pide un icono solo en una pestaña, un botón a la derecha de la lista o un texto entre la lista y los paneles. Cada petición añade otra opción al componente. Con piezas compuestas, ese marcado lo decide quien lo usa:
<Pestanas.Raiz valorInicial="puesto">
<Pestanas.Lista etiqueta="Ficha del empleado">
<Pestanas.Pestana valor="puesto">Puesto</Pestanas.Pestana>
<Pestanas.Pestana valor="contacto">Contacto</Pestanas.Pestana>
</Pestanas.Lista>
<Pestanas.Panel valor="puesto">...</Pestanas.Panel>
<Pestanas.Panel valor="contacto">...</Pestanas.Panel>
</Pestanas.Raiz>
Pestana y Panel no reciben el estado por props: lo leen de un Context que crea Raiz. El Context ya lo conoces del módulo 11. Aquí se usa a pequeña escala: es privado, porque no se exporta y solo lo ven las piezas del grupo.
La implementación 🆕 19.0
🆕 Desde React 19.0.
<Contexto value>no existe en React 18.3 ni anteriores. Si usas una versión anterior: escribe<ContextoPestanas.Provider value={...}>, que sigue funcionando y hace lo mismo.
import { createContext, useContext, useId, useState } from 'react';
import type { KeyboardEvent, ReactNode } from 'react';
type ValorContexto = {
activa: string;
seleccionar: (valor: string) => void;
idBase: string;
};
const ContextoPestanas = createContext<ValorContexto | null>(null);
function usePestanas() {
const contexto = useContext(ContextoPestanas);
if (!contexto) {
throw new Error('Las piezas de Pestanas deben ir dentro de <Pestanas.Raiz>');
}
return contexto;
}
type PropsRaiz = { valorInicial: string; children: ReactNode };
export function Raiz({ valorInicial, children }: PropsRaiz) {
const [activa, seleccionar] = useState(valorInicial);
const idBase = useId();
return <ContextoPestanas value={{ activa, seleccionar, idBase }}>{children}</ContextoPestanas>;
}
type PropsLista = { etiqueta: string; children: ReactNode };
export function Lista({ etiqueta, children }: PropsLista) {
function alPulsarTecla(evento: KeyboardEvent<HTMLDivElement>) {
if (evento.key !== 'ArrowRight' && evento.key !== 'ArrowLeft') return;
const botones = [...evento.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
const actual = botones.findIndex((boton) => boton === document.activeElement);
const paso = evento.key === 'ArrowRight' ? 1 : -1;
const siguiente = botones[(actual + paso + botones.length) % botones.length];
siguiente.focus();
siguiente.click();
}
return (
<div role="tablist" aria-label={etiqueta} onKeyDown={alPulsarTecla}>
{children}
</div>
);
}
type PropsPestana = { valor: string; children: ReactNode };
export function Pestana({ valor, children }: PropsPestana) {
const { activa, seleccionar, idBase } = usePestanas();
const seleccionada = activa === valor;
return (
<button
type="button"
role="tab"
id={`${idBase}-pestana-${valor}`}
aria-selected={seleccionada}
aria-controls={`${idBase}-panel-${valor}`}
tabIndex={seleccionada ? 0 : -1}
onClick={() => seleccionar(valor)}
>
{children}
</button>
);
}
type PropsPanel = { valor: string; children: ReactNode };
export function Panel({ valor, children }: PropsPanel) {
const { activa, idBase } = usePestanas();
return (
<div
role="tabpanel"
id={`${idBase}-panel-${valor}`}
aria-labelledby={`${idBase}-pestana-${valor}`}
hidden={activa !== valor}
>
{children}
</div>
);
}
Merece la pena pararse en cuatro detalles:
- El Context empieza en
nully un hook lo comprueba. Si alguien ponePestanas.Panelfuera deRaiz, recibe un mensaje claro y no unundefinedmisterioso. TypeScript, además, deja de obligarte a comprobarnullen cada pieza. useIdda identificadores únicos, así que dos pestañas en la misma página no chocan. Con ellos se enlazanaria-controlsyaria-labelledby.- El teclado sigue el patrón de pestañas accesibles. Solo la pestaña activa está en el orden de tabulación, por el
tabIndex, y las flechas se mueven entre ellas. Lo ampliaremos con detalle en el módulo 24. - Los paneles no se desmontan, se ocultan con
hidden. Asíaria-controlssiempre apunta a algo que existe, y el estado de un formulario dentro de un panel no se pierde al cambiar de pestaña.
Por qué import * as
Aquí tienes la ficha del empleado usando las piezas. import * as Pestanas crea el espacio de nombres, y por eso se escribe Pestanas.Raiz:
import { Link, useLoaderData } from 'react-router';
import type { LoaderFunctionArgs } from 'react-router';
import { obtenerDepartamento } from '@/features/departamentos';
import * as Pestanas from '@/shared/ui/Pestanas';
import { obtenerEmpleado } from './api';
export async function loader({ params, request }: LoaderFunctionArgs) {
const empleado = await obtenerEmpleado(Number(params.id), request.signal);
const departamento = await obtenerDepartamento(empleado.deptId, request.signal);
return { empleado, departamento };
}
export function Component() {
const { empleado, departamento } = useLoaderData<typeof loader>();
return (
<section className="tarjeta">
<h1>
{empleado.nombre} {empleado.apellidos}
</h1>
<Pestanas.Raiz valorInicial="puesto">
<Pestanas.Lista etiqueta="Ficha del empleado">
<Pestanas.Pestana valor="puesto">Puesto</Pestanas.Pestana>
<Pestanas.Pestana valor="contacto">Contacto</Pestanas.Pestana>
<Pestanas.Pestana valor="departamento">Departamento</Pestanas.Pestana>
</Pestanas.Lista>
<Pestanas.Panel valor="puesto">
<p>
{empleado.oficio}, {empleado.salario} € al mes
</p>
</Pestanas.Panel>
<Pestanas.Panel valor="contacto">
<p>
{empleado.email}, {empleado.telefono}
</p>
</Pestanas.Panel>
<Pestanas.Panel valor="departamento">
<p>
{departamento.nombre} ({departamento.localidad})
</p>
</Pestanas.Panel>
</Pestanas.Raiz>
<p>
<Link to="/empleados">Volver al listado</Link>
</p>
</section>
);
}
También verás mucho esta otra forma, en la que las piezas cuelgan del componente principal:
export const Pestanas = Object.assign(Raiz, { Lista, Pestana, Panel });
Permite escribir <Pestanas valorInicial="...">, que se lee algo mejor. Tiene dos pegas. Lo primero, la regla only-export-components del linter protesta: al probarla aquí dio un aviso por cada componente del fichero, porque el fichero ya no exporta componentes directamente sino un objeto calculado. Lo segundo, en Server Components, que viste en el módulo 23, acceder a una propiedad de un componente de cliente no funciona igual que en cliente. El espacio de nombres con import * as no tiene ninguna de las dos pegas, y además deja al empaquetador eliminar las piezas que no uses.
Pruebas
Los componentes compuestos se prueban como los usaría una persona, con las consultas accesibles del módulo 22. Hacen falta dos ficheros. vitest.config.ts, con sus propias constantes porque Vitest no carga aquí vite.config.ts:
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
resolve: { tsconfigPaths: true },
define: {
__VERSION__: JSON.stringify('0.0.0'),
__COMMIT__: JSON.stringify('pruebas'),
},
test: {
environment: 'jsdom',
setupFiles: ['./src/pruebas/setup.ts'],
env: { VITE_API_URL: '/api' },
},
});
Y src/pruebas/setup.ts:
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(cleanup);
Las pruebas de Pestanas comprueban el clic, el teclado y el mensaje de error:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import * as Pestanas from './Pestanas';
function Ejemplo() {
return (
<Pestanas.Raiz valorInicial="datos">
<Pestanas.Lista etiqueta="Ficha">
<Pestanas.Pestana valor="datos">Datos</Pestanas.Pestana>
<Pestanas.Pestana valor="contacto">Contacto</Pestanas.Pestana>
</Pestanas.Lista>
<Pestanas.Panel valor="datos">Oficio y salario</Pestanas.Panel>
<Pestanas.Panel valor="contacto">Correo y teléfono</Pestanas.Panel>
</Pestanas.Raiz>
);
}
describe('Pestanas', () => {
it('muestra solo el panel activo y cambia con un clic', async () => {
const usuario = userEvent.setup();
render(<Ejemplo />);
expect(screen.getByRole('tabpanel')).toHaveTextContent('Oficio y salario');
await usuario.click(screen.getByRole('tab', { name: 'Contacto' }));
expect(screen.getByRole('tabpanel')).toHaveTextContent('Correo y teléfono');
});
it('se maneja con las flechas del teclado', async () => {
const usuario = userEvent.setup();
render(<Ejemplo />);
await usuario.tab();
expect(screen.getByRole('tab', { name: 'Datos' })).toHaveFocus();
await usuario.keyboard('{ArrowRight}');
expect(screen.getByRole('tab', { name: 'Contacto' })).toHaveAttribute('aria-selected', 'true');
await usuario.keyboard('{ArrowRight}');
expect(screen.getByRole('tab', { name: 'Datos' })).toHaveFocus();
});
it('avisa si una pieza se usa fuera de <Pestanas.Raiz>', () => {
const silenciar = console.error;
console.error = () => {};
expect(() => render(<Pestanas.Panel valor="datos">Suelto</Pestanas.Panel>)).toThrow(
'deben ir dentro de <Pestanas.Raiz>',
);
console.error = silenciar;
});
});
Errores típicos
- Recorrer
childrenconChildren.mapycloneElementpara inyectar props. Era la técnica clásica, pero se rompe en cuanto envuelves una pieza en un<div>o en otro componente. Context atraviesa cualquier nivel. - Crear el valor del Context sin pensar en quién se vuelve a renderizar. Aquí no importa, porque un grupo de pestañas es pequeño. Si cada pieza fuera cara, separa el Context, como viste en el módulo 11.
- Exportar el Context “por si acaso”. Las piezas son la API pública; el Context es un detalle interno.
4. Variables de entorno
La URL de la API no es la misma en tu ordenador que en producción. Lo que cambia entre entornos no debe estar escrito en el código. Vite lee esos valores de ficheros .env.
Cómo funciona en Vite
VITE_API_URL=/api
VITE_NOMBRE_APP=EMPRESA
Ese fichero, .env, va en la raíz del proyecto. En el código, el valor se lee así: import.meta.env.VITE_API_URL. Hay dos cosas que debes saber:
- Solo llegan al navegador las variables con prefijo
VITE_. Es una medida de seguridad: el resto se queda fuera del código generado. - No es una variable de verdad. En producción,
vite buildsustituyeimport.meta.env.VITE_API_URLpor su valor como texto. El JavaScript final contiene"/api". No hay ningún proceso leyendo el entorno en el navegador.
Además, Vite define sus propias constantes:
| Constante | Valor |
|---|---|
import.meta.env.MODE | El modo: development, production o el que pases con --mode |
import.meta.env.DEV | true con vite, false con vite build |
import.meta.env.PROD | Lo contrario de DEV |
import.meta.env.BASE_URL | El valor de base, por defecto / |
Qué fichero gana
Se pueden tener varios ficheros, y Vite los combina. Para no fiarnos de la memoria, hemos definido las mismas variables en varios sitios y hemos leído el resultado con vite build en modo production:
| Variable definida en | Valor obtenido |
|---|---|
Solo en .env | env |
.env y .env.production | production |
.env, .env.production y .env.local | production |
Todos, y además en el proceso (VITE_D=proceso npm run build) | proceso |
Por lo tanto, el orden de menos a más prioridad es: .env, .env.local, .env.[modo], .env.[modo].local y las variables del proceso. Fíjate en la tercera fila, que sorprende a mucha gente: .env.production gana a .env.local. Y fíjate en la cuarta, que es la que usarás en CI y en Docker.
| Fichero | ¿Va al repositorio? | Para qué |
|---|---|---|
.env | Sí | Valores por defecto sin nada sensible |
.env.example | Sí | Documenta qué variables existen |
.env.production | Sí | Valores de producción sin nada sensible |
.env.local, .env.*.local | No | Ajustes de tu máquina |
Este es el .env.example:
# Copia este fichero como .env.local y ajusta los valores.
# Todo lo que empiece por VITE_ acaba en el bundle: nunca pongas aquí un secreto.
VITE_API_URL=/api
VITE_NOMBRE_APP=EMPRESA
Y el .gitignore:
node_modules
dist
*.local
.env.*.local
Tipar las variables y las constantes
Sin ayuda, TypeScript ve import.meta.env.VITE_API_URL como any. Se arregla en src/vite-env.d.ts, donde además declaramos las dos constantes propias que usaremos enseguida:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_NOMBRE_APP: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare const __VERSION__: string;
declare const __COMMIT__: string;
Fallar pronto: validar en la compilación
Si falta una variable, import.meta.env.VITE_API_URL vale undefined, y la aplicación se publica rota: fetch('undefined/empleados'). Es mucho mejor que falle la compilación. Esta es la configuración completa de Vite:
import { readFileSync } from 'node:fs';
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
const paquete = JSON.parse(readFileSync('package.json', 'utf8')) as { version: string };
export default defineConfig(({ mode }) => {
// Lee .env, .env.[mode] y las variables del proceso con prefijo VITE_
const entorno = loadEnv(mode, process.cwd());
if (!entorno.VITE_API_URL) {
throw new Error(`Falta VITE_API_URL para el modo "${mode}". Revisa .env.example.`);
}
return {
plugins: [react()],
resolve: { tsconfigPaths: true },
define: {
__VERSION__: JSON.stringify(paquete.version),
__COMMIT__: JSON.stringify(process.env.GIT_COMMIT ?? 'local'),
},
server: {
proxy: { '/api': process.env.API_DESTINO ?? 'http://localhost:3000' },
},
};
});
Al borrar el fichero .env, esto es lo que se obtiene:
failed to load config from ...\vite.config.ts
Error: Falta VITE_API_URL para el modo "production". Revisa .env.example.
Hay tres ideas más en esa configuración:
definesustituye un identificador por un valor en todo el código. Así la aplicación sabe qué versión y qué commit está ejecutando. Se usaJSON.stringifyporque lo que se inserta es código: sin él,a1b2c3dsería un identificador y no una cadena.server.proxyhace que en desarrollo/apise reenvíe a la API. El navegador cree que todo viene del mismo origen, así que el código usa la misma URL relativa en desarrollo y en producción, y nunca hay CORS del que preocuparse. En producción ese trabajo lo hará Nginx.tsconfig.node.jsondebe incluirvite.config.tsyvitest.config.ts, ytsconfig.app.jsonnecesita"lib": ["ES2023", "DOM", "DOM.Iterable"]para poder recorrer con...lo que devuelvequerySelectorAll.
import.meta.env.DEV y el código que desaparece
Como las constantes se sustituyen antes de minificar, una condición falsa se convierte en código muerto y se elimina. Hemos compilado este fragmento y buscado las cadenas en los ficheros generados:
if (import.meta.env.DEV) console.log('solo-en-desarrollo');
if (import.meta.env.VITE_PANEL_NUEVO === 'true') console.log('panel-experimental');
| Compilación | solo-en-desarrollo | panel-experimental |
|---|---|---|
VITE_PANEL_NUEVO=false | No aparece | No aparece |
VITE_PANEL_NUEVO=true | No aparece | Aparece |
Esto es una bandera de funcionalidad en compilación: el código desactivado ni siquiera se publica, de modo que no se puede leer ni activar desde la consola.
Lo que no debes hacer: secretos en VITE_
⚠️ Importante: Todo lo que empiece por
VITE_y se use en el código se publica. Hemos añadido al.envuna clave de pago con prefijo y una contraseña sin prefijo:
VITE_CLAVE_PAGO=sk_live_123456
CLAVE_BD=supersecreta
Y las hemos leído en un componente. Tras compilar, buscando en dist/assets/*.js:
| Valor | ¿Aparece en el código publicado? |
|---|---|
sk_live_123456 (con prefijo) | Sí, en un fichero que cualquiera puede descargar |
supersecreta (sin prefijo) | No |
No hay forma de esconder un secreto en una aplicación que se ejecuta en el navegador. Minificar no es cifrar. Lo que deba ser secreto se queda en un servidor: el navegador llama a tu API, y es tu API quien usa la clave.
Una imagen para todos los entornos: configuración al arrancar
Las variables de Vite tienen otro límite. Si el valor se fija al compilar, necesitas una imagen distinta para pruebas y para producción. Eso va contra una buena práctica: lo que pruebas debe ser exactamente lo que publicas.
La solución es un fichero que se genera al arrancar el contenedor, y que el navegador carga antes que la aplicación. En index.html, sin type="module" para que se ejecute antes y Vite no intente empaquetarlo:
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EMPRESA</title>
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
En public/config.js hay una versión vacía para desarrollo:
// En desarrollo no hay nada que sobrescribir. En Docker, este fichero se regenera al arrancar.
window.__CONFIG__ = {};
La aplicación reúne todo en un único módulo, y valida con Zod (visto en el módulo 13):
import { z } from 'zod';
declare global {
interface Window {
__CONFIG__?: { apiUrl?: string };
}
}
const EsquemaConfig = z.object({
apiUrl: z.string().min(1, 'Falta la URL de la API'),
nombreApp: z.string().min(1),
version: z.string(),
commit: z.string(),
});
export type Config = z.infer<typeof EsquemaConfig>;
export function leerConfig(enEjecucion = window.__CONFIG__): Config {
return EsquemaConfig.parse({
// Lo que llega al arrancar el contenedor gana a lo fijado en la compilación
apiUrl: enEjecucion?.apiUrl || import.meta.env.VITE_API_URL,
nombreApp: import.meta.env.VITE_NOMBRE_APP ?? 'EMPRESA',
version: __VERSION__,
commit: __COMMIT__,
});
}
export const config = leerConfig();
Se usa || y no ?? porque el contenedor puede generar una cadena vacía, y esa también debe ceder ante el valor de la compilación. Las pruebas lo dejan por escrito:
import { describe, expect, it } from 'vitest';
import { leerConfig } from './entorno';
describe('leerConfig', () => {
it('usa el valor de la compilación si no hay configuración en ejecución', () => {
expect(leerConfig({}).apiUrl).toBe('/api');
});
it('da prioridad a la configuración en ejecución', () => {
expect(leerConfig({ apiUrl: 'https://api.empresa.es' }).apiUrl).toBe('https://api.empresa.es');
});
it('ignora una cadena vacía', () => {
expect(leerConfig({ apiUrl: '' }).apiUrl).toBe('/api');
});
});
Figura 2. Cuándo se fija cada valor. En los dos casos acaba en el navegador, así que ninguno sirve para secretos.
5. El build de producción
npm run build ejecuta tsc -b && vite build. TypeScript solo comprueba tipos; Vite genera dist/. Esta es la salida real del proyecto:
dist/index.html 0.51 kB │ gzip: 0.30 kB
dist/assets/index-_wrwmcFE.css 1.02 kB │ gzip: 0.49 kB
dist/assets/api-CMagGSdF.js 0.21 kB │ gzip: 0.17 kB
dist/assets/TablaDatos-DxLF-KTl.js 0.47 kB │ gzip: 0.27 kB
dist/assets/PaginaDepartamentos-HvxN_LcI.js 0.49 kB │ gzip: 0.32 kB
dist/assets/PaginaEmpleados-T3hXl1oM.js 1.02 kB │ gzip: 0.59 kB
dist/assets/PaginaEmpleado-D9oRjSbh.js 2.26 kB │ gzip: 0.99 kB
dist/assets/jsx-runtime-DqW3t8Pu.js 162.34 kB │ gzip: 50.00 kB
dist/assets/index-DG5xaQcm.js 237.50 kB │ gzip: 75.24 kB
Qué hay en esa salida
- Minificación. Los ficheros
.tsxse han convertido en JavaScript sin tipos, sin espacios y con nombres cortos. - Compilación de producción de React. Sin los avisos de desarrollo. Por eso nunca debes medir el rendimiento con
npm run dev, como viste en el módulo 14. - División de código. Cada
lazy: () => import(...)genera su propio fichero. Quien solo entra en departamentos no descarga la ficha del empleado.TablaDatosyapivan aparte porque los comparten varias páginas. - Un hash en cada nombre.
index-DG5xaQcm.js. Ese código sale del contenido: si cambia un byte, cambia el nombre.
📘 Concepto: El hash resuelve el problema clásico de la caché. El navegador puede guardar
index-DG5xaQcm.jspara siempre, porque ese nombre siempre tendrá el mismo contenido. Al publicar una versión nueva,index.htmlapunta a un nombre nuevo, y el navegador lo descarga. Por esoindex.htmles el único fichero que nunca debe quedarse viejo. Lo llevaremos a la práctica en la sección 6.
vite preview no es un servidor de producción
npm run preview sirve dist/ para que lo veas en local. Es útil, pero engaña. Lo hemos comprobado contra el propio dist/:
| Petición | vite preview | Nginx sin configurar | Nginx configurado |
|---|---|---|---|
/empleados/100 | 200 | 404 | 200 |
/assets/no-existe.js | 200 con HTML | 404 | 404 |
vite preview ya trae de serie la vuelta a index.html, así que un fallo de rutas no se ve hasta que publicas. Y por lo mismo, responde con HTML a un fichero que no existe. Por eso conviene probar la imagen real.
Dividir más: grupos de código
Vite 8 empaqueta con Rolldown. Puedes crear tus propios grupos con build.rolldownOptions.output.codeSplitting.groups, como harás en el ejercicio 8. La utilidad principal es separar las dependencias, que cambian poco, de tu código, que cambia a menudo: así, al publicar, el usuario solo vuelve a descargar lo que ha cambiado.
⚠️ Importante: Si vienes de versiones anteriores de Vite, verás
build.rollupOptionsymanualChunks. En Vite 8, los tipos marcanrollupOptionscomo obsoleto en favor derolldownOptions, yadvancedChunksen favor decodeSplitting.
Source maps
Un source map es un fichero que traduce el código minificado al original. Sin él, un error en producción dice at e (index-DG5xaQcm.js:1:4827).
| Opción | Se genera .map | El navegador lo enlaza | Uso |
|---|---|---|---|
build.sourcemap: false (por defecto) | No | No | Sin información de depuración |
build.sourcemap: true | Sí | Sí | Depurar; cualquiera puede leer tu código original |
build.sourcemap: 'hidden' | Sí | No | Subir los mapas a un servicio de errores y no publicarlos |
Con vite build --sourcemap hemos obtenido siete ficheros .map, uno por cada JavaScript, y el comentario //# sourceMappingURL=PaginaEmpleados-T3hXl1oM.js.map al final de cada uno. El mapa contiene tu código fuente, comentarios incluidos.
La opción base
Vite asume que la aplicación vive en la raíz del dominio: https://empresa.es/. Si vive en una subcarpeta, como https://fran.github.io/empresa-web/, los enlaces a /assets/... fallan. base corrige todas las rutas:
npm run build -- --base=/empresa-web/
<script src="/empresa-web/config.js"></script>
<script type="module" crossorigin src="/empresa-web/assets/index-CNL33joS.js"></script>
Fíjate en que también ha reescrito config.js, aunque no sea un módulo: al estar en public/, Vite sabe que es suyo. El mismo valor llega al código como import.meta.env.BASE_URL, y por eso se lo damos al router como basename.
⚠️ Importante: En Git Bash para Windows, esa orden produce un resultado absurdo:
/Program Files/Git/empresa-web/config.js. El intérprete cree que/empresa-web/es una ruta de su sistema de ficheros y la traduce. La compilación termina sin error y la aplicación queda rota. Solución:MSYS_NO_PATHCONV=1 npm run build -- --base=/empresa-web/, o usar PowerShell. En la máquina Linux de GitHub Actions esto no ocurre.
6. Docker con Nginx
Cuando acaba vite build solo hay ficheros: HTML, CSS y JavaScript. Para publicarlos basta un servidor de ficheros estáticos. Nginx es rápido, ligero y muy común. Docker lo empaqueta todo en una imagen que se comporta igual en tu portátil y en el servidor.
💡 Consejo: Si no conoces Docker, lo mínimo es esto. Una imagen es una plantilla inmutable con un sistema de ficheros y una orden de arranque. Un contenedor es una imagen en ejecución. Un
Dockerfilees la receta que construye la imagen.
Un Dockerfile de dos etapas
La tentación es usar una imagen de Node y ejecutar npm run preview. Eso publica node_modules, el código fuente y un servidor que, como acabas de ver, no es para producción. La solución es una imagen en dos etapas: una compila, y la otra solo se queda con el resultado.
# Etapa 1: compilar la aplicación con Node
FROM node:24-alpine AS compilacion
WORKDIR /app
# Primero las dependencias: si no cambian, Docker reutiliza esta capa
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL=/api
ARG GIT_COMMIT=local
ENV VITE_API_URL=$VITE_API_URL GIT_COMMIT=$GIT_COMMIT
RUN npm run build
# Etapa 2: servir los ficheros estáticos con Nginx
FROM nginx:1.31-alpine
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY nginx/seguridad.conf /etc/nginx/seguridad.conf
COPY --chmod=755 docker/40-config-ejecucion.sh /docker-entrypoint.d/40-config-ejecucion.sh
COPY --from=compilacion /app/dist /usr/share/nginx/html
ENV API_ORIGEN=http://api:3000
ENV API_URL=
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -q --spider http://127.0.0.1/salud || exit 1
Figura 3. Solo la última etapa forma la imagen. De la primera se copia el resultado.
La diferencia se puede medir, construyendo solo la primera etapa con docker build --target compilacion:
| Imagen | Tamaño |
|---|---|
Etapa compilacion (Node, node_modules, código) | 418 MB |
| Imagen final | 63,3 MB |
De ellos, nginx:1.31-alpine | 62,9 MB |
Tu aplicación añade menos de medio megabyte a Nginx. Y en la imagen final no hay ni Node ni tu código fuente: hay menos que descargar y menos que atacar.
Cada línea del Dockerfile tiene su razón:
| Línea | Por qué |
|---|---|
node:24-alpine | Alpine es una distribución Linux muy pequeña. React Router 8 exige Node 22.22 o superior |
COPY package* antes que COPY . . | Docker reutiliza una capa si sus entradas no han cambiado. Si cambias un componente, no reinstala nada. Aquí, npm ci tardó 77 segundos y la compilación 11 |
npm ci | Instala exactamente lo del package-lock.json, y falla si no coincide con package.json. npm install podría actualizar versiones |
ARG + ENV | Valores que se pasan con --build-arg y que la compilación ve como variables del proceso |
COPY --chmod=755 | El script debe ser ejecutable. Si no lo es, Nginx lo ignora y sigue, y config.js no se genera |
nginx:1.31-alpine | Versión fija. Con nginx:latest, dos construcciones iguales pueden dar imágenes distintas |
HEALTHCHECK | Docker pregunta cada 30 segundos si el contenedor está sano. Se usa 127.0.0.1 y no localhost, que en Alpine puede resolverse a IPv6 |
⚠️ Importante: No uses
ARGpara secretos: quedan a la vista endocker history. Y no pienses quenpm civa a fallar por crear elpackage-lock.jsonen Windows. Lo hemos probado: el fichero generado en Windows funciona en Alpine, incluidos los binarios nativos de Rolldown y oxlint.
.dockerignore
COPY . . copiaría node_modules y dist de tu máquina a la imagen. Eso es lento, y además sobrescribiría las dependencias recién instaladas para Linux con las de Windows. .dockerignore excluye lo que no debe entrar:
node_modules
dist
.git
.github
*.local
Dockerfile
compose.yaml
api
Con él, el contexto que se envía a Docker pesó 122 kB. *.local evita que tus ajustes personales acaben dentro de la imagen.
La configuración de Nginx
Este fichero, nginx/default.conf.template, es el corazón de la sección:
server {
listen 80;
server_name _;
server_tokens off;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_min_length 1024;
gzip_types text/css application/javascript text/javascript application/json image/svg+xml;
# Comprobación de salud para Docker
location = /salud {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# La API: mismo origen para el navegador, otro contenedor por detrás
location /api/ {
proxy_pass ${API_ORIGEN};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Ficheros con hash en el nombre: se pueden guardar un año
location /assets/ {
include /etc/nginx/seguridad.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Configuración que cambia sin recompilar: nunca en caché
location = /config.js {
include /etc/nginx/seguridad.conf;
add_header Cache-Control "no-store" always;
}
# El resto de rutas: si no existe el fichero, responde index.html
location / {
include /etc/nginx/seguridad.conf;
add_header Cache-Control "no-cache" always;
try_files $uri /index.html;
}
}
Y nginx/seguridad.conf, con las cabeceras que se explican en la sección 10:
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
La imagen oficial de Nginx procesa cada fichero de /etc/nginx/templates/*.template al arrancar: sustituye las variables de entorno definidas y guarda el resultado en /etc/nginx/conf.d/. Por eso ${API_ORIGEN} se rellena, mientras que $uri y $host no se tocan, al no ser variables de entorno definidas.
Falta el script que genera config.js, docker/40-config-ejecucion.sh. La imagen ejecuta lo que haya en /docker-entrypoint.d/ antes de arrancar Nginx:
#!/bin/sh
# Genera config.js al arrancar el contenedor, con el valor de API_URL
set -eu
cat > /usr/share/nginx/html/config.js <<EOF
window.__CONFIG__ = { apiUrl: "${API_URL:-}" };
EOF
⚠️ Importante: Si este fichero se guarda con finales de línea de Windows (CRLF), el contenedor fallará con un error extraño. Añade un
.gitattributescon la línea*.sh text eol=lf, para que Git nunca lo convierta.
Antes de construir nada, la API necesita sus propios ficheros. Copia server.mjs y datos.json de la descarga a la carpeta api/ del proyecto; la usarás en Compose. Construye y arranca:
docker build -t empresa-web:1.0 --build-arg GIT_COMMIT=$(git rev-parse --short HEAD) .
docker run --rm -d --name empresa-web -p 127.0.0.1:8080:80 -e API_ORIGEN=http://host.docker.internal:3000 empresa-web:1.0
host.docker.internal es el nombre con el que Docker Desktop llama a tu ordenador desde dentro del contenedor. En Linux hay que añadir --add-host=host.docker.internal:host-gateway. Abre http://127.0.0.1:8080/empleados y verás la aplicación servida por Nginx.

Figura 4. La aplicación servida desde la imagen Docker, contra la API EMPRESA real.
try_files: por qué recargar da un 404
Este es el error de despliegue más famoso de las SPA. Navegas por la aplicación, todo va bien, pulsas F5 y aparece esto:

Figura 5. Lo que devuelve Nginx sin try_files al entrar directamente en /empleados/100.
La causa es que hay dos enrutadores. Mientras navegas, el router cambia la URL en el navegador con la API history, sin hacer ninguna petición. Pero al recargar, o al pegar un enlace, el navegador pide /empleados/100 al servidor. Y en el disco del servidor no hay ninguna carpeta empleados.
La línea try_files $uri /index.html dice: “busca un fichero con esa ruta; si no existe, responde con index.html”. El navegador recibe la aplicación, React Router lee la URL y pinta la pantalla correcta.
Figura 6. Nginx elige un bloque location por cada petición. La ruta de la aplicación cae en location /.
Lo hemos probado con la imagen real, cambiando solo la configuración:
| Configuración | Petición | Respuesta |
|---|---|---|
Sin try_files | / | 200 |
Sin try_files | /empleados/100 | 404 |
Con try_files | /empleados/100 | 200 con index.html |
Con try_files | Recargar la página en el navegador | Sigue en la ficha de Ana García López |

Figura 7. La misma URL con try_files. Las pestañas de la sección 3, funcionando desde Docker tras una entrada directa.
Hay un matiz. Si una ruta interna no existe, como /ruta/que/no/existe, Nginx también responde 200 con index.html, y es React Router quien muestra el error. El servidor no puede saber qué rutas conoce tu aplicación. Para los buscadores esto importa, y es una de las razones para el renderizado en servidor del módulo 23.
Por qué /assets/ no usa la vuelta a index.html
Si solo tienes un location / con try_files, un fichero que falta también recibe HTML. Probado:
| Configuración | Petición | Respuesta |
|---|---|---|
Un solo location / | /assets/no-existe.js | 200 text/html |
Con location /assets/ y =404 | /assets/no-existe.js | 404 |
El primer caso produce uno de los errores más confusos: Failed to load module script: Expected a JavaScript module but the server responded with a MIME type of "text/html". El fichero no existe, pero el servidor dice que todo ha ido bien.
La caché, fichero por fichero
| Fichero | Cache-Control | Motivo |
|---|---|---|
index.html y rutas | no-cache | Puede guardarse, pero hay que preguntar antes de usarlo. Es lo que anuncia la versión nueva |
/assets/* | public, max-age=31536000, immutable | El hash garantiza que nunca cambia. Un año, sin preguntar |
/config.js | no-store | Cambia sin que cambie el nombre, así que no se guarda nunca |
no-cache no significa “no guardar”. Significa “guarda, pero revalida”. Nginx responde con un ETag, y si no ha cambiado, un 304 sin cuerpo. Quien no guarda es no-store. Estas son las cabeceras reales de una ruta:
HTTP/1.1 200 OK
Server: nginx
Content-Type: text/html
Content-Length: 513
ETag: "6ae8301f-201"
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Cache-Control: no-cache
Server: nginx no dice la versión gracias a server_tokens off. Compáralo con la figura 5, donde se lee nginx/1.31.6.
⚠️ Importante: Fíjate en que
Cache-Controlde/assets/no llevaalways, y los demás sí.alwaysañade la cabecera también a las respuestas de error. Nuestra primera versión lo llevaba, y la prueba reveló que el 404 de un fichero ausente salía conimmutabledurante un año. Un navegador o una CDN podría guardar ese error. Las cabeceras de seguridad sí deben llevarlo.
La trampa de add_header
Parece lógico poner las cabeceras una sola vez en el bloque server. Probado con X-Content-Type-Options en server y un add_header de caché en location /assets/:
| Ruta | ¿Lleva X-Content-Type-Options? |
|---|---|
/ (el location no define cabeceras) | Sí |
/assets/index-*.js (el location define Cache-Control) | No |
Un bloque hereda los add_header de su padre solo si no define ninguno propio. Basta un add_header en el location para perder todos los del server, sin ningún aviso. Por eso las cabeceras viven en seguridad.conf y cada location hace include.
proxy_pass y la API
Con location /api/, el navegador llama a /api/empleados en su mismo origen, y Nginx lo reenvía a la API. Esto tiene tres ventajas: no hay CORS, la API no se publica directamente y el cliente usa una URL relativa.
Hay un detalle de una sola barra, y da un error difícil de ver:
| Directiva | /api/empleados/100 llega a la API como | Resultado |
|---|---|---|
proxy_pass http://api:3000; | /api/empleados/100 | 200 |
proxy_pass http://api:3000/; | /empleados/100 | 404: Ruta no encontrada: /empleados/100 |
Con algo después del puerto, aunque sea una barra, Nginx sustituye la parte de la URL que coincide con location.
Otro problema real: si el nombre api no existe, Nginx no arranca. Al lanzar la imagen sola, sin el contenedor de la API:
estado=exited salida=1
nginx: [emerg] host not found in upstream "api" in /etc/nginx/conf.d/default.conf:22
Nginx resuelve los nombres al leer la configuración. Por eso, en el siguiente apartado, web espera a que api esté sana.
Compresión
gzip on reduce a la tercera parte el JavaScript y el CSS. Según la salida de vite build, el fichero principal pasa de 237,50 kB a 75,24 kB. Hemos comprobado que el contenedor responde con Content-Encoding: gzip en los ficheros de /assets/. gzip_min_length evita comprimir respuestas tan pequeñas que el resultado no compensa.
Y un detalle para cuando escribas gzip_types: la imagen de Nginx usada sirve el JavaScript como application/javascript (está en su mime.types), pero el tipo que recomienda hoy el estándar es text/javascript, y otras configuraciones lo usan. Se listan los dos para no depender de ello.
Con Docker Compose
Compose describe varios contenedores en un fichero. compose.yaml levanta la aplicación y la API en una red privada, sin que tengas que instalar nada:
services:
web:
build:
context: .
args:
GIT_COMMIT: ${GIT_COMMIT:-local}
ports:
- "127.0.0.1:${PUERTO_WEB:-8080}:80"
environment:
API_ORIGEN: http://api:3000
depends_on:
api:
condition: service_healthy
api:
image: node:24-alpine
working_dir: /app
command: node server.mjs
volumes:
- ./api:/app:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/departamentos"]
interval: 5s
timeout: 3s
retries: 5
docker compose up -d --build
docker compose ps
docker compose down --rmi local
Dentro de la red de Compose, cada servicio se alcanza por su nombre: http://api:3000. api no publica ningún puerto, de modo que solo se puede llegar a ella a través de Nginx. Y condition: service_healthy hace esperar a web hasta que la API responda de verdad, lo que evita el host not found de antes.
Cambiar la API sin recompilar
Con -e API_URL=..., el mismo contenedor apunta a otra API:
docker run --rm -d -p 127.0.0.1:8080:80 -e API_ORIGEN=http://host.docker.internal:3000 -e API_URL=http://127.0.0.1:3000/api empresa-web:1.0
Al pedir /config.js, se obtiene el valor nuevo:
window.__CONFIG__ = { apiUrl: "http://127.0.0.1:3000/api" };
Pero la página muestra “Algo ha ido mal”, y la consola explica por qué:
Connecting to 'http://127.0.0.1:3000/api/empleados?q=&orden=-salario&tamano=8' violates the following Content Security Policy directive: "connect-src 'self'". The action has been blocked.
La aplicación ha leído bien la configuración, pero la cabecera CSP solo permite conectar con el propio origen. Es la seguridad haciendo su trabajo. Para usar una API en otro dominio hay que añadirlo a connect-src, como propone el ejercicio 11. El proyecto usa el proxy, que no tiene este problema.
Comprobar el despliegue con un script
Después de cada despliegue conviene comprobar lo importante de forma automática. Este smoke test se ejecuta con Node, sin dependencias, y es la solución del ejercicio 6.
Con la configuración de esta sección, las ocho comprobaciones pasan, tanto con la imagen sola como con Compose:
OK La portada responde
OK Una ruta interna devuelve index.html
OK index.html no se guarda sin revalidar
OK Hay Content-Security-Policy
OK Los ficheros con hash son inmutables
OK Un fichero ausente da 404
OK Ese 404 no se guarda en caché
OK La API responde a través del proxy
Sin privilegios de administrador
La imagen nginx arranca como root. Aunque sus procesos de trabajo bajan de privilegios, si alguien aprovechara un fallo, partiría de más permisos de los necesarios. La variante nginxinc/nginx-unprivileged:alpine se ejecuta con un usuario sin privilegios (comprobado: uid=101(nginx)) y escucha en el puerto 8080, porque un usuario normal no puede abrir puertos por debajo de 1024. Si la usas, cambia listen y el mapeo de puertos. Lo harás en el ejercicio 9.
7. Desplegar con pestañas abiertas
Los hash traen un problema que no aparece en el ordenador de desarrollo. Una persona tiene la aplicación abierta. Publicas una versión nueva: los ficheros viejos desaparecen del servidor. Esa persona pulsa un enlace a una pantalla que aún no había cargado, y su pestaña pide un fichero que ya no existe.
Lo hemos reproducido: con la pestaña abierta en /acerca, hemos sustituido los ficheros del contenedor por los de una versión nueva, y hemos pulsado “Empleados”:
404 PaginaEmpleados-xXVH3mo-.js
404 api-DpJ_f4BV.js
404 TablaDatos-DO8T2-mB.js
Sin ninguna medida, el resultado es la pantalla de error de la aplicación: “Algo ha ido mal”. La persona no ha hecho nada mal; simplemente tenía la pestaña abierta.
Vite lanza el evento vite:preloadError cuando falla una importación dinámica, justo para esto. La respuesta habitual es recargar: index.html no está en caché, así que trae los nombres nuevos.
export function reportarError(tipo: string, error: unknown, pilaComponentes?: string) {
// En un proyecto real, aquí se envía el error a un servicio de seguimiento
console.error(`[${tipo}]`, error, pilaComponentes ?? '');
}
const CLAVE_RECARGA = 'recarga-tras-despliegue';
export function instalarRecargaTrasDespliegue() {
window.addEventListener('vite:preloadError', (evento) => {
// Solo una recarga: si el fichero falla también después, no entramos en un bucle
if (sessionStorage.getItem(CLAVE_RECARGA)) return;
sessionStorage.setItem(CLAVE_RECARGA, '1');
evento.preventDefault();
window.location.reload();
});
// Cuando la aplicación lleva un rato estable, se admite otra recarga futura
window.setTimeout(() => sessionStorage.removeItem(CLAVE_RECARGA), 10_000);
}
La protección con sessionStorage es imprescindible. Si un fichero faltara de verdad, por un despliegue mal hecho, cada recarga volvería a fallar y la página entraría en un bucle infinito.
El resultado medido en el navegador:
| Situación | Tras pulsar “Empleados” |
|---|---|
| Primer fallo, con el manejador | La página se recarga sola, sigue en /acerca y ya muestra el commit nuevo. Un segundo clic abre el listado de la versión nueva |
| Ya hubo una recarga (la misma ruta que sin manejador) | Pantalla “Algo ha ido mal”, y la URL ya es /empleados |
La recarga deja a la persona en la pantalla donde estaba, y debe pulsar otra vez. Es un mal menor. Si quieres evitarlo del todo, mantén en el servidor los ficheros de las versiones anteriores durante unos días. Eso es sencillo con una CDN o un almacenamiento de objetos, y más difícil con un contenedor que se sustituye entero.
💡 Consejo: La misma idea explica por qué
index.htmldebe ser lo último en cambiar en un despliegue manual. Si subes primero el HTML, durante unos segundos apuntará a ficheros que aún no existen.
8. GitHub Pages
GitHub Pages sirve ficheros estáticos gratis, y es ideal para portafolios y prácticas. Tiene dos limitaciones para una SPA: la aplicación vive en una subcarpeta (usuario.github.io/empresa-web/) y no puedes configurar el servidor, así que no hay try_files.
La primera se resuelve con base, como en la sección 5. Y la segunda, con un truco: cuando Pages no encuentra una ruta, sirve el fichero 404.html de la raíz. Si ese fichero es una copia de index.html, la aplicación arranca igual:
cp dist/index.html dist/404.html
Lo hemos comprobado con un servidor que imita ese comportamiento, en /empresa-web/, usando la compilación con --base=/empresa-web/:
entrada directa -> 404 Ana García López
url interna: http://127.0.0.1:5225/empresa-web/empleados Empleados (24)
errores: []
Fíjate en la primera línea: la aplicación se ve bien, pero el código HTTP es 404. La persona no lo nota, pero un buscador sí, y entenderá que la página no existe. Para un portafolio da igual. Para una web que quieras posicionar, no es la herramienta adecuada.
| Tema | Qué hacer en GitHub Pages |
|---|---|
| Rutas internas | 404.html, o un createHashRouter con URL del tipo /#/empleados/100 |
| Subcarpeta | --base=/nombre-del-repo/ y basename en el router |
| API | Pages no ejecuta código de servidor. La API va en otro sitio, con CORS y VITE_API_URL absoluta |
| Cabeceras (CSP, caché) | No se pueden configurar |
| Dominio propio | Se configura en Ajustes del repositorio, y entonces base vuelve a ser / |
Como no hay proxy ni contenedor, aquí la URL de la API debe fijarse en la compilación. Es justo el mecanismo de la sección 4. Para activarlo en el repositorio, ve a Settings > Pages y elige GitHub Actions como origen. La publicación la hace el flujo de trabajo de la siguiente sección.
9. Integración continua con GitHub Actions
“En mi máquina funciona” significa que has probado una combinación concreta de versiones, ficheros sin subir y variables de tu entorno. La integración continua (CI) ejecuta las comprobaciones en una máquina limpia con cada cambio. El despliegue continuo (CD) publica lo que ha pasado esas comprobaciones.
Figura 8. Un mismo flujo de trabajo: el despliegue solo ocurre si la calidad ha pasado.
Un flujo de trabajo (workflow) es un fichero YAML en .github/workflows/. Este es ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
calidad:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist
retention-days: 7
compilar-pages:
needs: calidad
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run build -- --base=/${{ github.event.repository.name }}/
env:
VITE_API_URL: ${{ vars.API_PUBLICA }}
GIT_COMMIT: ${{ github.sha }}
- run: cp dist/index.html dist/404.html
- uses: actions/upload-pages-artifact@v5
with:
path: dist
publicar-pages:
needs: compilar-pages
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.despliegue.outputs.page_url }}
steps:
- id: despliegue
uses: actions/deploy-pages@v5
Crea también .nvmrc, con la versión de Node en una línea (24). Así, tu equipo y la CI usan la misma versión mayor. Y los scripts de package.json deben quedar así:
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "oxlint",
"test": "vitest run"
}
}
Leer el flujo
| Elemento | Qué hace |
|---|---|
on | Cuándo se lanza: en cada push a main y en cada pull request |
permissions | El token automático solo puede leer el repositorio. Cada trabajo pide lo que le falte |
jobs | Trabajos. Cada uno se ejecuta en una máquina nueva |
needs | Dependencias. compilar-pages no empieza si calidad falla |
if | Condición. Los pull requests se comprueban, pero no publican |
uses | Ejecuta una acción publicada |
run | Ejecuta una orden del sistema |
cache: npm | Guarda la caché de npm entre ejecuciones. Se compone con el package-lock.json |
vars.API_PUBLICA | Una variable que defines en los ajustes del repositorio (Settings > Secrets and variables > Actions). Si falta, vite.config.ts hace fallar la compilación, y no se publica nada |
pages: write e id-token: write | Lo que necesita deploy-pages, y solo en ese trabajo |
Los trabajos se ejecutan en máquinas distintas, por lo que compilar-pages vuelve a instalar y compilar. No reutiliza el dist de calidad porque la base es diferente.
⚠️ Importante: Lo que se ha comprobado de este flujo es lo siguiente. Su sintaxis, con
actionlint, sin errores. Las versiones de las acciones, consultando sus repositorios (checkoutv7,setup-nodev7,upload-artifactv7,upload-pages-artifactv5,deploy-pagesv5). Y sus pasos, ejecutados en orden en un contenedor limpio denode:24-alpine:npm ci,lint,test,build, con este resultado. No se ha ejecutado en GitHub. La primera vez en tu repositorio, revisa la pestaña Actions.
Found 0 warnings and 0 errors.
Test Files 3 passed (3)
Tests 8 passed (8)
✓ built in 2.05s
found 0 vulnerabilities
Reglas de protección
La CI solo protege si es obligatoria. En Settings > Branches, crea una regla para main que exija que calidad pase antes de fusionar. Sin eso, el trabajo en rojo es solo una sugerencia.
Publicar la imagen Docker
Si despliegas con contenedores, el destino es un registro de imágenes. GitHub ofrece GHCR. Este segundo flujo, .github/workflows/imagen.yml, construye y publica la imagen al crear una etiqueta v1.2.0:
name: Publicar imagen Docker
on:
push:
tags: ['v*']
permissions:
contents: read
packages: write
jobs:
imagen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: metadatos
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.metadatos.outputs.tags }}
labels: ${{ steps.metadatos.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
GITHUB_TOKEN lo crea GitHub en cada ejecución y caduca al terminar. Para tus propios secretos, usa Secrets en los ajustes del repositorio. GitHub los oculta en los registros, pero nunca los pases a una variable VITE_: acabarían en el bundle.
El Dockerfile no ejecuta lint ni pruebas, porque de eso se encarga ci.yml. Si quieres que una etiqueta no pueda publicarse sin pasar la calidad, añade esos pasos antes en este mismo trabajo.
Saber qué versión está desplegada
Cuando alguien informa de un fallo, la primera pregunta es “¿en qué versión?”. Por eso el proyecto inyecta la versión y el commit con define, y los muestra en una página:

Figura 9. La imagen construida con --build-arg GIT_COMMIT=a1b2c3d, leyendo su propia identidad.
10. Seguridad
React te protege de algunos problemas, y de otros no. Y esto tiene un límite claro: todo lo que corre en el navegador está en manos del usuario. La seguridad real se aplica en el servidor. En el cliente solo se reducen riesgos.
XSS: lo que React escapa y lo que no
El módulo 02 explicó que {texto} se escapa, así que un <script> que llegue en un dato se muestra como texto. Las puertas que siguen abiertas son estas:
| Riesgo | Ejemplo | Defensa |
|---|---|---|
| HTML sin escapar | dangerouslySetInnerHTML={{ __html: comentario }} | Evitarlo, o sanear con una biblioteca especializada como DOMPurify |
| URL con esquema peligroso | <a href={urlDelUsuario}> | Aceptar solo https: y mailto:. React bloquea javascript:, pero no otros |
| Props del usuario | <div {...datosDeLaApi} /> | Nunca hacer spread de datos externos: pueden traer dangerouslySetInnerHTML |
| Código fuera de React | el.innerHTML = valor en una ref | Usar textContent |
Enlaces javascript: 🔄 19.0
🔄 Cambia en React 19.0. En React 18.3,
<a href="javascript:alert(1)">llega tal cual al DOM, con solo un aviso en desarrollo, y al pulsarlo se ejecuta. Si usas una versión anterior: valida las URL que vengan de los datos antes de pintarlas, por ejemplo connew URL(valor).protocol, y acepta solo una lista de esquemas conocidos.
Desde React 19, la URL se sustituye por otra que lanza un error. Esto es lo obtenido con cada versión al pintar ese enlace:
<!-- React 18.3.1 -->
<a href="javascript:alert(1)">x</a>
<!-- React 19.3.1 -->
<a href="javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')">x</a>
El resultado de 19.3.1 es el mismo en producción y en desarrollo.
Content Security Policy
La CSP es una cabecera que le dice al navegador de dónde puede cargar y ejecutar cosas. Es tu segunda línea de defensa: si se cuela un XSS, el código inyectado no se ejecuta o no puede enviar nada fuera.
| Directiva | Valor | Efecto |
|---|---|---|
default-src | 'self' | Por defecto, solo el propio origen |
script-src | 'self' | Ningún script en línea ni de otro dominio |
style-src | 'self' | Nada de <style> en línea |
img-src | 'self' data: | Imágenes propias y data: |
connect-src | 'self' | fetch solo al propio origen. Limita el robo de datos |
object-src | 'none' | Sin plugins |
base-uri | 'self' | Impide cambiar las URL relativas con <base> |
form-action | 'self' | Los formularios solo se envían aquí |
frame-ancestors | 'none' | Nadie puede incrustar la página. Evita el clickjacking |
Vite genera JavaScript en ficheros externos, sin nada en línea, y esto hace posible una política tan estricta. Se ha probado en el navegador contra la imagen, con estos resultados:
- La aplicación funciona entera y la consola queda sin errores.
- El atributo
stylede las barras de salario funciona. React lo aplica desde JavaScript con la API del DOM, ystyle-srcno lo bloquea. - Un ataque simulado, inyectando
<img src="/nada.png" onerror="window.atacado = true">coninnerHTML, no se ejecuta:atacadosigue sin valertrue.
Executing inline event handler violates the following Content Security Policy directive 'script-src 'self''.
💡 Consejo: Si tu aplicación usa bibliotecas que inyectan
<style>,style-src 'self'las romperá. Para probar sin riesgo, usa la cabeceraContent-Security-Policy-Report-Only: el navegador informa de lo que bloquearía, pero no bloquea nada. En el módulo 17 viste Trusted Types, que complementa esta política.
Dónde guardar el token
| Lugar | Ventaja | Riesgo |
|---|---|---|
localStorage | Sencillo, sobrevive a recargas | Un XSS lo lee y lo envía fuera |
| Memoria (una variable) | Un XSS lo tiene más difícil | Se pierde al recargar |
Cookie HttpOnly; Secure; SameSite | JavaScript no puede leerla | Necesita colaboración del servidor, y protección contra CSRF |
No hay una opción perfecta. Para una aplicación profesional, lo más recomendado es una cookie HttpOnly gestionada por el servidor. La API EMPRESA del curso devuelve un token en el cuerpo porque su objetivo es didáctico, y el módulo 26 lo gestionará así.
Y recuerda que ocultar un botón con {esAdmin && <BotonBorrar />} no es seguridad, sino interfaz. Cualquiera puede llamar a la API con curl. La API EMPRESA, por ejemplo, exige el rol ADMIN en el servidor para borrar.
Dependencias
Tu aplicación contiene cientos de paquetes que no has escrito, y cada uno es código que se ejecuta en el navegador de tus usuarios o en tu CI.
| Práctica | Cómo |
|---|---|
| Fijar versiones | Sube package-lock.json al repositorio y usa npm ci |
| Revisar vulnerabilidades | npm audit. En este proyecto, found 0 vulnerabilities |
| Actualizar de forma regular | Dependabot abre pull requests que tu CI comprueba |
| Sospechar de nombres parecidos | react-routerr no es react-router |
| Mantener React al día | Sigue la política de versiones: se retroportan parches de seguridad, pero hay que aplicarlos |
La vulnerabilidad de los Server Components de diciembre de 2025, que analizaste en el módulo 23, es el mejor argumento: quienes actualizaron rápido no tuvieron problema.
Dependabot se activa con un fichero .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
CORS no protege tu API
Una confusión frecuente: CORS no es una medida de seguridad de la API. Es una regla del navegador sobre qué páginas pueden leer una respuesta. curl o un script la ignoran. Una API sigue necesitando autenticación y autorización aunque tenga el CORS más estricto.
11. Errores en producción
En desarrollo ves los errores en la consola. En producción ocurren en el navegador de otra persona, y si no los recoges, no existen para ti.
onUncaughtError y onCaughtError 🆕 19.0
🆕 Desde React 19.0. Estas opciones de
createRootno existen en React 18.3 ni anteriores. Si usas una versión anterior: informa de los errores en elcomponentDidCatchde una Error Boundary que envuelva toda la aplicación, y usawindow.addEventListener('error', ...)para el resto.
createRoot acepta funciones que reciben cada error del árbol, con la pila de componentes. Con ellas, tienes un punto único donde recogerlos. Este es src/main.tsx:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { router } from '@/app/router';
import { instalarRecargaTrasDespliegue, reportarError } from '@/shared/errores/errores';
import './index.css';
instalarRecargaTrasDespliegue();
createRoot(document.getElementById('root')!, {
onUncaughtError: (error, info) => reportarError('no capturado', error, info.componentStack),
onCaughtError: (error, info) => reportarError('capturado', error, info.componentStack),
}).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
| Opción | Cuándo se llama |
|---|---|
onUncaughtError | Ninguna Error Boundary capturó el error. React desmonta el árbol |
onCaughtError | Una Error Boundary lo capturó y se muestra su alternativa |
onRecoverableError | React se recuperó solo, por ejemplo en la hidratación. Esta ya existía en 18.3 |
Estas opciones solo ven los errores de renderizado y de efectos. Un error dentro de un manejador de eventos o de una promesa no pasa por ahí, igual que no lo capturan las Error Boundaries del módulo 15. Para esos, escucha los eventos error y unhandledrejection de window.
reportarError escribe en la consola para que el ejemplo sea autónomo. En un proyecto real se envía a un servicio de seguimiento, junto con la versión y el commit, y con source maps hidden para traducir las pilas. Cuidado con enviar datos personales en esos informes.
12. Lista de comprobación antes de publicar
| Área | Comprobación |
|---|---|
| Código | npm run lint, npm test y npm run build pasan en una máquina limpia |
| Código | Ni rastro de console.log de depuración ni de código comentado |
| Entorno | Todas las variables están en .env.example, y ninguna VITE_ contiene un secreto |
| Rutas | Recargar /empleados/100 funciona en el entorno real, no en vite preview |
| Caché | index.html con no-cache; /assets/ con immutable |
| Errores | Un fichero ausente da 404, y no index.html |
| Seguridad | CSP activa y consola limpia; npm audit revisado |
| Accesibilidad | Pasada automática y prueba con teclado (módulo 24) |
| Rendimiento | Tamaño de los trozos revisado (módulo 14) |
| Operación | La versión desplegada es visible y los errores se recogen |
| Vuelta atrás | Sabes cómo volver a la versión anterior. Con imágenes, basta arrancar la etiqueta previa |
13. Ejercicios
Ejercicio 1: Una frontera que falla sola
En el proyecto del módulo, crea src/features/empleados/mala.ts con esta línea, y comprueba qué dice npm run lint. Luego corrígela sin desactivar la regla.
import { obtenerDepartamento } from '@/features/departamentos/api';
Ver solución
oxlint informa de un error en la línea 1, de la regla eslint(no-restricted-imports), y npm run lint termina con fallo, de modo que la CI se pone en rojo.
La corrección consiste en importar por la cara pública:
import { obtenerDepartamento } from '@/features/departamentos';
departamentos/index.ts ya exporta esa función. Si necesitaras algo que no exporta, la decisión correcta es añadirlo a index.ts de forma consciente, o preguntarte si esa lógica pertenece a shared. Lo que se quiere evitar es depender de algo interno por accidente.
Ejercicio 2: Un acordeón compuesto
Escribe src/shared/ui/Acordeon.tsx con las piezas Raiz, Elemento, Boton y Panel. Con la prop multiple, pueden estar abiertos varios elementos a la vez; sin ella, abrir uno cierra el otro. Debe ser accesible: aria-expanded, aria-controls, y el panel como region con su nombre.
Ver solución
La novedad respecto a Pestanas son dos contextos: uno para el grupo, y otro para que Boton y Panel sepan a qué elemento pertenecen sin repetir el valor.
import { createContext, useContext, useId, useState } from 'react';
import type { ReactNode } from 'react';
type ValorRaiz = {
abiertos: ReadonlySet<string>;
alternar: (valor: string) => void;
};
const ContextoRaiz = createContext<ValorRaiz | null>(null);
const ContextoElemento = createContext<{ valor: string; idBase: string } | null>(null);
function exigir<T>(valor: T | null, pieza: string): T {
if (valor === null) throw new Error(`${pieza} debe ir dentro de su contenedor de Acordeon`);
return valor;
}
type PropsRaiz = { multiple?: boolean; children: ReactNode };
export function Raiz({ multiple = false, children }: PropsRaiz) {
const [abiertos, setAbiertos] = useState<ReadonlySet<string>>(new Set());
function alternar(valor: string) {
setAbiertos((actuales) => {
const siguientes = new Set(multiple ? actuales : []);
if (actuales.has(valor)) siguientes.delete(valor);
else siguientes.add(valor);
return siguientes;
});
}
return <ContextoRaiz value={{ abiertos, alternar }}>{children}</ContextoRaiz>;
}
type PropsElemento = { valor: string; children: ReactNode };
export function Elemento({ valor, children }: PropsElemento) {
const idBase = useId();
return <ContextoElemento value={{ valor, idBase }}>{children}</ContextoElemento>;
}
export function Boton({ children }: { children: ReactNode }) {
const { abiertos, alternar } = exigir(useContext(ContextoRaiz), 'Boton');
const { valor, idBase } = exigir(useContext(ContextoElemento), 'Boton');
return (
<h3>
<button
type="button"
id={`${idBase}-boton`}
aria-expanded={abiertos.has(valor)}
aria-controls={`${idBase}-panel`}
onClick={() => alternar(valor)}
>
{children}
</button>
</h3>
);
}
export function Panel({ children }: { children: ReactNode }) {
const { abiertos } = exigir(useContext(ContextoRaiz), 'Panel');
const { valor, idBase } = exigir(useContext(ContextoElemento), 'Panel');
return (
<div
role="region"
id={`${idBase}-panel`}
aria-labelledby={`${idBase}-boton`}
hidden={!abiertos.has(valor)}
>
{children}
</div>
);
}
El Set nunca se modifica: se crea uno nuevo, por la inmutabilidad que viste en el módulo 04. La línea new Set(multiple ? actuales : []) es toda la diferencia entre los dos modos.
Las pruebas:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import * as Acordeon from './Acordeon';
function Ejemplo({ multiple }: { multiple?: boolean }) {
return (
<Acordeon.Raiz multiple={multiple}>
<Acordeon.Elemento valor="madrid">
<Acordeon.Boton>Madrid</Acordeon.Boton>
<Acordeon.Panel>Dirección y Ventas</Acordeon.Panel>
</Acordeon.Elemento>
<Acordeon.Elemento valor="sevilla">
<Acordeon.Boton>Sevilla</Acordeon.Boton>
<Acordeon.Panel>Producción</Acordeon.Panel>
</Acordeon.Elemento>
</Acordeon.Raiz>
);
}
describe('Acordeon', () => {
it('con multiple=false, abrir uno cierra el otro', async () => {
const usuario = userEvent.setup();
render(<Ejemplo />);
await usuario.click(screen.getByRole('button', { name: 'Madrid' }));
await usuario.click(screen.getByRole('button', { name: 'Sevilla' }));
expect(screen.getByRole('button', { name: 'Madrid' })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('region', { name: 'Sevilla' })).toHaveTextContent('Producción');
});
it('con multiple, los dos pueden estar abiertos', async () => {
const usuario = userEvent.setup();
render(<Ejemplo multiple />);
await usuario.click(screen.getByRole('button', { name: 'Madrid' }));
await usuario.click(screen.getByRole('button', { name: 'Sevilla' }));
expect(screen.getAllByRole('region')).toHaveLength(2);
});
});
Las dos pasan. getAllByRole no cuenta los paneles con hidden, y por eso sirve para saber cuántos hay abiertos.
Ejercicio 3: Validar el entorno con Zod
Sustituye el if de vite.config.ts por una validación con Zod. VITE_API_URL debe ser una ruta relativa o una URL https, y VITE_NOMBRE_APP debe existir. Si algo falla, la compilación debe mostrar todos los errores.
Ver solución
Solo cambian estas partes de vite.config.ts:
import { z } from 'zod';
const EsquemaEntorno = z.object({
VITE_API_URL: z
.string({ error: 'Falta la variable' })
.refine((valor) => valor.startsWith('/') || valor.startsWith('https://'), {
error: 'Debe ser una ruta relativa o una URL https',
}),
VITE_NOMBRE_APP: z.string({ error: 'Falta la variable' }).min(1),
});
// Dentro de defineConfig:
const resultado = EsquemaEntorno.safeParse(loadEnv(mode, process.cwd()));
if (!resultado.success) {
throw new Error(`Entorno no válido:\n${z.prettifyError(resultado.error)}`);
}
Con VITE_API_URL=http://api.empresa.es npm run build:
Error: Entorno no válido:
✖ Debe ser una ruta relativa o una URL https
→ at VITE_API_URL
Con https://api.empresa.es, compila. Exigir https evita publicar una aplicación que el navegador bloquearía por contenido mixto. safeParse no lanza excepción, y permite dar un mensaje unificado.
Ejercicio 4: Una bandera de funcionalidad
Añade VITE_PANEL_NUEVO. Si vale true, PaginaAcerca muestra un aviso con el texto panel-experimental. Comprueba, buscando en dist/assets, que con false el texto no se publica.
Ver solución
Declara la variable en vite-env.d.ts (readonly VITE_PANEL_NUEVO?: string;) y úsala comparando con la cadena:
{import.meta.env.VITE_PANEL_NUEVO === 'true' && <p>panel-experimental</p>}
VITE_PANEL_NUEVO=false npm run build
grep -l panel-experimental dist/assets/*.js
VITE_PANEL_NUEVO=true npm run build
grep -l panel-experimental dist/assets/*.js
Con false, grep no encuentra nada. Con true, encuentra un fichero. Es lo que obtuvimos en la sección 4.
Hay que comparar con 'true' porque todas las variables son cadenas. if (import.meta.env.VITE_PANEL_NUEVO) sería verdadero también con el valor "false", un error clásico.
Ejercicio 5: Encuentra los tres fallos
Esta configuración de Nginx está en producción. Hay tres quejas: recargar da un 404, la API responde Ruta no encontrada, y una auditoría dice que los ficheros JavaScript no llevan X-Content-Type-Options. Localiza y corrige cada fallo.
server {
listen 80;
root /usr/share/nginx/html;
add_header X-Content-Type-Options "nosniff" always;
location /api/ {
proxy_pass http://api:3000/;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
Ver solución
- Recargar da 404. No hay
location /contry_files. Hay que añadirlo. Ruta no encontrada. La barra dehttp://api:3000/hace que se elimine el prefijo/api/. La API recibe/empleados/100. Debe serproxy_pass http://api:3000;.- Falta la cabecera en JavaScript. El
add_headerdelocation /assets/anula la herencia del deserver. Hay que repetirlo, o hacerincludeen cada bloque.
server {
listen 80;
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://api:3000;
}
location /assets/ {
add_header X-Content-Type-Options "nosniff" always;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
add_header X-Content-Type-Options "nosniff" always;
add_header Cache-Control "no-cache" always;
try_files $uri /index.html;
}
}
Cada uno de estos tres fallos se ha reproducido por separado en la sección 6, con la imagen real y las mismas respuestas que describen las quejas.
Ejercicio 6: Un smoke test del despliegue
Escribe scripts/comprobar-despliegue.mjs, que reciba una URL y compruebe lo importante de un despliegue. Debe terminar con código de salida distinto de 0 si algo falla, para poder usarlo en CI.
Ver solución
// Uso: node scripts/comprobar-despliegue.mjs http://127.0.0.1:8080
const base = (process.argv[2] ?? 'http://127.0.0.1:8080').replace(/\/$/, '');
let fallos = 0;
function comprobar(nombre, correcto, detalle) {
console.log(`${correcto ? 'OK ' : 'FALLO'} ${nombre}${correcto ? '' : ` (${detalle})`}`);
if (!correcto) fallos += 1;
}
const inicio = await fetch(`${base}/`);
const html = await inicio.text();
comprobar('La portada responde', inicio.status === 200, inicio.status);
const ruta = await fetch(`${base}/empleados/100`);
comprobar('Una ruta interna devuelve index.html', ruta.status === 200, ruta.status);
comprobar('index.html no se guarda sin revalidar', ruta.headers.get('cache-control') === 'no-cache', ruta.headers.get('cache-control'));
comprobar('Hay Content-Security-Policy', ruta.headers.has('content-security-policy'), 'sin cabecera');
const fichero = html.match(/assets\/[^"]+\.js/)?.[0];
const js = await fetch(`${base}/${fichero}`);
comprobar('Los ficheros con hash son inmutables', js.headers.get('cache-control')?.includes('immutable') === true, js.headers.get('cache-control'));
const ausente = await fetch(`${base}/assets/no-existe.js`);
comprobar('Un fichero ausente da 404', ausente.status === 404, ausente.status);
comprobar('Ese 404 no se guarda en caché', !ausente.headers.get('cache-control')?.includes('immutable'), ausente.headers.get('cache-control'));
const api = await fetch(`${base}/api/departamentos`);
comprobar('La API responde a través del proxy', api.status === 200, api.status);
process.exitCode = fallos === 0 ? 0 : 1;
Usa process.exitCode y no process.exit(). La primera versión llamaba a process.exit() con conexiones de fetch aún abiertas, y en Windows Node terminó con Assertion failed: !(handle->flags & UV_HANDLE_CLOSING) y código 127, aunque todas las comprobaciones eran correctas. Con exitCode, Node acaba solo y sale con 0.
Fíjate en que este script habría detectado el fallo del always de la sección 6, que sacaba el 404 con immutable.
Ejercicio 7: La misma CI en dos versiones de Node
Crea un flujo que ejecute npm ci y npm test en un pull request, con la versión mínima que admite React Router y con la versión del proyecto. Un fallo en una no debe cancelar la otra.
Ver solución
name: CI matriz
on:
pull_request:
jobs:
calidad:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['22.22', '24']
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
matrix crea un trabajo por cada valor. fail-fast: false evita que el primer fallo cancele los demás. Las versiones van entre comillas por una razón clásica de YAML: sin ellas, 22.20 sería el número 22.2.
actionlint no encuentra errores en este fichero.
Ejercicio 8: Separar las dependencias
Haz que React, React DOM y React Router vayan en un único fichero react-*.js. Compara la salida antes y después. ¿Pesa menos la aplicación?
Ver solución
En vite.config.ts, dentro del objeto que devuelve defineConfig:
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [{ name: 'react', test: /node_modules[\\/](react|react-dom|react-router|scheduler)[\\/]/ }],
},
},
},
},
Se escribe [\\/] para que funcione con la barra de Windows y con la de Linux. Salida real:
dist/assets/index-_wrwmcFE.css 1.02 kB │ gzip: 0.49 kB
dist/assets/api-ColkxQcn.js 0.20 kB │ gzip: 0.17 kB
dist/assets/TablaDatos-lQ3S-uLy.js 0.46 kB │ gzip: 0.26 kB
dist/assets/PaginaDepartamentos-CJxLtcsn.js 0.48 kB │ gzip: 0.32 kB
dist/assets/rolldown-runtime-WkwZ5CU-.js 0.59 kB │ gzip: 0.36 kB
dist/assets/PaginaEmpleados-Q_e7b5kb.js 0.98 kB │ gzip: 0.57 kB
dist/assets/PaginaEmpleado-BCH_t-oJ.js 2.30 kB │ gzip: 1.01 kB
dist/assets/index-o-HGsIk7.js 5.19 kB │ gzip: 2.16 kB
dist/assets/cliente-B8wTFo_3.js 79.05 kB │ gzip: 22.89 kB
dist/assets/react-Do3GECNC.js 314.25 kB │ gzip: 98.96 kB
No pesa menos: la suma es prácticamente la misma, e incluso aparece un fichero más. Lo que mejora es la caché. Tu código propio está ahora en index (5 kB), separado de react (314 kB). Al cambiar un componente, react-*.js conserva su nombre y el usuario no vuelve a descargarlo.
cliente-*.js contiene lo que comparten varias pantallas, sobre todo Zod. Si quisieras reducirlo, ese es el primer candidato.
Ejercicio 9 (propuesto): Imagen sin root
Cambia la etapa final a nginxinc/nginx-unprivileged:alpine. Tendrás que cambiar listen a 8080 y el mapeo de puertos. Comprueba además si el script de arranque sigue pudiendo escribir config.js: ahora se ejecuta con un usuario sin privilegios, y los ficheros que copias con COPY pertenecen a root salvo que uses COPY --chown. Cuando funcione, pasa scripts/comprobar-despliegue.mjs.
Ejercicio 10 (propuesto): Publícalo de verdad
Sube el proyecto a un repositorio tuyo, activa GitHub Pages con GitHub Actions y crea la variable API_PUBLICA. Como la API EMPRESA no estará en Internet, puedes publicarla en un servicio gratuito o comprobar solo que carga la página de /acerca. Comprueba que un pull request con una prueba rota queda en rojo y no publica nada.
Ejercicio 11 (propuesto): CSP con una API en otro dominio
Haz que la cabecera connect-src acepte el origen de API_URL sin recompilar. Pista: convierte seguridad.conf en una plantilla, con una variable nueva CSP_CONNECT. Comprueba que desaparece el error de la sección 6.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Por funcionalidades | src/features/*, index.ts | Lo que cambia junto vive junto; index.ts es el contrato |
| Fronteras | no-restricted-imports | La regla la vigila el linter, no la memoria |
| Barril y división de código | INEFFECTIVE_DYNAMIC_IMPORT | Exportar páginas desde index.ts rompe lazy |
| Slots | props con ReactNode | Varios huecos con nombre |
| Render prop | celda={(fila) => ...} | El contenido depende de datos del componente |
| Compound components | Context privado, import * as | Piezas que comparten estado implícito |
| Variables de entorno | import.meta.env.VITE_* | Se sustituyen al compilar; solo pasa VITE_ |
| Prioridad de ficheros | .env.production > .env.local | Y el proceso gana a todos |
| Secretos | Ninguna | Lo que llega al navegador no es secreto |
| Configuración al arrancar | window.__CONFIG__, config.js | Una imagen para todos los entornos |
| Constantes de compilación | define, __COMMIT__ | Usa JSON.stringify: se inserta código |
| Nombres con hash | index-DG5xaQcm.js | Permiten caché de un año |
vite preview | npm run preview | Oculta los fallos de rutas |
| Subcarpeta | --base, basename | En Git Bash, MSYS_NO_PATHCONV=1 |
| Source maps | build.sourcemap: 'hidden' | Depurar sin publicar tu código |
| Imagen en dos etapas | FROM ... AS, COPY --from | 418 MB frente a 63,3 MB |
| Evitar el 404 al recargar | try_files $uri /index.html | El servidor no conoce las rutas de React |
| Ficheros ausentes | try_files $uri =404 | Nunca responder HTML por un JS |
| Herencia de cabeceras | add_header + include | Un add_header local anula los del padre |
| Proxy | proxy_pass http://api:3000; | Con una barra al final se pierde /api/ |
| Caché | no-cache, immutable, no-store | no-cache significa revalidar |
| Compose | depends_on: service_healthy | Nginx no arranca si no resuelve el nombre |
| Tras desplegar | vite:preloadError | Recarga una vez, con protección contra bucles |
| GitHub Pages | 404.html, --base | Funciona, pero con código HTTP 404 |
| CI | needs, if, npm ci | Solo se publica lo que ha pasado la calidad |
| XSS | dangerouslySetInnerHTML, href | React escapa el texto; lo demás es tuyo |
javascript: 🔄 19.0 | href | React 19 lo bloquea; 18.3 no |
| CSP | Content-Security-Policy | Segunda línea de defensa; style de React funciona |
| Errores 🆕 19.0 | onUncaughtError, onCaughtError | Punto único para recoger errores |