🎯 Meta: entender qué pasa en el navegador: cómo se pinta una página, cómo funciona el DOM, CSS moderno (flexbox, grid, variables) y JavaScript del lado del cliente con
fetch. Este capítulo es la base obligatoria antes de tocar HTMX, React, Next.js o Vue.Versiones (julio 2026): HTML Living Standard · CSS moderno (baseline 2026) · ECMAScript 2025 · Vite 8 (bundler Rolldown en Rust) · Tailwind CSS 4.3 · Node.js 24 LTS.
📘 Si vienes de los capítulos backend ya sabes HTTP, JSON y REST (cap. 00). Aquí aprendes el otro lado del cable: el cliente que consume tus APIs.
15.1 · Qué es el frontend (y qué hace el navegador)
El frontend es todo lo que se ejecuta en el navegador del usuario: la estructura (HTML), el estilo (CSS) y el comportamiento (JavaScript). Tu backend responde datos; el frontend los convierte en una interfaz usable.
Cuando el navegador recibe HTML, hace esto:
┌────────────────────────────────────────────────────────────────┐
│ 1. HTML ──parse──▶ DOM (árbol de nodos de la página) │
│ 2. CSS ──parse──▶ CSSOM (árbol de reglas de estilo) │
│ 3. DOM + CSSOM ──▶ Render tree (qué se ve y cómo) │
│ 4. Layout ─────▶ calcula posición y tamaño de cada caja │
│ 5. Paint ─────▶ pinta píxeles en pantalla │
│ 6. JavaScript puede MODIFICAR el DOM ▶ se repiten 4 y 5 │
└────────────────────────────────────────────────────────────────┘🧠 Idea clave: el DOM (Document Object Model) es la representación viva de la página en memoria. JavaScript no edita "el archivo HTML": edita el DOM, y el navegador re-pinta. Todos los frameworks (React, Vue…) son, al final, formas más cómodas de manipular el DOM.
Las tres formas de construir frontend (mapa del libro)
| Enfoque | Cómo funciona | Capítulo |
|---|---|---|
| MPA clásica + HTMX | El servidor devuelve HTML; HTMX actualiza trozos de página | 16 |
| SPA (React, Vue) | El servidor devuelve JSON; JavaScript construye toda la UI | 17, 19 |
| Meta-framework (Next.js) | Híbrido: renderiza en servidor Y en cliente | 18 |
15.2 · HTML semántico (lo que de verdad importa)
HTML define la estructura y el significado. Usar la etiqueta correcta te da accesibilidad, SEO y CSS más simple gratis.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mi tienda</title>
<link rel="stylesheet" href="estilos.css">
</head>
<body>
<header>
<nav>
<ul>
<li><a href="/">Inicio</a></li>
<li><a href="/productos">Productos</a></li>
</ul>
</nav>
</header>
<main>
<article> <!-- contenido independiente y completo -->
<h1>Teclado mecánico</h1>
<p>Precio: <strong>89,90 €</strong></p>
</article>
<aside>Productos relacionados…</aside> <!-- contenido secundario -->
</main>
<footer>© 2026 Mi tienda</footer>
<script type="module" src="app.js"></script> <!-- module = ESM + defer automático -->
</body>
</html>Etiquetas semánticas que debes usar (en vez de <div> para todo):
| Etiqueta | Para qué |
|---|---|
<header> <footer> <nav> <main> | Estructura de la página (solo un <main> por página) |
<article> <section> <aside> | Bloques de contenido con significado |
<h1>–<h6> | Jerarquía de títulos (¡solo un <h1>, no saltes niveles!) |
<button> | Acciones. Nunca un <div onclick> — pierde teclado y accesibilidad |
<a href> | Navegación a otra URL |
<form> <label> <input> | Formularios (label siempre asociado a su input) |
Formularios: la pieza que une frontend y backend
<form action="/api/productos" method="post">
<label for="nombre">Nombre</label>
<input id="nombre" name="nombre" type="text" required minlength="2">
<label for="precio">Precio</label>
<input id="precio" name="precio" type="number" step="0.01" min="0" required>
<label for="cat">Categoría</label>
<select id="cat" name="categoria">
<option value="perifericos">Periféricos</option>
<option value="audio">Audio</option>
</select>
<button type="submit">Crear</button>
</form>⚠️ Advertencia: la validación HTML (
required,minlength,type=email…) mejora la UX pero no sustituye la validación del backend (cap. 00 y C). Cualquiera puede saltarse el formulario y llamar a tu API concurl. Regla de oro: valida en los dos lados.
15.3 · CSS moderno
CSS controla la presentación. Lo esencial en 2026: box model, flexbox, grid, variables y responsive. Ya no necesitas frameworks pesados ni preprocesadores para el 90% de los casos: CSS nativo tiene anidamiento, variables y container queries.
El box model (todo es una caja)
┌─ margin (espacio FUERA de la caja) ──────────────┐
│ ┌─ border ────────────────────────────────┐ │
│ │ ┌─ padding (espacio DENTRO) ──────┐ │ │
│ │ │ contenido │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘/* SIEMPRE al inicio de tu CSS: width incluye padding y border */
*, *::before, *::after { box-sizing: border-box; }Selectores y especificidad
p { } /* etiqueta (especificidad baja) */
.tarjeta { } /* clase (la que más usarás) */
#cabecera { } /* id (evítalo para estilos)*/
.tarjeta:hover { } /* pseudo-clase */
.lista > li { } /* hijo directo */
.card:has(img) { } /* :has() = "padre que contiene" 🔥 */💡 Regla práctica: estiliza con clases. Reserva los
idpara JavaScript y anclas. Si te peleas con la especificidad (!importantpor todas partes), tu CSS está mal organizado.
Variables CSS (custom properties)
:root {
--color-primario: #2563eb;
--radio: 8px;
--espacio: 1rem;
}
.boton {
background: var(--color-primario);
border-radius: var(--radio);
padding: calc(var(--espacio) / 2) var(--espacio);
}
/* Modo oscuro cambiando SOLO las variables */
@media (prefers-color-scheme: dark) {
:root { --color-primario: #60a5fa; }
}Flexbox: alinear en una dimensión (fila O columna)
.navbar {
display: flex;
justify-content: space-between; /* eje principal: reparte horizontal */
align-items: center; /* eje cruzado: centra vertical */
gap: 1rem; /* espacio entre hijos (usa gap, no margins) */
}justify-content: align-items:
flex-start ▶ ■■■____ center ▶ los hijos centrados verticalmente
center ▶ __■■■__ stretch▶ los hijos estiran su altura
space-between ■_■_■Grid: layout en dos dimensiones (filas Y columnas)
.galeria {
display: grid;
/* columnas responsivas SIN media queries: mínimo 250px, se reparten solas */
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.layout-app {
display: grid;
grid-template-areas:
"sidebar header"
"sidebar main"
"sidebar footer";
grid-template-columns: 250px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100dvh; /* dvh = viewport dinámico (móvil correcto) */
}
.sidebar { grid-area: sidebar; }
.header { grid-area: header; }🧠 ¿Flex o Grid? Flexbox para componentes (navbar, botones en fila, card interna). Grid para layouts de página y cuadrículas. Se combinan: grid para el esqueleto, flex dentro.
Responsive: mobile-first + container queries
/* Mobile-first: estilos base para móvil, y amplías hacia arriba */
.tarjetas { display: grid; grid-template-columns: 1fr; gap: 1rem; }
@media (min-width: 768px) { .tarjetas { grid-template-columns: 1fr 1fr; } }
@media (min-width: 1200px) { .tarjetas { grid-template-columns: repeat(4, 1fr); } }
/* Container queries: el componente responde a SU contenedor, no a la ventana */
.card-wrapper { container-type: inline-size; }
@container (min-width: 400px) {
.card { display: flex; } /* card horizontal solo si SU hueco es ancho */
}Anidamiento nativo (ya no necesitas Sass para esto)
.card {
border: 1px solid #ddd;
& h2 { margin: 0; }
&:hover { box-shadow: 0 4px 12px rgb(0 0 0 / 0.1); }
& .precio { font-weight: bold; }
}⚠️ Compatibilidad: anidamiento,
:has(), container queries ydvhson Baseline (soportados en todos los navegadores modernos) desde 2023-2024. Para features nuevas consulta siempre caniuse.com y el indicador "Baseline" de MDN.
Cascade layers (@layer): domar la especificidad a propósito
El problema clásico de CSS a escala: un reset, un framework de utilidades (Tailwind) y tus propios componentes compiten por especificidad, y ganar "por accidente" obliga a !important. @layer declara un orden explícito entre grupos de reglas, sin importar cuán específico sea cada selector dentro de cada capa:
/* El orden de esta declaración ES el orden de prioridad — se lee de menos a más importante */
@layer reset, base, componentes, utilidades;
@layer reset {
* { margin: 0; padding: 0; }
}
@layer componentes {
.boton { background: blue; padding: 0.5rem 1rem; }
}
@layer utilidades {
/* gana SIEMPRE a .boton de "componentes", aunque tenga menos especificidad */
.bg-red { background: red; }
}💡 Por qué importa en la práctica: sin capas,
.bg-red(una clase de utilidad, poca especificidad) puede perder contra.botonsi.botonse declaró después en el CSS — el orden de aparición desempata cuando la especificidad es igual. Con@layer, la capautilidadesgana siempre sobrecomponentessin importar el orden de escritura ni la especificidad de cada regla. Es exactamente el mecanismo que usa Tailwind 4 (15.5) por debajo para que tus clases nunca pierdan contra el CSS de un componente de terceros.
15.4 · JavaScript en el navegador
Ya conoces JS/TS del backend (caps. 06-07 y apéndice A). Lo nuevo aquí: el DOM y los eventos.
Seleccionar y modificar el DOM
// Seleccionar (querySelector acepta cualquier selector CSS)
const boton = document.querySelector('#comprar');
const cards = document.querySelectorAll('.card'); // NodeList (iterable)
// Modificar
boton.textContent = 'Añadir al carrito'; // texto (seguro)
boton.classList.add('activo'); // clases: add / remove / toggle / contains
boton.disabled = true; // atributos como propiedades
boton.dataset.productoId = '42'; // data-producto-id="42"
// Crear e insertar
const li = document.createElement('li');
li.textContent = 'Nuevo item';
lista.append(li); // también: prepend, before, after, remove⚠️ Seguridad — XSS: usa
textContentpara texto.innerHTMLsolo con HTML que tú controles, jamás con datos del usuario sin sanear:el.innerHTML = comentarioDelUsuarioes la puerta de entrada clásica del XSS (apéndice C).
Eventos
boton.addEventListener('click', (evento) => {
console.log('click en', evento.currentTarget);
});
// Formularios: intercepta el submit para hacerlo con fetch
formulario.addEventListener('submit', async (e) => {
e.preventDefault(); // evita la recarga de página
const datos = Object.fromEntries(new FormData(formulario));
// datos = { nombre: "...", precio: "..." } ← ¡ojo: todo llega como string!
});
// Delegación: UN listener en el padre gestiona N hijos (incluso futuros)
lista.addEventListener('click', (e) => {
const boton = e.target.closest('button.borrar');
if (boton) borrarItem(boton.dataset.id);
});fetch: consumir tu API desde el navegador
const API = 'http://localhost:8000';
// GET
async function listarProductos() {
const res = await fetch(`${API}/productos`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch NO lanza error por 4xx/5xx
return res.json();
}
// POST
async function crearProducto(producto) {
const res = await fetch(`${API}/productos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(producto),
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.mensaje ?? `HTTP ${res.status}`);
}
return res.json();
}
// Uso con estados de carga y error (patrón que repetirás en TODOS los frameworks)
async function cargar() {
spinner.hidden = false;
try {
const productos = await listarProductos();
pintarLista(productos);
} catch (err) {
mensajeError.textContent = 'No se pudo cargar. Reintenta.';
} finally {
spinner.hidden = true;
}
}🧠 CORS, el error favorito de todo principiante: si tu frontend corre en
localhost:5173y tu API enlocalhost:8000, son orígenes distintos y el navegador bloqueará la respuesta salvo que la API envíeAccess-Control-Allow-Origin. No es un bug de tu JS: se configura en el backend (lo viste en cada framework, caps. 03-08) o se evita con un proxy en el dev server (ver 15.5).
Módulos ESM
// api.js
export async function listarProductos() { /* ... */ }
export const API_URL = 'http://localhost:8000';
// app.js
import { listarProductos, API_URL } from './api.js';<script type="module" src="app.js"></script>15.5 · Herramientas: npm, Vite 8 y el ecosistema
En proyectos reales no escribes <script src> a mano: usas un bundler que empaqueta módulos, optimiza, y te da recarga instantánea en desarrollo. El estándar en 2026 es Vite 8 (con Rolldown, su bundler en Rust: builds 10-30× más rápidos que Vite 5).
# Crear un proyecto (elige vanilla, vanilla-ts, react, vue…)
npm create vite@latest mi-frontend
cd mi-frontend
npm install
npm run dev # http://localhost:5173 con hot-reload
npm run build # genera dist/ optimizado para producción
npm run preview # sirve dist/ para probar el buildEstructura típica:
mi-frontend/
├── index.html ← punto de entrada (Vite lo procesa)
├── src/
│ ├── main.ts ← tu código
│ ├── api.ts
│ └── styles.css
├── public/ ← estáticos que se copian tal cual (favicon…)
├── package.json
└── vite.config.tsProxy de desarrollo (adiós CORS en local)
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
// El frontend llama a /api/... y Vite lo reenvía al backend
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});Variables de entorno en frontend
# .env (¡solo variables con prefijo VITE_ llegan al navegador!)
VITE_API_URL=https://api.mitienda.comconst API = import.meta.env.VITE_API_URL;⚠️ Advertencia crítica: TODO lo que pongas en el frontend es público. Cualquier "secreto" en variables
VITE_*, en el código JS o en el HTML lo puede leer cualquiera con F12. Las claves privadas (API keys de pago, tokens de servicio…) viven solo en el backend.
TypeScript en el frontend
Todo lo del apéndice A aplica. En frontend, TypeScript brilla tipando las respuestas de tu API:
interface Producto {
id: number;
nombre: string;
precio: number;
categoria: 'perifericos' | 'audio';
}
async function listar(): Promise<Producto[]> {
const res = await fetch('/api/productos');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<Producto[]>;
}💡 Si tu backend es NestJS/Elysia (caps. 06-07) puedes generar los tipos desde el esquema OpenAPI (
openapi-typescript) o compartirlos en un monorepo. Una sola fuente de verdad.
Tailwind CSS 4 (opcional pero omnipresente)
Tailwind es CSS por clases de utilidad: en vez de inventar nombres de clases, compones estilos en el HTML. La v4 se configura en CSS (ya no hay tailwind.config.js obligatorio) y compila 5× más rápido.
npm install tailwindcss @tailwindcss/vite/* src/styles.css */
@import "tailwindcss";<button class="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700
disabled:opacity-50">
Comprar
</button>🧠 ¿Tailwind o CSS "normal"? Tailwind acelera equipos y prototipos y evita el CSS muerto; CSS puro te obliga a entender la base (imprescindible). Aprende primero CSS, adopta Tailwind después si el proyecto lo pide. En este libro los ejemplos usan CSS puro por claridad.
15.6 · Rendimiento y accesibilidad mínimos
Core Web Vitals (lo que mide Google y sienten tus usuarios):
| Métrica | Qué mide | Objetivo |
|---|---|---|
| LCP | Cuándo se pinta el contenido principal | < 2,5 s |
| INP | Latencia al interactuar (clicks, teclas) | < 200 ms |
| CLS | Saltos de layout mientras carga | < 0,1 |
Reglas de oro que cubren el 80%:
- Imágenes: formato moderno (
webp/avif),width/heighten el HTML (evita CLS),loading="lazy"para las que no se ven al cargar. - JS mínimo: cada KB de JS se descarga, parsea Y ejecuta. Mide con Lighthouse (F12 → Lighthouse) antes de optimizar.
- Accesibilidad básica: HTML semántico,
alten imágenes, contraste suficiente, navegable con teclado (Tab). Pruébalo: deja el ratón y usa tu página solo con teclado.
15.7 · Buenas prácticas frontend
- HTML semántico primero. Si tu página no funciona sin CSS ni JS, la base está mal.
- Mobile-first. Diseña para móvil y amplía; lo contrario duele.
- Valida en cliente Y en servidor. El cliente por UX; el servidor por seguridad.
textContentpor defecto;innerHTMLsolo con contenido propio.- Nada de secretos en el frontend. Todo lo que envías al navegador es público.
- Estados de UI siempre: cargando, error, vacío y éxito. Los 4, en cada fetch.
- Mide antes de optimizar: Lighthouse y la pestaña Network son tus
EXPLAIN ANALYZE.
✅ Ejercicio del capítulo
Frontend vanilla para tu API de productos (la de los caps. 03-08, el backend que prefieras):
1. Proyecto con Vite (vanilla-ts) + proxy /api hacia tu backend.
2. Página con grid responsivo de tarjetas de producto (CSS Grid + variables CSS).
3. Carga la lista con fetch: estados de cargando / error / vacío / éxito.
4. Formulario de creación con validación HTML + envío con fetch (sin recargar).
5. Botón borrar en cada tarjeta usando delegación de eventos.
6. Modo oscuro con prefers-color-scheme y variables CSS.
7. Pásale Lighthouse: consigue > 90 en Performance y Accessibility.Con la base dominada, empieza lo divertido: HTMX, que te da interactividad devolviendo HTML desde el backend que ya tienes — sin escribir casi JavaScript.
💡 Pistas de la solución (abre solo si te atascas)
- El proxy de Vite (15.5) es lo que te evita CORS en local: sin él, cada
fetch('/api/...')fallará contra tu backend en otro puerto. - Los 4 estados de la lista se controlan mejor con una variable de "fase" (
'cargando' | 'error' | 'vacio' | 'listo') que con varios booleanos sueltos — evita combinaciones imposibles como "cargando Y error" a la vez. - La delegación de eventos del punto 5: UN solo
addEventListener('click', ...)en el contenedor de la lista, usandoe.target.closest('button.borrar')para saber qué tarjeta se borró — no un listener por tarjeta. - Si Lighthouse te penaliza el CLS, revisa que cada
<img>tengawidth/heightfijos aunque la imagen se vea conmax-width: 100%en CSS.
🧠 Autoevaluación
Un <div> no recibe foco de teclado por defecto ni se activa con Enter/Espacio, y los lectores de pantalla no lo anuncian como algo interactivo. <button> trae todo eso gratis — es la misma regla de accesibilidad "usa el elemento nativo" que verás formalizada en el apéndice M.
Porque required es una ayuda de UX que corre en el navegador del usuario — cualquiera puede saltársela llamando a tu API directamente con curl o editando el HTML con las DevTools. El backend es el único punto que no puedes rodear, así que la validación real vive ahí (cap. 00 y apéndice C).
innerHTML interpreta el string como HTML: si contiene <script> o atributos onerror=, el navegador los ejecuta — es la puerta de entrada clásica del XSS (apéndice C). textContent inserta el string como texto plano sin interpretarlo nunca como marcado.
Porque el frontend (localhost:5173) y el backend (localhost:8000) son orígenes distintos para el navegador, aunque ambos sean "localhost". El navegador bloquea la respuesta salvo que el servidor la autorice explícitamente con Access-Control-Allow-Origin, o evites el problema con el proxy de Vite (15.5), que hace que la petición parezca del mismo origen.
Grid resuelve bien dos dimensiones a la vez (filas y columnas de la cuadrícula de productos); Flexbox resuelve una dimensión (alinear el título, precio y botón dentro de una tarjeta). Usar Grid para todo obliga a definir áreas donde Flexbox ya alinea de forma más simple y directa.
Sin capas, ganar una regla depende de una combinación de especificidad del selector y orden de aparición en el archivo — algo frágil a escala, que empuja a abusar de !important. @layer declara un orden de prioridad explícito entre grupos de reglas (reset, componentes, utilidades…): una capa posterior gana siempre sobre una anterior, sin importar cuán específico sea el selector dentro de cada una.
Siguiente: 16-htmx.md — hipermedia moderna: interactividad sin SPA.