Skip to content

🎯 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)

EnfoqueCómo funcionaCapítulo
MPA clásica + HTMXEl servidor devuelve HTML; HTMX actualiza trozos de página16
SPA (React, Vue)El servidor devuelve JSON; JavaScript construye toda la UI17, 19
Meta-framework (Next.js)Híbrido: renderiza en servidor Y en cliente18

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.

html
<!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):

EtiquetaPara 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

html
<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 con curl. 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                │    │     │
│  │  └─────────────────────────────────┘    │     │
│  └─────────────────────────────────────────┘     │
└──────────────────────────────────────────────────┘
css
/* SIEMPRE al inicio de tu CSS: width incluye padding y border */
*, *::before, *::after { box-sizing: border-box; }

Selectores y especificidad

css
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 id para JavaScript y anclas. Si te peleas con la especificidad (!important por todas partes), tu CSS está mal organizado.

Variables CSS (custom properties)

css
: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)

css
.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)

css
.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

css
/* 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)

css
.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 y dvh son 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:

css
/* 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 .boton si .boton se declaró después en el CSS — el orden de aparición desempata cuando la especificidad es igual. Con @layer, la capa utilidades gana siempre sobre componentes sin 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

javascript
// 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 textContent para texto. innerHTML solo con HTML que tú controles, jamás con datos del usuario sin sanear: el.innerHTML = comentarioDelUsuario es la puerta de entrada clásica del XSS (apéndice C).

Eventos

javascript
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

javascript
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:5173 y tu API en localhost:8000, son orígenes distintos y el navegador bloqueará la respuesta salvo que la API envíe Access-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

javascript
// api.js
export async function listarProductos() { /* ... */ }
export const API_URL = 'http://localhost:8000';

// app.js
import { listarProductos, API_URL } from './api.js';
html
<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).

bash
# 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 build

Estructura 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.ts

Proxy de desarrollo (adiós CORS en local)

typescript
// 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

bash
# .env            (¡solo variables con prefijo VITE_ llegan al navegador!)
VITE_API_URL=https://api.mitienda.com
typescript
const 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:

typescript
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.

bash
npm install tailwindcss @tailwindcss/vite
css
/* src/styles.css */
@import "tailwindcss";
html
<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étricaQué mideObjetivo
LCPCuándo se pinta el contenido principal< 2,5 s
INPLatencia al interactuar (clicks, teclas)< 200 ms
CLSSaltos de layout mientras carga< 0,1

Reglas de oro que cubren el 80%:

  1. Imágenes: formato moderno (webp/avif), width/height en el HTML (evita CLS), loading="lazy" para las que no se ven al cargar.
  2. JS mínimo: cada KB de JS se descarga, parsea Y ejecuta. Mide con Lighthouse (F12 → Lighthouse) antes de optimizar.
  3. Accesibilidad básica: HTML semántico, alt en 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

  1. HTML semántico primero. Si tu página no funciona sin CSS ni JS, la base está mal.
  2. Mobile-first. Diseña para móvil y amplía; lo contrario duele.
  3. Valida en cliente Y en servidor. El cliente por UX; el servidor por seguridad.
  4. textContent por defecto; innerHTML solo con contenido propio.
  5. Nada de secretos en el frontend. Todo lo que envías al navegador es público.
  6. Estados de UI siempre: cargando, error, vacío y éxito. Los 4, en cada fetch.
  7. 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, usando e.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> tenga width/height fijos aunque la imagen se vea con max-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.