Tu proyecto funciona, está probado, se mide y una máquina lo vigila en cada push. Y sin embargo sigue viviendo en tu portátil, donde no le sirve a nadie. Desplegar es lo que convierte un repositorio en un producto, y es también el momento en el que aparecen los problemas que ningún entorno de desarrollo enseña: las rutas que funcionaban en localhost y dan 404 en el servidor, la caché que sirve la versión antigua durante una semana, el service worker que se queda pegado con código de hace tres despliegues, la clave de API que creías protegida y que cualquiera puede leer con dos clics, y las cabeceras de seguridad que nadie configura porque nadie se las ha explicado. En esta lección aprenderás a preparar la compilación de producción entendiendo qué hace cada parte y por qué ninguna clave secreta puede vivir en el cliente; a elegir dónde alojar con una tabla comparativa honesta; a configurar el servidor de verdad —rutas de SPA, caché coherente con el hashing y con el service worker, compresión y cabeceras de seguridad explicadas una a una—; a decidir qué hacer con el backend sin escribirlo, con la advertencia de que la validación del cliente nunca sustituye a la del servidor; a montar el despliegue continuo con vistas previas por rama y una estrategia de reversión que funcione a las tres de la madrugada; a monitorizar en producción sin convertirte en un problema de privacidad; y a actualizar una PWA ya instalada sin dejar a nadie con una versión antigua. Terminarás con la aplicación desplegada con HTTPS, su flujo de despliegue continuo y una lista de comprobación de lanzamiento firmada.
Contenido
- Qué cambia cuando el código sale de tu máquina
- La compilación de producción
- Variables de entorno y por qué no hay secretos en el cliente
- Dónde alojar: tabla comparativa honesta
- Dominio, HTTPS y certificados
- Las rutas de una SPA y el fallback a
index.html - Caché: la parte que más se hace mal
- Compresión
- Cabeceras de seguridad, una a una
- El backend: qué opciones hay sin escribir un servidor
- La validación del cliente nunca sustituye a la del servidor
- Despliegue continuo con GitHub Actions
- Entornos de vista previa por rama
- Estrategia de reversión
- Monitorización de errores en producción
- Métricas de campo con
web-vitals - Actualizar una PWA ya instalada
- La lista de comprobación de lanzamiento
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Qué cambia cuando el código sale de tu máquina
En desarrollo, Vite hace muchísimas cosas por ti que en producción no existen. Conviene ver la lista completa antes de nada, porque cada fila es una fuente de sorpresas:
| Aspecto | En desarrollo (npm run dev) |
En producción |
|---|---|---|
| Módulos | Se sirven sin empaquetar, uno por fichero | Empaquetados y minificados |
| Rutas | El servidor devuelve index.html para todo |
Devuelve 404 si no lo configuras |
| Caché | Desactivada | Agresiva, y difícil de deshacer |
| Errores | Consola visible, con mapas de código | Nadie los ve salvo que los recojas |
| Red | Local, instantánea | Latencia real, pérdidas, 3G |
| Variables de entorno | Del fichero .env |
Las que inyecte el proceso de compilación |
| HTTPS | Opcional | Obligatorio (y muchas API lo exigen) |
| Service worker | Normalmente desactivado | Activo, y cachea con persistencia |
| Usuarios | Tú, en un navegador reciente | Cualquiera, en cualquier cosa |
La consecuencia práctica es una regla de oro que ahorra muchísimo tiempo:
Nunca despliegues algo que no hayas probado con
npm run buildynpm run previewen tu máquina.
preview sirve la compilación real desde dist/, y ahí aparecen la mitad de los problemas: rutas mal resueltas, imágenes que no se copiaron, import dinámicos que en desarrollo funcionaban por casualidad, y variables de entorno que no se inyectaron.
Y una segunda regla, del consejo de 11-01 que ojalá hayas seguido: el primer despliegue se hace con la aplicación vacía, en el hito H1. Si lo hiciste, este apartado es un repaso. Si no, hazlo ahora antes de seguir: descubrir los problemas de configuración con una página en blanco cuesta una hora; descubrirlos con todo el producto en juego cuesta un fin de semana.
- La compilación de producción
Y lo que produce, comentado:
dist/
├── index.html 2,1 kB ← referencias con hash
├── manifest.json 0,6 kB
├── sw.js 4,3 kB ← SIN hash, a propósito
├── assets/
│ ├── index-C4f8a2b1.js 38,2 kB ← el paquete principal
│ ├── informe-a91b3c7d.js 9,4 kB ← trozo cargado a demanda
│ ├── calendario-7f2e9d10.js 6,5 kB
│ └── index-B2d91e0f.css 11,8 kB
└── iconos/
├── icono-192.png
└── icono-512.pngCuatro cosas ocurren aquí y conviene entender cada una.
1 · Empaquetado. Los cientos de módulos ES de src/ se combinan en unos pocos ficheros. En desarrollo cada import era una petición; en producción son tres o cuatro descargas. Es lo que hace posible cumplir el presupuesto de «≤ 4 peticiones» de 11-01.
2 · División de código (09-05). Las pantallas que no se ven al arrancar salen en trozos aparte, cargados cuando hacen falta:
// El informe solo se descarga si alguien lo abre
const { crearInformeVista } = await import('./vista/informe-vista.js');3 · Minificación. Se eliminan espacios, comentarios y nombres largos, y se aplican optimizaciones como el tree shaking: si importas una función de un módulo y no usas las otras cinco, las otras cinco no se incluyen. Esto solo funciona con módulos ES estáticos, que es una de las razones por las que 05-04 insistía en ellos.
4 · Hashing del nombre. index-C4f8a2b1.js contiene un resumen del contenido. Si el contenido cambia, el nombre cambia. Esta es la pieza que hace posible la estrategia de caché del apartado 7, y merece verla con claridad:
| Situación | Nombre | Consecuencia |
|---|---|---|
| Sin hash | index.js |
Hay que elegir entre caché larga (usuarios con versión vieja) o corta (descarga en cada visita) |
| Con hash | index-C4f8a2b1.js |
Caché de un año y actualización inmediata: el fichero nuevo tiene otro nombre |
Es una solución elegante a un problema que parecía irresoluble, y explica por qué index.html no lleva hash: alguien tiene que ser el punto de entrada estable que apunte a los ficheros con hash.
Configuración de Vite comentada:
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
base: '/orbita/', // ← si sirves desde un subdirectorio (GitHub Pages)
build: {
outDir: 'dist',
sourcemap: true, // ← mapas de código: ver más abajo
target: 'es2020',
rollupOptions: {
output: {
manualChunks: {
// Separar lo que cambia poco de lo que cambia mucho
dominio: ['./src/dominio/tarea.js', './src/dominio/tablero.js', './src/dominio/arbol.js']
}
}
},
chunkSizeWarningLimit: 60 // avisa si un trozo supera tu presupuesto
}
});base es la causa número uno de despliegues rotos. Si sirves en https://usuario.github.io/orbita/, las rutas absolutas /assets/... apuntarían a la raíz del dominio, donde no hay nada. Con base: '/orbita/', Vite genera /orbita/assets/.... En Netlify, Vercel o un dominio propio, donde la aplicación está en la raíz, base debe ser '/'.
Sobre sourcemap: true, que tiene un matiz que conviene decidir conscientemente: los mapas de código permiten depurar en producción viendo tu código original en lugar del minificado, y son imprescindibles para que un servicio de seguimiento de errores dé trazas legibles. La contrapartida es que exponen tu código fuente. Para un proyecto de portafolio con repositorio público, no hay ninguna razón para ocultarlo. En un producto comercial, la práctica habitual es generarlos y subirlos al servicio de errores sin publicarlos en el servidor.
- Variables de entorno y por qué no hay secretos en el cliente
Vite inyecta en el paquete las variables que empiecen por VITE_:
# .env.production
VITE_API_URL=https://api.orbita.example/v1
VITE_ENTORNO=produccion
VITE_VERSION=1.0.0Y aquí viene el punto más importante de toda la lección, que hay que entender sin ambigüedad:
import.meta.env.VITE_LO_QUE_SEAse sustituye literalmente por su valor durante la compilación. Ese valor acaba escrito en un fichero.jsque se descarga en el navegador de cualquiera. No es una variable: es una constante pública.
Compruébalo tú mismo, y hazlo ahora:
npm run build
grep -r "VITE_" dist/ # se ven los nombres reemplazados
grep -r "sk_live\|api_key\|secret" dist/ # ← esto debe devolver CERO resultadosQué puede ir en el cliente y qué no:
| Puede ir | No puede ir jamás |
|---|---|
| La URL pública de tu API | Claves de API secretas |
| El nombre del entorno | Contraseñas o cadenas de conexión a base de datos |
| La versión de la aplicación | Secretos de firma de tokens |
| Identificadores públicos de servicios (Google Analytics, Sentry DSN público) | Credenciales de correo, pasarelas de pago, servicios en la nube |
| Banderas de funcionalidad no sensibles | Cualquier cosa que dé acceso a datos que no son de ese usuario |
Por qué la gente se equivoca aquí, y con qué consecuencias. El razonamiento erróneo es: «está en una variable de entorno, y las variables de entorno son secretas». Lo son en el servidor. En una compilación de cliente, la variable de entorno solo describe dónde se escribió el valor, no dónde acaba. Y el valor acaba en un fichero público, minificado pero perfectamente legible con Ctrl+F.
Las consecuencias son reales y caras: claves de servicios en la nube filtradas en repositorios públicos se detectan automáticamente por robots en cuestión de minutos, y el resultado suele ser una factura por uso ajeno.
Qué hacer si tu proyecto necesita hablar con un servicio que exige clave secreta. Solo hay una respuesta correcta: un intermediario en el servidor.
flowchart LR
A["Navegador<br/><i>sin secretos</i>"] -->|"petición pública"| B["Tu función en servidor<br/><i>guarda la clave</i>"]
B -->|"clave secreta"| C["Servicio externo"]
C --> B --> A
style A fill:#dcfce7,stroke:#16a34a
style B fill:#dbeafe,stroke:#2563eb
Netlify Functions, Vercel Functions y Cloudflare Workers permiten hacerlo con veinte líneas y sin montar un servidor completo. Es el apartado 10.
Y la regla del repositorio: .env en .gitignore, siempre, con un .env.example versionado que documente qué variables hacen falta y con valores de ejemplo. Si alguna vez confirmas un secreto por error, cambiarlo es obligatorio: borrarlo del historial no basta, porque ya está en cualquier clon que se hiciera mientras tanto.
- Dónde alojar: tabla comparativa honesta
Una aplicación como la tuya es un sitio estático: HTML, CSS, JavaScript y algunas imágenes. No hay proceso de servidor ejecutando tu código. Eso abre muchas opciones y todas son buenas; las diferencias están en los detalles.
| Plataforma | Coste | Despliegue | Vistas previas | Funciones de servidor | Cabeceras a medida | Dominio propio | Cuándo elegirla |
|---|---|---|---|---|---|---|---|
| GitHub Pages | Gratis | Acción de GitHub | ❌ | ❌ | Muy limitadas | ✅ con HTTPS | Portafolio puro; lo más simple |
| Netlify | Gratis generoso | Git o CLI | ✅ por PR | ✅ Functions | ✅ _headers, _redirects |
✅ | La recomendada para este proyecto |
| Vercel | Gratis generoso | Git o CLI | ✅ por PR | ✅ Functions | ✅ vercel.json |
✅ | Muy similar; excelente si luego usas Next.js |
| Cloudflare Pages | Gratis muy amplio | Git o CLI | ✅ por rama | ✅ Workers | ✅ _headers |
✅ | Red global excelente; límites generosos |
| Servidor propio + Nginx | Coste del servidor | Tuyo (rsync, CI) | Manual | Lo que montes | Control total | ✅ (Let's Encrypt) | Cuando necesitas control o ya tienes servidor |
Lo que la tabla no dice y conviene saber:
GitHub Pages es la opción más simple y tiene una limitación que afecta directamente a este proyecto: no permite configurar cabeceras HTTP. Eso significa que no puedes poner una Content-Security-Policy real (solo la versión en <meta>, que es más limitada), ni controlar el Cache-Control, ni configurar el fallback de SPA salvo con el truco del 404.html. Para un portafolio es perfectamente válido; para practicar el apartado 9 de esta lección, no.
Netlify, Vercel y Cloudflare Pages hacen esencialmente lo mismo desde el punto de vista de un sitio estático: conectas el repositorio, cada push a main despliega, cada PR genera una URL de vista previa, y tienes un fichero de configuración para cabeceras y redirecciones. Elegir entre ellas para este proyecto es cuestión de preferencia; las tres son excelentes.
Un servidor propio con Nginx es el único que te obliga a entender qué está pasando, y precisamente por eso es formativo. También es el único donde tú eres responsable de las actualizaciones de seguridad, las copias de seguridad y el certificado.
Un aviso sobre los planes gratuitos, porque conviene la honestidad: son generosos y suficientes para un proyecto personal, pero tienen límites (minutos de compilación, ancho de banda, invocaciones de función) y cambian con el tiempo. Lee los límites actuales antes de comprometerte, y ten en cuenta que un proyecto que crece puede acabar necesitando un plan de pago.
Recomendación para este módulo: despliega en Netlify o Cloudflare Pages. Tienes vistas previas por PR, cabeceras configurables y funciones de servidor si las necesitas, sin coste y sin administrar nada. Y si quieres el ejercicio completo, monta además una configuración de Nginx equivalente aunque sea en local: entender el fichero de configuración enseña más que cualquier panel de control.
- Dominio, HTTPS y certificados
HTTPS no es opcional, y no solo por seguridad:
| Sin HTTPS no funcionan | Motivo |
|---|---|
| Service workers | La especificación lo exige |
| Notificaciones, geolocalización, cámara, portapapeles | Contextos seguros obligatorios |
crypto.subtle |
Ídem |
| La instalación como PWA | Requisito del manifiesto |
| La confianza del usuario | El navegador muestra «No es seguro» |
Además, los datos viajan en claro: en una wifi pública, cualquiera puede leer y modificar lo que se envía.
Cómo se consigue. En las plataformas del apartado anterior, automáticamente y gratis: emiten y renuevan el certificado por ti. En un servidor propio, con Let's Encrypt y certbot:
sudo certbot --nginx -d orbita.example -d www.orbita.example
# Renovación automática cada 60 días mediante temporizador del sistema
sudo certbot renew --dry-runEl dominio. Un dominio propio cuesta entre diez y quince euros al año y cambia por completo la percepción del proyecto. La configuración es un registro DNS:
| Tipo | Nombre | Valor | Para qué |
|---|---|---|---|
CNAME |
www |
tu-sitio.netlify.app |
El subdominio apunta a la plataforma |
A o ALIAS |
@ |
La IP o el alias que indique la plataforma | El dominio raíz |
Y una decisión que hay que tomar y no dejar a medias: elige una forma canónica —con www o sin www— y redirige la otra con un 301. Tener las dos activas duplica el contenido para los buscadores, parte la caché y confunde a los usuarios. Lo mismo con http → https, que debe ser una redirección permanente.
HSTS (Strict-Transport-Security) le dice al navegador que nunca intente conectarse por HTTP a tu dominio:
Es una de las cabeceras más eficaces que existen. Con una advertencia importante: es difícil de revertir. Una vez que un navegador la ha visto, se niega a usar HTTP en ese dominio durante el max-age aunque tú quites la cabecera. Empieza con un valor pequeño (max-age=300), comprueba que todo funciona, y sube a un año.
- Las rutas de una SPA y el fallback a
index.html
index.htmlEste es el problema que sorprende a todo el mundo en su primer despliegue.
Tu enrutador (11-01) usa la History API: al navegar a la vista de informe, la URL pasa a https://orbita.example/informe. Funciona perfectamente… hasta que alguien recarga la página o comparte ese enlace.
sequenceDiagram
participant U as Usuario
participant S as Servidor
U->>S: GET /informe
S->>S: ¿Existe el fichero /informe?
S-->>U: 404 Not Found ❌
Note over U,S: Tu aplicación nunca llega a cargarse
La causa es simple: el servidor no sabe nada de tu enrutador. Busca un fichero llamado informe y no existe.
La solución se llama fallback a index.html: cualquier ruta que no corresponda a un fichero real devuelve index.html, y entonces tu JavaScript arranca, lee la URL y pinta la pantalla correspondiente.
Con una advertencia importante: el fallback debe devolver código 200, no 404, o los buscadores indexarán tus rutas como errores.
En Netlify (public/_redirects):
En Vercel (vercel.json):
En Cloudflare Pages (public/_redirects): idéntico a Netlify.
En Nginx:
try_files prueba en orden: el fichero exacto, el directorio, y si no existe ninguno, index.html. Es exactamente la lógica que necesitas.
En GitHub Pages no hay configuración de servidor, así que se usa un truco: crear un 404.html idéntico al index.html. GitHub lo sirve para rutas inexistentes y la aplicación arranca. Funciona, pero devuelve código 404, lo cual es malo para SEO. Es una de las razones para preferir otra plataforma si te importa la indexación.
Y el detalle que rompe el fallback: si aplicas la regla a todo, /api/tareas también devolverá index.html, y tu código recibirá HTML donde esperaba JSON — con un error de análisis desconcertante. Excluye las rutas de API antes de la regla general:
location /api/ { proxy_pass http://127.0.0.1:3000; } # antes del location /
location / { try_files $uri $uri/ /index.html; }
- Caché: la parte que más se hace mal
La caché es donde más se equivoca la gente, y produce dos fallos opuestos e igual de malos:
| Fallo | Causa | Síntoma |
|---|---|---|
| Todo se cachea demasiado | Caché larga en index.html |
Los usuarios siguen con la versión antigua durante días |
| Nada se cachea | Sin Cache-Control |
Se descarga todo en cada visita: lento y caro |
La solución correcta aprovecha el hashing del apartado 2, y se resume en una regla de dos líneas:
Los ficheros con hash en el nombre: caché de un año, inmutable. El
index.html, el service worker y el manifiesto: nunca en caché.
La lógica es impecable: si el contenido cambia, el nombre cambia, luego un fichero con hash jamás necesita revalidarse. Y index.html, que es quien apunta a los nombres nuevos, debe pedirse siempre.
La tabla completa:
| Recurso | Cache-Control |
Por qué |
|---|---|---|
assets/*-[hash].js y .css |
public, max-age=31536000, immutable |
El nombre cambia si cambia el contenido |
| Imágenes con hash | public, max-age=31536000, immutable |
Ídem |
| Fuentes | public, max-age=31536000, immutable |
Rara vez cambian; si lo hacen, se les pone hash |
index.html |
no-cache |
Debe revalidarse siempre para descubrir los nombres nuevos |
sw.js |
no-cache |
Si se cachea, los usuarios se quedan con el service worker antiguo |
manifest.json |
no-cache o max-age=3600 |
Cambia poco pero debe poder actualizarse |
| Respuestas de API | no-store si son privadas |
No cachear datos de usuario en intermediarios |
no-cache no significa «no guardar». Significa «guarda, pero revalida con el servidor antes de usar». Con ETag, la revalidación suele devolver un 304 sin cuerpo: coste mínimo y siempre actualizado. Lo que no guarda nada es no-store.
En Netlify o Cloudflare Pages (public/_headers):
/assets/*
Cache-Control: public, max-age=31536000, immutable
/index.html
Cache-Control: no-cache
/sw.js
Cache-Control: no-cache
/manifest.json
Cache-Control: no-cacheEn Nginx:
# Ficheros con hash: caché máxima
location ~* ^/assets/.*\.[0-9a-zA-Z_-]{8}\.(js|css|woff2|png|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# El punto de entrada y el service worker: nunca en caché
location = /index.html { add_header Cache-Control "no-cache"; }
location = /sw.js { add_header Cache-Control "no-cache"; }El caso especial del service worker merece énfasis porque produce el fallo más desconcertante de todos. Si sw.js se sirve con caché larga, el navegador no descarga el nuevo, así que el service worker antiguo sigue sirviendo la versión antigua de tu aplicación indefinidamente. El usuario recarga, borra el historial, y sigue viendo lo mismo. Los navegadores modernos limitan la caché de sw.js a 24 horas por defecto, pero no confíes en eso: pon no-cache explícito.
Cómo comprobar que está bien configurado:
curl -sI https://orbita.example/assets/index-C4f8a2b1.js | grep -i cache-control
# → cache-control: public, max-age=31536000, immutable
curl -sI https://orbita.example/index.html | grep -i cache-control
# → cache-control: no-cache
- Compresión
Comprimir es la optimización con mejor relación entre esfuerzo y resultado que existe: una línea de configuración y entre un 60 % y un 80 % menos de bytes en texto.
| Algoritmo | Reducción típica en JS | Compatibilidad | Coste de CPU |
|---|---|---|---|
| Sin comprimir | — | Todos | 0 |
| gzip | ~70 % | Universal | Bajo |
| Brotli | ~75–80 % | Todos los navegadores actuales | Mayor al comprimir |
Las plataformas gestionadas lo hacen solas y no tienes que configurar nada. En Nginx:
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024; # comprimir cosas de 200 bytes no compensa
# Brotli, si el módulo está disponible
brotli on;
brotli_types text/css application/javascript application/json image/svg+xml;Dos matices importantes:
- No comprimas lo ya comprimido. PNG, JPEG, WebP, MP4 y WOFF2 ya lo están. Volver a comprimirlos gasta CPU y a veces aumenta el tamaño.
- Tu presupuesto de 11-01 está en bytes comprimidos. Los 60 kB del presupuesto se miden en la columna «Transferred» del panel Network, no en «Size». Confundirlas hace que creas que incumples cuando cumples, o al revés.
- Cabeceras de seguridad, una a una
Estas cabeceras son gratis, se configuran una vez y cierran clases enteras de ataques. Van explicadas de una en una porque copiarlas sin entenderlas produce sitios rotos que nadie sabe arreglar.
9.1 Content-Security-Policy
Es la más potente y la más delicada. Declara de dónde puede cargar recursos tu página; todo lo demás lo bloquea el navegador.
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.orbita.example; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'Directiva a directiva:
| Directiva | Qué controla | Valor y por qué |
|---|---|---|
default-src 'self' |
Lo que no esté especificado | Solo tu propio origen |
script-src 'self' |
De dónde se cargan scripts | La clave: bloquea scripts inyectados y en línea |
style-src 'self' |
Hojas de estilo | Ídem para CSS |
img-src 'self' data: |
Imágenes | data: permite SVG en línea e iconos incrustados |
connect-src |
fetch, XHR, WebSocket |
Solo tu API: si te inyectan código, no puede exfiltrar datos a otro servidor |
object-src 'none' |
<object>, <embed> |
No los necesitas y son vectores clásicos |
base-uri 'self' |
La etiqueta <base> |
Impide reescribir todas tus rutas relativas |
form-action 'self' |
Destino de los formularios | Impide que un formulario envíe datos fuera |
frame-ancestors 'none' |
Quién puede meterte en un iframe | Evita el clickjacking; sustituye a X-Frame-Options |
Por qué la CSP es tan eficaz: es la segunda línea de defensa contra XSS. En 11-03 aprendiste la primera —textContent en lugar de innerHTML—, y la CSP es el paracaídas de reserva: aunque un atacante consiga inyectar <script>alert(1)</script> en tu página, script-src 'self' impide que se ejecute, porque no viene de tu origen.
Los dos escollos prácticos:
- Los estilos en línea. Si tu código hace
elemento.style.color = 'red', la CSP constyle-src 'self'lo bloquea. La solución correcta es usar clases CSS en lugar de estilos en línea — que es mejor práctica de todos modos. La solución perezosa es'unsafe-inline', que desactiva buena parte de la protección. Evítala. - Los scripts en línea. Si tienes
<script>con código dentro del HTML, se bloquea. Vite no genera ninguno por defecto, así que normalmente no es problema.
Cómo implantarla sin romper nada: empieza en modo informe, que no bloquea nada y te avisa de lo que bloquearía:
Navega por toda tu aplicación, mira las advertencias de la consola, ajusta la política, y solo entonces quita el -Report-Only.
9.2 Las demás
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY| Cabecera | Qué hace | Por qué la quieres |
|---|---|---|
X-Content-Type-Options: nosniff |
Impide que el navegador adivine el tipo de un fichero por su contenido | Sin ella, un fichero subido por un usuario que «parece» JavaScript podría ejecutarse como tal |
Referrer-Policy |
Cuánta información de la URL de origen se envía al navegar fuera | strict-origin-when-cross-origin envía la URL completa dentro de tu sitio y solo el dominio hacia fuera: evita filtrar identificadores de tus URL a terceros |
Permissions-Policy |
Desactiva funciones del navegador que no usas | Si no usas cámara ni micrófono, declararlo cierra la puerta a que un script inyectado los pida |
Strict-Transport-Security |
Fuerza HTTPS | Apartado 5. Cuidado con max-age al principio |
X-Frame-Options: DENY |
Impide que te metan en un iframe | Redundante con frame-ancestors pero cubre navegadores antiguos |
En Netlify o Cloudflare Pages (public/_headers):
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.orbita.example; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Strict-Transport-Security: max-age=31536000; includeSubDomainsCómo verificarlo. Hay servicios públicos que analizan tus cabeceras y te dan una puntuación con explicaciones. También basta con:
curl -sI https://orbita.example/ | grep -iE "content-security|x-content|referrer|permissions|strict-transport"Conseguir una buena puntuación de cabeceras de seguridad es un detalle pequeño con mucho peso en una revisión técnica: demuestra que has pensado en el despliegue, no solo en el código.
- El backend: qué opciones hay sin escribir un servidor
Tu proyecto puede funcionar perfectamente sin servidor: los datos en localStorage, todo en el navegador. Es una decisión legítima si está documentada con sus limitaciones (un solo dispositivo, sin privacidad real, sin compartir).
Si necesitas más, hay tres caminos:
| Opción | Qué es | Esfuerzo | Control | Cuándo |
|---|---|---|---|---|
| Sin backend | Todo en el navegador | Ninguno | Total sobre el cliente | MVP, portafolio, uso personal |
| Backend como servicio (BaaS) | Un servicio te da base de datos, autenticación y API | Bajo | Medio; dependes del proveedor | Cuando necesitas multiusuario sin escribir servidor |
| Funciones sin servidor | Trozos de código en el servidor para casos concretos | Bajo-medio | Alto en lo que escribes | Ocultar una clave, enviar un correo, validar algo |
| Backend propio | Node.js + base de datos, escrito por ti | Alto | Total | Cuando la lógica de servidor es el producto |
Backend como servicio. Servicios como Supabase, Firebase o PocketBase te dan base de datos, API automática, autenticación y a veces tiempo real, con configuración en lugar de código. Para un proyecto de portafolio que necesita usuarios y datos compartidos, es la opción con mejor relación entre esfuerzo y resultado.
Lo que hay que saber antes de elegir uno:
- Dependes del proveedor. Migrar después tiene coste. Mitígalo manteniendo tu frontera del repositorio (11-02): si el BaaS vive detrás de
RepositorioSupabase, cambiarlo es reescribir un fichero. - Las reglas de seguridad son tu responsabilidad. Estos servicios exponen la base de datos directamente al cliente, protegida por reglas que tú configuras. Una regla mal escrita expone todos los datos de todos los usuarios. Es el fallo más frecuente y más grave de estas plataformas.
- Los planes gratuitos tienen límites y el servicio puede cambiar de precios o desaparecer.
Funciones sin servidor. Cuando solo necesitas ocultar una clave o hacer una operación puntual en servidor:
// netlify/functions/tipo-cambio.js — la clave NUNCA llega al navegador
export async function handler(evento) {
const clave = process.env.CLAVE_SERVICIO; // variable del servidor, no VITE_
const respuesta = await fetch(`https://servicio.example/api?key=${clave}`);
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
body: JSON.stringify(await respuesta.json())
};
}Fíjate en que la variable no lleva prefijo VITE_: eso es exactamente lo que la mantiene fuera del paquete del cliente.
Backend propio. Es el camino natural si quieres crecer, y es el tema de la lección 11-07: Node.js con Express o Fastify, una base de datos, autenticación con tokens, y toda la lógica de servidor que tu aplicación necesite. Requiere aprender cosas nuevas y merece la pena, pero no es requisito de este proyecto.
- La validación del cliente nunca sustituye a la del servidor
Este apartado es corto, no tiene matices, y es de los que más importan.
Tienes quince reglas de negocio, R1 a R15, implementadas y probadas en dominio/. Funcionan perfectamente… en el navegador. Y el navegador es un entorno que el usuario controla por completo:
- Puede abrir DevTools y llamar a tus funciones con lo que quiera.
- Puede modificar tu código antes de que se ejecute.
- Puede saltarse tu aplicación entera y llamar a la API con
curl.
# Ninguna validación de cliente interviene aquí
curl -X POST https://api.orbita.example/v1/tareas \
-H "Content-Type: application/json" \
-d '{"titulo":"","horasEstimadas":9999,"estado":"hecha","responsableId":"otro-usuario"}'Si el servidor acepta eso, tus quince reglas no valen nada.
La validación del cliente es una comodidad para el usuario. La validación del servidor es la que protege los datos. No son alternativas: son dos capas con propósitos distintos, y las dos son obligatorias en cuanto hay servidor.
| Capa | Propósito | Qué pasa si falta |
|---|---|---|
| Cliente | Retroalimentación inmediata, evitar viajes inútiles, buena experiencia | La aplicación se siente lenta y torpe, pero los datos siguen protegidos |
| Servidor | Integridad de los datos y seguridad | Cualquiera puede escribir lo que quiera: datos corruptos, cuentas ajenas modificadas |
La buena noticia es que tu arquitectura ya está preparada para esto. dominio/ es JavaScript puro sin dependencias del navegador — esa fue la primera frontera de 11-01. Si escribes el backend en Node.js, puedes importar exactamente los mismos ficheros y ejecutar las mismas reglas en el servidor:
// servidor/rutas/tareas.js — el MISMO dominio, sin duplicar nada
import { Tarea } from '../../src/dominio/tarea.js';
import { ErrorDeValidacion } from '../../src/dominio/errores.js';
app.post('/v1/tareas', async (peticion, respuesta) => {
try {
const tarea = new Tarea(peticion.body); // R1-R15 aplicadas en el servidor
respuesta.status(201).json(await repositorio.guardar(tarea));
} catch (error) {
if (error instanceof ErrorDeValidacion) {
return respuesta.status(400).json({ codigo: 'VALIDACION', campo: error.campo, mensaje: error.message });
}
throw error;
}
});Una sola implementación de las reglas, ejecutada en los dos lados. Eso es lo que valía la pena de mantener el dominio libre de dependencias durante todo el proyecto, y es un argumento excelente para contar en una entrevista (11-06).
Y lo que el servidor debe validar además de tus reglas, porque son cosas que el cliente no puede hacer:
| Comprobación | Por qué solo puede hacerla el servidor |
|---|---|
| Autenticación: ¿quién eres? | El cliente puede decir que es quien quiera |
| Autorización: ¿puedes tocar esto? | R15 (roles) es trivial de saltarse en el cliente |
| Límite de peticiones | Protege contra abuso y contra bucles accidentales |
| Tamaño máximo del cuerpo | Evita que alguien envíe 500 MB |
| Unicidad real | Solo la base de datos puede garantizarla sin carreras |
- Despliegue continuo con GitHub Actions
Despliegue continuo significa que lo que se fusiona en main llega a producción automáticamente, sin pasos manuales. Con la CI de 11-04 protegiendo la rama, eso es seguro: nada llega a main sin lint, pruebas, cobertura, presupuesto y recorridos en verde.
# .github/workflows/desplegar.yml
name: Desplegar
on:
push:
branches: [main]
workflow_dispatch: # permite lanzarlo a mano desde la interfaz
concurrency:
group: despliegue-produccion
cancel-in-progress: false # NO cancelar un despliegue a medias
jobs:
desplegar:
runs-on: ubuntu-latest
environment:
name: produccion
url: https://orbita.example
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- name: Verificar antes de desplegar
run: npm run verificar # cinturón y tirantes: nunca se despliega en rojo
- name: Compilar para producción
run: npm run build
env:
VITE_API_URL: ${{ vars.API_URL }}
VITE_VERSION: ${{ github.sha }}
- name: Comprobar que no hay secretos en el paquete
run: |
if grep -rEq "(sk_live|api[_-]?key|BEGIN [A-Z ]*PRIVATE KEY)" dist/; then
echo "::error::Posible secreto en dist/"
exit 1
fi
- name: Desplegar en Netlify
uses: nwtgck/actions-netlify@v3
with:
publish-dir: './dist'
production-deploy: true
deploy-message: ${{ github.event.head_commit.message }}
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
- name: Comprobación de humo
run: |
sleep 15
curl -sfI https://orbita.example/ | head -1
curl -sf https://orbita.example/ | grep -q "Órbita" || exit 1Las decisiones del fichero:
| Elemento | Por qué |
|---|---|
cancel-in-progress: false |
Cancelar un despliegue a mitad puede dejar el sitio inconsistente. Aquí sí se espera |
workflow_dispatch |
Poder redesplegar a mano sin hacer un commit vacío |
environment con url |
GitHub muestra el enlace y guarda el historial de despliegues |
npm run verificar otra vez |
La CI ya pasó, pero un despliegue nunca debe fiarse de otro flujo |
VITE_VERSION: github.sha |
Cada despliegue sabe de qué commit viene. Vale oro al depurar en producción |
El grep de secretos |
Última red de seguridad automática antes de publicar |
| Comprobación de humo | Que el despliegue termine no significa que el sitio funcione |
La comprobación de humo es una idea que conviene interiorizar: una verificación mínima y rápida de que lo desplegado responde y contiene lo que debe. Cuesta cinco líneas y detecta el «se desplegó una carpeta vacía», que ocurre más de lo que parece.
Los secretos del repositorio (Settings → Secrets and variables): NETLIFY_AUTH_TOKEN y NETLIFY_SITE_ID van como secrets (cifrados, no visibles ni en los registros); API_URL va como variable (no es secreta y así se puede leer). Nunca escribas un token directamente en el YAML: el fichero está en el repositorio.
- Entornos de vista previa por rama
Un entorno de vista previa es un despliegue temporal de una rama, con su propia URL. Netlify, Vercel y Cloudflare Pages lo hacen automáticamente al abrir una Pull Request.
Por qué merece la pena, aunque trabajes solo:
| Beneficio | Detalle |
|---|---|
| Ves el cambio en condiciones reales | Con la compilación de producción, HTTPS, caché y service worker |
| Puedes probarlo en el móvil | Basta con abrir la URL; nada de configurar red local |
| Puedes pedir opinión | Un enlace, no «clónate el repo y ejecuta esto» |
| Comparas antes y después | Producción y vista previa abiertas en dos pestañas |
| Lighthouse sobre lo real | La CI de 11-04 puede auditar la vista previa en vez de un servidor local |
Tres precauciones importantes:
noindexen las vistas previas. Si Google indexadeploy-preview-42--orbita.netlify.app, tendrás contenido duplicado compitiendo con tu sitio. Las plataformas suelen ponerlo, pero compruébalo.- Nunca apuntes una vista previa a datos de producción. Una prueba destructiva en una vista previa que escribe en la base real es un desastre evitable. Usa un entorno de datos separado.
- Las vistas previas son públicas. Cualquiera con la URL entra. No pruebes ahí con datos reales de nadie.
- Estrategia de reversión
Todo despliegue puede salir mal. La pregunta no es si ocurrirá, sino cuánto tardarás en arreglarlo cuando ocurra — probablemente a una hora incómoda y con prisa.
Las opciones, de mejor a peor:
| Estrategia | Tiempo | Riesgo | Disponibilidad |
|---|---|---|---|
| Volver al despliegue anterior (botón de la plataforma) | < 1 min | Muy bajo | Netlify, Vercel, Cloudflare |
git revert + despliegue automático |
3–5 min | Bajo | Siempre |
| Arreglar hacia delante (fix forward) | 10–60 min | Alto con prisa | Siempre |
| Restaurar desde una copia | Variable | Medio | Servidor propio |
La primera es la buena, y hay que probarla antes de necesitarla. Las plataformas gestionadas guardan todos los despliegues anteriores y permiten volver a cualquiera con un clic, porque cada despliegue es un conjunto inmutable de ficheros estáticos.
El procedimiento escrito, que debe estar en tu README o en docs/operaciones.md:
## Si un despliegue rompe producción
1. **Revertir primero, investigar después.** El objetivo inmediato es que
los usuarios tengan algo que funcione, no entender qué pasó.
→ Panel de Netlify → Deploys → el anterior → "Publish deploy"
2. Comprobar que el sitio funciona (comprobación de humo manual).
3. `git revert <sha>` en `main` para que el código refleje la realidad.
NO usar `git reset --force` sobre una rama compartida.
4. Abrir una incidencia con: qué se rompió, cómo se detectó, qué se revirtió.
5. Reproducir el fallo **en local o en una vista previa**, con una prueba
que falle (método de 11-04).
6. Corregir, con la prueba en verde, y volver a desplegar.
7. Escribir un post mortem breve: qué pasó, por qué la CI no lo detectó,
qué prueba o comprobación se añade para que no vuelva.El punto 1 es contraintuitivo y es el correcto. La tentación es investigar mientras el sitio está roto. Pero cada minuto de investigación es un minuto de servicio caído, y la prisa hace que se tomen peores decisiones. Revertir es reversible; arreglar con prisa, no.
El punto 7 es el que convierte un incidente en aprendizaje. Y la pregunta clave del post mortem no es «quién se equivocó» sino «por qué la CI no lo detectó», porque la respuesta es siempre una comprobación nueva que evita toda una familia de fallos futuros.
Una precaución sobre la base de datos: si tu despliegue incluyó una migración de datos (como las de 11-03), revertir el código no revierte los datos. Por eso las migraciones deben ser compatibles hacia atrás siempre que se pueda: añadir campos, no renombrarlos ni borrarlos en el mismo despliegue que los deja de usar. La secuencia segura es en dos despliegues: primero añadir y escribir en los dos sitios, después dejar de usar el viejo.
- Monitorización de errores en producción
En desarrollo, un error aparece en tu consola. En producción, aparece en la consola de un usuario que no la va a mirar y no te lo va a contar: simplemente dejará de usar tu aplicación.
En 11-02 montaste un registro local con las dos redes de seguridad globales (error y unhandledrejection). Ahora hay que enviarlo a algún sitio.
Las opciones:
| Opción | Esfuerzo | Qué obtienes |
|---|---|---|
| Solo el registro local + panel de diagnóstico | Ninguno | Nada hasta que alguien te lo enseñe |
| Servicio de seguimiento de errores | Bajo | Errores agrupados, con traza, contexto y frecuencia |
| Punto de acceso propio que recibe errores | Medio | Control total, y también responsabilidad total |
Servicios de seguimiento de errores. Sentry, Bugsnag, Rollbar y similares tienen planes gratuitos suficientes para un proyecto personal, se integran con tres líneas y agrupan errores idénticos —lo cual importa mucho: un fallo que ocurre 400 veces es una entrada, no 400 correos.
Y aquí viene la parte que hay que hacer bien:
Un servicio de seguimiento de errores envía datos de tus usuarios a un tercero. Eso es una decisión de privacidad, no solo técnica.
Lo que hay que configurar antes de activarlo:
| Configuración | Por qué |
|---|---|
| Filtrar datos personales antes de enviar | Nombres, correos, contenido escrito por el usuario |
| No enviar el cuerpo de las peticiones | Puede contener cualquier cosa |
| Enmascarar el DOM si usas grabación de sesión | Una grabación captura literalmente todo lo que se ve |
| Reducir el muestreo | No necesitas el 100 % de los eventos, y menos datos es mejor |
| Mencionarlo en el aviso de privacidad | Es un encargado de tratamiento; hay que declararlo |
| Comprobar dónde se almacenan los datos | Las transferencias fuera de la UE tienen requisitos propios |
// src/util/registro-remoto.js
export function enviarError(entrada) {
if (import.meta.env.DEV) return;
const seguro = {
mensaje: entrada.mensaje,
nombre: entrada.nombre,
pila: entrada.pila,
version: import.meta.env.VITE_VERSION,
ruta: location.pathname, // NO location.href: puede llevar parámetros
contexto: { caso: entrada.contexto?.caso, campos: entrada.contexto?.campos }
// NUNCA: valores de formulario, nombres de usuario, identificadores personales, tokens
};
navigator.sendBeacon('/api/errores', JSON.stringify(seguro));
}Dos detalles del código: location.pathname en lugar de location.href evita enviar parámetros de consulta que pueden contener búsquedas o identificadores; y sendBeacon envía sin bloquear y funciona aunque la página se esté cerrando, que es justo cuando ocurren muchos errores.
Advertencia. No envíes datos personales a un servicio externo sin haberlo previsto en tu aviso de privacidad y sin conocer dónde se almacenan. En un proyecto de aprendizaje con datos ficticios el riesgo es nulo; en un producto real, activar un servicio de seguimiento sin más es un problema de cumplimiento normativo, no un detalle técnico.
- Métricas de campo con
web-vitals
web-vitalsEn 09-01 aprendiste la distinción que más confusión evita: laboratorio (tu Lighthouse, tu máquina, tu red) frente a campo (usuarios reales, dispositivos reales, redes reales). Los números de campo son siempre peores, y son los que importan.
// src/util/vitales.js
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals';
function enviar(metrica) {
navigator.sendBeacon('/api/vitales', JSON.stringify({
nombre: metrica.name, // LCP, INP, CLS, TTFB
valor: Math.round(metrica.value),
calificacion: metrica.rating, // good | needs-improvement | poor
ruta: location.pathname,
version: import.meta.env.VITE_VERSION,
conexion: navigator.connection?.effectiveType ?? 'desconocida'
}));
}
if (import.meta.env.PROD) {
onLCP(enviar); onINP(enviar); onCLS(enviar); onTTFB(enviar);
}Cómo leer los resultados, que es donde está el valor:
| Observación | Qué significa | Qué hacer |
|---|---|---|
| Laboratorio 1,9 s, campo p75 4,2 s | Tus usuarios tienen peores dispositivos y redes | Estrangula más al medir en local |
| CLS bueno en escritorio, malo en móvil | Algo se reajusta solo en pantallas pequeñas | Revisar con el emulador de dispositivos |
| INP malo solo en una ruta | Una pantalla concreta tiene trabajo excesivo | Perfilar esa pantalla (09-02) |
| Empeoramiento tras un despliegue | Una regresión concreta | Comparar por version: por eso se envía |
Ese último punto es el que justifica todo el esfuerzo: enviar la versión con cada métrica permite atribuir una regresión a un despliegue concreto. Sin eso, solo sabes que empeoró en algún momento.
Y una nota de privacidad: las métricas de rendimiento no son datos personales si no incluyen identificadores. No añadas un identificador de usuario «para poder correlacionar»; el percentil 75 no lo necesita.
- Actualizar una PWA ya instalada
Este es el problema más específico del despliegue de una PWA, y produce una situación exasperante: el usuario tiene la aplicación instalada, tú despliegas una corrección, y él sigue viendo la versión antigua. Recarga, y nada.
La causa está en el ciclo de vida del service worker (07-05):
stateDiagram-v2
[*] --> Instalando: se detecta un sw.js distinto
Instalando --> Instalado: install completado
Instalado --> EnEspera: hay un SW activo controlando pestañas
EnEspera --> Activando: skipWaiting() o se cierran TODAS las pestañas
Activando --> Activo: activate completado
Activo --> [*]
El estado «En espera» es el problema. Por defecto, un service worker nuevo espera a que todas las pestañas de la aplicación se cierren. Y como mucha gente nunca cierra del todo una PWA instalada, esa espera puede durar días.
La solución completa, en tres piezas.
Pieza 1 · El service worker permite saltarse la espera bajo demanda:
// sw.js
const VERSION = 'orbita-v7';
self.addEventListener('install', (evento) => {
evento.waitUntil(caches.open(VERSION).then((c) => c.addAll(RECURSOS)));
// Nada de skipWaiting() automático: el usuario decide
});
self.addEventListener('activate', (evento) => {
evento.waitUntil(
caches.keys()
.then((claves) => Promise.all(claves.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('message', (evento) => {
if (evento.data?.tipo === 'SALTAR_ESPERA') self.skipWaiting();
});Pieza 2 · La aplicación detecta que hay una versión nueva y lo dice:
// src/util/actualizacion.js
export async function vigilarActualizaciones() {
if (!('serviceWorker' in navigator)) return;
const registro = await navigator.serviceWorker.register('/sw.js');
registro.addEventListener('updatefound', () => {
const nuevo = registro.installing;
nuevo.addEventListener('statechange', () => {
if (nuevo.state === 'installed' && navigator.serviceWorker.controller) {
mostrarAvisoDeActualizacion(() => {
nuevo.postMessage({ tipo: 'SALTAR_ESPERA' });
});
}
});
});
// Cuando el nuevo toma el control, recargar UNA vez
let recargando = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (recargando) return;
recargando = true;
location.reload();
});
setInterval(() => registro.update(), 60 * 60 * 1000); // comprobar cada hora
}Pieza 3 · El aviso, que debe ser discreto y accesible:
<div role="status" aria-live="polite" class="aviso-actualizacion" hidden>
<p>Hay una versión nueva disponible.</p>
<button type="button" data-accion="actualizar">Actualizar ahora</button>
<button type="button" data-accion="mas-tarde">Más tarde</button>
</div>Las cuatro reglas de la actualización de una PWA:
- Nunca recargues sin avisar. El usuario puede estar escribiendo. Una recarga sorpresa que pierde un formulario es imperdonable.
- La bandera
recargandoes imprescindible. Sin ella,controllerchangepuede provocar un bucle de recargas: es un fallo real y muy desagradable. sw.jsconCache-Control: no-cache(apartado 7). Sin eso, nada de esto funciona porque el navegador ni siquiera descarga el service worker nuevo.- Limpia las cachés viejas en
activate. Si no, se acumulan versiones y acabas ocupando cientos de megabytes en el dispositivo de otra persona.
El plan de emergencia, que conviene tener escrito: si despliegas un service worker roto que rompe la aplicación para todos los que la tienen instalada, la salida es publicar un sw.js mínimo que se desregistre a sí mismo y limpie todas las cachés:
// sw.js de emergencia
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', async () => {
const claves = await caches.keys();
await Promise.all(claves.map((k) => caches.delete(k)));
await self.registration.unregister();
const clientes = await self.clients.matchAll();
clientes.forEach((c) => c.navigate(c.url));
});Guárdalo. El día que lo necesites, lo agradecerás.
- La lista de comprobación de lanzamiento
El entregable final del hito. Se recorre entera, se marca, y se guarda firmada y fechada en docs/lanzamiento.md.
18.1 Técnico
| # | Comprobación | Cómo |
|---|---|---|
| 1 | npm run verificar en verde |
Local y en CI |
| 2 | Compilación de producción probada en local | build + preview |
| 3 | Cero secretos en dist/ |
grep del apartado 3 |
| 4 | HTTPS activo y http redirige |
curl -I http://… devuelve 301 |
| 5 | Una sola forma canónica (con o sin www) |
La otra redirige con 301 |
| 6 | Fallback de SPA con código 200 | curl -I …/informe |
| 7 | Caché correcta en ficheros con hash | curl -I sobre assets/… |
| 8 | index.html y sw.js con no-cache |
curl -I |
| 9 | Compresión activa | Comparar Size y Transferred |
| 10 | Las cinco cabeceras de seguridad presentes | curl -I o analizador público |
| 11 | CSP sin unsafe-inline y sin errores en consola |
Navegar por toda la aplicación |
| 12 | Presupuesto de rendimiento cumplido en producción | Lighthouse sobre la URL real |
| 13 | Accesibilidad ≥ 95 en Lighthouse, 0 incidencias graves de axe | Ídem |
| 14 | Funciona sin conexión | DevTools en Offline |
| 15 | La PWA se instala y se actualiza | Instalar, desplegar, ver el aviso |
| 16 | Reversión probada de verdad | Volver al despliegue anterior y regresar |
18.2 Contenido y descubribilidad
| # | Comprobación | Detalle |
|---|---|---|
| 17 | <title> único y descriptivo |
«Órbita — Gestión de trabajo para equipos pequeños» |
| 18 | <meta name="description"> de 150–160 caracteres |
Lo que se lee en los resultados de búsqueda |
| 19 | <html lang="es"> |
Accesibilidad y buscadores |
| 20 | Open Graph completo | og:title, og:description, og:image (1200×630), og:url, og:type |
| 21 | La tarjeta social se ve bien | Probar con un validador de enlaces |
| 22 | Favicon en varios tamaños | 16, 32, 180 (Apple), 192 y 512 (PWA) |
| 23 | robots.txt presente |
Con Sitemap: apuntando al sitemap |
| 24 | sitemap.xml con las rutas públicas |
Aunque sean pocas |
| 25 | Página 404 propia y útil | Con enlace a inicio, no una pantalla vacía |
| 26 | manifest.json correcto |
name, short_name, start_url, display, theme_color, iconos |
<meta property="og:title" content="Órbita — Gestión de trabajo para equipos pequeños">
<meta property="og:description" content="Tareas, subtareas, carga por persona e historial de cambios. Sin frameworks.">
<meta property="og:image" content="https://orbita.example/og-imagen.png">
<meta property="og:url" content="https://orbita.example/">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">18.3 Legal y operativo
| # | Comprobación | Detalle |
|---|---|---|
| 27 | Aviso de privacidad | Qué datos se guardan, dónde, cuánto tiempo, y con qué terceros |
| 28 | Cookies y almacenamiento | Si usas analítica o seguimiento, el consentimiento tiene requisitos legales |
| 29 | Aviso visible sobre los datos | «Los datos se guardan solo en este navegador y no son privados frente a quien use este dispositivo» |
| 30 | Licencia en el repositorio | MIT, Apache 2.0 o la que elijas |
| 31 | Copia de seguridad | Exportación de datos disponible para el usuario |
| 32 | Procedimiento de reversión escrito | docs/operaciones.md |
| 33 | Forma de contacto | Para que alguien pueda reportar un fallo |
Advertencia legal. Los puntos 27 y 28 no son formalidades. En la Unión Europea, informar sobre el tratamiento de datos personales es una obligación, y el uso de cookies o almacenamiento no estrictamente necesario requiere consentimiento previo, informado y revocable — un banner que solo dice «aceptar» no cumple. Si tu proyecto es un ejercicio con datos ficticios y sin analítica, el riesgo práctico es nulo, pero acostúmbrate a incluir el aviso. Si algún día publicas un producto con usuarios reales, esto requiere asesoramiento legal específico: ni esta lección ni ninguna documentación técnica lo sustituyen.
Un aviso de privacidad honesto para un proyecto como este es corto y se escribe en diez minutos:
# Aviso de privacidad — Órbita
**Qué datos se guardan.** Las tareas, usuarios e historial que introduces se
guardan **únicamente en el almacenamiento local de tu navegador**. No se envían
a ningún servidor.
**Quién puede verlos.** Cualquier persona con acceso a este navegador y a este
dispositivo. Órbita **no** ofrece privacidad frente a otros usuarios del mismo
equipo. No introduzcas información confidencial.
**Errores.** Si ocurre un fallo, se registran el mensaje técnico, la ruta y la
versión de la aplicación. **No** se registra el contenido de tus tareas.
**Cómo borrar tus datos.** Ajustes → Borrar todos los datos. También puedes
borrar los datos del sitio desde tu navegador.
**Exportar tus datos.** Ajustes → Exportar (formato JSON).
**Contacto.** <tu forma de contacto>
Última actualización: 2026-11-28Errores Comunes y Consejos
Poner un secreto en una variable VITE_. Acaba escrito en un fichero público que cualquiera lee con Ctrl+F, y los robots que rastrean repositorios lo encuentran en minutos. Cualquier cosa que dé acceso a algo va detrás de una función de servidor. Comprueba dist/ con grep antes de cada despliegue, y automatízalo en el flujo.
No configurar el fallback de SPA. Todo funciona navegando desde la portada y da 404 al recargar o al abrir un enlace compartido — que es exactamente cómo llegará la gente a quien enseñes el proyecto.
Caché larga en index.html. Los usuarios se quedan con la versión antigua durante días y no hay forma de forzarlos a actualizar. Los ficheros con hash llevan caché de un año; el punto de entrada, no-cache.
Caché en sw.js. Produce el fallo más desconcertante que existe: el usuario recarga, borra el historial, reinstala, y sigue viendo la aplicación de hace tres despliegues.
base mal configurado. En GitHub Pages con subdirectorio, sin base: '/repo/' la página carga en blanco y la consola se llena de 404 sobre /assets/…. Es el fallo número uno del primer despliegue.
Añadir 'unsafe-inline' a la CSP para que deje de quejarse. Desactiva buena parte de la protección contra XSS, que es justo lo que la CSP existía para dar. Arregla el estilo o el script en línea; casi siempre son dos líneas.
Confiar solo en la validación del cliente. Cualquiera puede llamar a tu API con curl. Con servidor, las reglas van también allí — y tu dominio sin dependencias hace que sea el mismo código.
Desplegar sin haber probado la reversión. El día que la necesites, con el sitio caído y prisa, no es el momento de descubrir cómo funciona. Pruébala hoy, en frío.
Recargar la PWA sin avisar cuando hay versión nueva. El usuario puede estar escribiendo. Aviso discreto, botón, y recarga solo cuando lo pida — con la bandera que evita el bucle de recargas.
Enviar datos personales a un servicio de seguimiento de errores. Es un problema de cumplimiento, no de estilo. Filtra antes de enviar, envía pathname y no href, y declara el servicio en tu aviso de privacidad.
Consejo · Despliega desde el primer día y con frecuencia. Un despliegue por semana durante dos meses es aburrido y seguro. Un despliegue enorme el último día es la receta de un desastre.
Consejo · Ten un panel de diagnóstico en producción. Una ruta discreta que muestre versión, commit, fecha de compilación, estado del service worker, espacio ocupado y últimos errores. Cuando alguien te diga «no me funciona», esa pantalla responde en diez segundos.
Consejo · Guarda una captura de la línea base de producción. Lighthouse sobre la URL real, con fecha. Dentro de seis meses sabrás si has mejorado o empeorado, y tendrás la prueba.
Consejo · Prueba tu sitio desplegado en un móvil de verdad. No en el emulador. Con datos móviles, no wifi. Descubrirás cosas que ningún estrangulamiento simulado te enseña.
Ejercicios
Estos ejercicios son el hito H6, primera parte: la aplicación desplegada con HTTPS, su flujo de despliegue continuo y la lista de comprobación firmada.
Ejercicio 1 — La compilación y el despliegue.
- Configura
vite.config.jsconbasecorrecto para tu plataforma,sourcemap,manualChunksychunkSizeWarningLimitcon tu presupuesto. - Crea
.env.exampleversionado y.env.productionignorado, y documenta en el README qué variables hacen falta. - Compila y audita
dist/: tamaño total, número de ficheros, ygrepde secretos. Documenta los resultados. - Prueba con
npm run previewtoda la aplicación antes de desplegar. Anota los problemas que aparezcan solo en producción (habrá al menos uno). - Despliega en la plataforma que elijas, con dominio propio o subdominio de la plataforma, y HTTPS activo.
- Configura el fallback de SPA y comprueba con
curl -Ique devuelve 200 en una ruta interna. - Configura la caché completa según la tabla del apartado 7 y verifícala con
curl -Isobre un fichero con hash, sobreindex.htmly sobresw.js. - Configura las cinco cabeceras de seguridad, con la CSP primero en modo informe y luego activa, sin
unsafe-inline. - Documenta cada decisión en
docs/despliegue.md: qué plataforma, por qué, y qué implica.
Ejercicio 2 — Despliegue continuo, vistas previas y reversión.
- Escribe
.github/workflows/desplegar.ymlcon: verificación previa, compilación con la versión del commit, comprobación automática de secretos, despliegue y comprobación de humo. - Configura los secretos y variables del repositorio correctamente (secretos cifrados, variables normales).
- Activa las vistas previas por PR y comprueba las tres precauciones:
noindex, datos separados, y conciencia de que son públicas. - Prueba la reversión de verdad: despliega un cambio visible, revierte al anterior, comprueba que el sitio vuelve, y vuelve a publicar el nuevo. Cronometra cuánto tardas.
- Escribe
docs/operaciones.mdcon el procedimiento de reversión de siete pasos adaptado a tu plataforma. - Configura la monitorización de errores con filtrado de datos personales, y demuestra que un error provocado a propósito llega con la traza y sin datos de usuario.
- Instrumenta
web-vitalsenviando la versión, y recoge al menos una sesión de datos de campo. - Implementa la actualización de la PWA con las tres piezas del apartado 17, y demuéstralo: instala la aplicación, despliega un cambio, y comprueba que aparece el aviso, que el botón actualiza y que no hay bucle de recargas.
Ejercicio 3 — El lanzamiento.
- Recorre las 33 comprobaciones del apartado 18 sobre tu sitio desplegado. Marca cada una con evidencia (comando ejecutado, captura o URL), no de memoria.
- Completa el bloque de contenido:
<title>, descripción, Open Graph con imagen de 1200×630, favicons en cinco tamaños,robots.txt,sitemap.xml, página 404 propia ymanifest.jsoncompleto. - Escribe el aviso de privacidad con la plantilla del apartado 18.3, adaptado a lo que tu aplicación hace realmente.
- Añade la licencia al repositorio.
- Ejecuta Lighthouse sobre la URL de producción (no en local) y compara con tu presupuesto de 11-01 y con tu línea base de 11-04. Documenta las diferencias y explícalas.
- Pásale el sitio a un analizador de cabeceras de seguridad y documenta la puntuación.
- Ábrelo en un móvil real con datos móviles y anota todo lo que no funcione como esperabas.
- Firma y fecha
docs/lanzamiento.md.
Soluciones
Criterios de aceptación del ejercicio 1 — Despliegue
| # | Criterio | Verificación |
|---|---|---|
| 1 | El sitio carga por HTTPS | curl -sI https://… devuelve 200 |
| 2 | HTTP redirige a HTTPS | curl -sI http://… devuelve 301 |
| 3 | Solo una forma canónica | La otra devuelve 301 |
| 4 | Una ruta interna recargada funciona | curl -sI …/informe devuelve 200 |
| 5 | Fichero con hash cacheado un año | cache-control: …max-age=31536000, immutable |
| 6 | index.html con no-cache |
curl -sI |
| 7 | sw.js con no-cache |
curl -sI |
| 8 | Compresión activa | Transferred < 40 % de Size |
| 9 | Las cinco cabeceras presentes | curl -sI | grep -iE … |
| 10 | CSP sin unsafe-inline |
Inspección + consola sin violaciones |
| 11 | Cero secretos en dist/ |
`grep -rE "(sk_ |
| 12 | Lighthouse en producción cumple el presupuesto | Informe adjunto |
Rúbrica del ejercicio 1 (21 puntos)
| Dimensión | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| Compilación | Sin configurar | Compila | base y trozos correctos |
Además con límite de tamaño activo |
| Secretos | Hay alguno | Ninguno por suerte | Verificado a mano | Verificado en el flujo automático |
| HTTPS y dominio | Sin HTTPS | Con HTTPS | Con redirección | Además canónico y con HSTS progresivo |
| Rutas | 404 al recargar | Fallback | Con código 200 | Además con API excluida |
| Caché | Sin configurar | Algo | Tabla completa | Verificada con curl y documentada |
| Seguridad | Ninguna cabecera | Algunas | Las cinco | CSP sin unsafe-inline y probada en modo informe |
| Documentación | No hay | Menciona la plataforma | Con justificación | Con las implicaciones de cada elección |
Umbral: 15/21, con obligatoriamente 3 en «Secretos». Un secreto filtrado invalida el ejercicio: es el único fallo de esta lección con consecuencias reales fuera del proyecto.
Criterios de aceptación del ejercicio 2 — Continuo y operaciones
| # | Criterio | Verificación |
|---|---|---|
| 1 | Un push a main despliega solo |
Ver el flujo y el sitio actualizado |
| 2 | Un fallo de verificar impide el despliegue |
Provocarlo |
| 3 | El paquete con un secreto falso rompe el flujo | Provocarlo y quitarlo |
| 4 | La comprobación de humo detecta un despliegue vacío | Provocarlo |
| 5 | Cada PR genera una vista previa | Abrir una |
| 6 | Las vistas previas no se indexan | Comprobar X-Robots-Tag o la meta |
| 7 | Revertir tarda menos de 2 minutos | Cronometrado |
| 8 | El procedimiento está escrito | docs/operaciones.md |
| 9 | Un error de producción llega al servicio | Provocarlo |
| 10 | Ese error no contiene datos personales | Inspeccionar el evento recibido |
| 11 | Las métricas de campo incluyen la versión | Inspeccionar el envío |
| 12 | La PWA avisa de la versión nueva | Demostración con la aplicación instalada |
| 13 | No hay bucle de recargas | Recargar varias veces tras actualizar |
| 14 | Las cachés viejas se limpian | caches.keys() en la consola: una sola |
Criterios de aceptación del ejercicio 3 — Lanzamiento
| # | Criterio | Verificación |
|---|---|---|
| 1 | Las 33 comprobaciones con evidencia | No basta con marcar la casilla |
| 2 | El enlace compartido muestra tarjeta correcta | Validador de enlaces |
| 3 | El favicon se ve en la pestaña | Visual, en dos navegadores |
| 4 | El 404 es propio y ofrece salida | Visitar una ruta inventada |
| 5 | robots.txt y sitemap.xml accesibles |
Por URL |
| 6 | El manifiesto permite instalar | Aparece la opción de instalación |
| 7 | El aviso de privacidad es honesto y específico | Describe tu aplicación, no una plantilla genérica |
| 8 | Hay licencia | Fichero LICENSE |
| 9 | Lighthouse en producción documentado | Con las diferencias respecto a local explicadas |
| 10 | Puntuación de cabeceras documentada | Con las pendientes anotadas |
| 11 | Probado en móvil real | Lista de hallazgos |
| 12 | Documento firmado y fechado | docs/lanzamiento.md |
Rúbrica global del hito H6 primera parte (24 puntos)
| Dimensión | Peso | Qué se evalúa |
|---|---|---|
| Compilación y secretos | 5 | base, trozos, cero secretos verificado automáticamente |
| Servidor | 5 | Rutas, caché, compresión, HTTPS canónico |
| Seguridad | 4 | Las cinco cabeceras, CSP real sin unsafe-inline |
| Despliegue continuo | 4 | Automático, con verificación, humo y vistas previas |
| Operaciones | 3 | Reversión probada, procedimiento escrito, monitorización sin datos personales |
| PWA | 2 | Actualización con aviso, sin bucle, cachés limpias |
| Lanzamiento | 1 | Las 33 comprobaciones con evidencia |
Umbral: 17/24. Con una condición que no se compensa: cero secretos en el paquete. Todo lo demás se puede mejorar en la siguiente iteración; una clave filtrada, no.
Autoevaluación del hito H6:
| Pregunta | Sí / No |
|---|---|
| ¿Puedo abrir una ruta interna en una pestaña nueva y funciona? | |
¿He comprobado con grep que no hay secretos en dist/? |
|
| ¿Sé cuánto tardo en revertir, porque lo he cronometrado? | |
| ¿Un usuario con la PWA instalada recibirá mi próxima corrección? | |
| ¿Mi aviso de privacidad describe lo que mi aplicación hace de verdad? | |
| ¿He abierto mi sitio en un móvil real con datos móviles? | |
| ¿Podría explicar cada una de las cinco cabeceras de seguridad? |
Conclusión
Tu proyecto ya no vive en tu portátil: está en internet, con HTTPS, y cualquiera puede usarlo.
Sabes qué cambia cuando el código sale de tu máquina —nueve diferencias, cada una fuente de sorpresas— y tienes la regla que evita la mitad de ellas: nunca despliegues nada que no hayas probado con build y preview. Conoces la compilación de producción por dentro: empaquetado que reduce peticiones, división de código que retrasa lo que no se ve, minificación que solo funciona bien gracias a los módulos ES estáticos de 05-04, y el hashing del nombre, que es la pieza elegante que permite tener a la vez caché de un año y actualización inmediata — y que explica por qué index.html es el único que no lo lleva. Con base bien configurado, que es la causa número uno de primeros despliegues en blanco.
Tienes clarísimo lo más importante de la lección: no hay secretos en el cliente. import.meta.env.VITE_* no es una variable: es una constante pública escrita literalmente en un fichero que se descarga. Sabes qué puede ir y qué no, sabes que el razonamiento erróneo («está en una variable de entorno») confunde dónde se escribió el valor con dónde acaba, y sabes que la única respuesta correcta cuando hace falta una clave es un intermediario en el servidor. Con la verificación por grep automatizada en el flujo, porque las buenas intenciones se olvidan y los robots que rastrean repositorios no.
Sabes dónde alojar con una tabla honesta que incluye lo que las comparativas no dicen: que GitHub Pages no permite cabeceras reales, que Netlify, Vercel y Cloudflare Pages son equivalentes para un sitio estático, que un servidor propio es el único que te obliga a entender lo que pasa, y que los planes gratuitos tienen límites que cambian. Y sabes por qué HTTPS no es opcional: sin él no hay service worker, ni PWA, ni contextos seguros, ni confianza.
Sabes resolver el problema que sorprende a todo el mundo —las rutas de una SPA—, con fallback a index.html que devuelve 200 y no 404, en las cuatro plataformas, y con la exclusión de las rutas de API que evita recibir HTML donde esperabas JSON.
Tienes la caché bien hecha, que es donde más se equivoca la gente: un año e inmutable para lo que lleva hash, no-cache para el punto de entrada, el service worker y el manifiesto. Sabes que no-cache significa «revalida», no «no guardes». Y sabes que un sw.js cacheado produce el fallo más desconcertante que existe: usuarios atrapados en una versión de hace tres despliegues por mucho que recarguen.
Sabes configurar las cabeceras de seguridad una a una, entendiéndolas: la CSP como segunda línea de defensa contra XSS —con connect-src impidiendo la exfiltración, frame-ancestors evitando el clickjacking, y la advertencia de que 'unsafe-inline' desactiva justo lo que la política venía a dar—, implantada primero en modo informe; nosniff, Referrer-Policy, Permissions-Policy y HSTS con su max-age progresivo porque es difícil de revertir.
Sabes qué hacer con el backend: que no tenerlo es legítimo si está documentado con sus limitaciones; que un backend como servicio resuelve el multiusuario sin escribir servidor, con la advertencia de que las reglas de seguridad son tuyas y una mal escrita expone todos los datos; que una función sin servidor de veinte líneas basta para ocultar una clave; y que escribir el tuyo es el camino de 11-07. Y sabes, sin matices, que la validación del cliente nunca sustituye a la del servidor — con la recompensa de que tu dominio sin dependencias del navegador se puede importar tal cual en Node y ejecutar las mismas R1–R15 en los dos lados, que es exactamente por lo que valía la pena la primera frontera de 11-01.
Tienes despliegue continuo con verificación previa, versión del commit inyectada —lo que hace posible atribuir una regresión a un despliegue—, comprobación automática de secretos y prueba de humo, porque que un despliegue termine no significa que el sitio funcione. Con vistas previas por rama y sus tres precauciones, y con una estrategia de reversión cuya regla es contraintuitiva y correcta: revertir primero, investigar después, porque revertir es reversible y arreglar con prisa no lo es. Con el post mortem cuya pregunta clave no es quién se equivocó sino por qué la CI no lo detectó.
Sabes monitorizar en producción sin convertirte en un problema de privacidad: filtrar antes de enviar, pathname en lugar de href, sendBeacon porque funciona mientras la página se cierra, y la conciencia de que un servicio de seguimiento es un tercero que recibe datos de tus usuarios. Y sabes recoger métricas de campo con web-vitals, con la distinción de 09-01 entre laboratorio y campo, y con la versión adjunta para poder atribuir.
Sabes actualizar una PWA instalada, que es el problema más específico del despliegue de una aplicación web moderna: el estado «en espera» que puede durar días, las tres piezas que lo resuelven, las cuatro reglas —avisar siempre, la bandera contra el bucle de recargas, no-cache en sw.js, limpiar cachés viejas— y el service worker de emergencia que conviene tener guardado antes de necesitarlo.
Y tienes la lista de comprobación de lanzamiento con sus 33 puntos en tres bloques: técnico, contenido y descubribilidad, y legal y operativo — incluidos el aviso de privacidad honesto y específico, la advertencia de que el consentimiento de cookies tiene requisitos legales que un botón de «aceptar» no cumple, y la licencia.
El producto está publicado. Y aquí aparece lo que separa un proyecto terminado de un proyecto que además sirve para algo: nadie sabe que existe, nadie sabe qué decisiones hay detrás, y tú todavía no has practicado cómo contarlo. Un trabajo que no se puede enseñar ni defender vale, en la práctica, mucho menos de lo que es. Convertirlo en algo que alguien entienda en dos minutos, que puedas demostrar en cinco, y que sepas explicar en una entrevista técnica sin sonar ni inseguro ni fanfarrón, es Presentación y Revisión del Proyecto.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
