Fetch y API REST con Web Components
Aprende a consumir APIs REST usando Web Components (JavaScript vanilla) en Astro
¿Qué son los Web Components?
Los Web Components son una tecnología nativa del navegador que permite crear elementos HTML personalizados y reutilizables usando JavaScript vanilla puro.
Ventajas
- • JavaScript puro, sin frameworks
- • Reutilizables en cualquier proyecto
- • Encapsulan lógica y estilos
- • Funcionan en todos los navegadores
Uso con Astro SSG
- • Página estática + JS interactivo
- • Se cargan con <script src>
- • Se usan como etiquetas HTML
- • No necesitan client:load
1. Estructura del Proyecto
Organización de archivos
mi-proyecto/
├── public/
│ └── js/
│ └── components/
│ └── ProductCatalog.js ← Web Component
│
└── src/
└── pages/
└── tienda.astro ← Página que usa el componente public/js/components/
Aquí van los Web Components. Los archivos en public/ se sirven estáticamente.
src/pages/
Páginas Astro que importan y usan los componentes.
2. Crear el Web Component
Primero creamos el archivo public/js/components/ProductCatalog.js:
1// public/js/components/ProductCatalog.js2 3class ProductCatalog extends HTMLElement {4 5 constructor() {6 super();7 // Estado interno del componente8 this.productos = [];9 this.loading = true;10 this.error = null;11 }12 13 // Atributos que el componente puede recibir14 static get observedAttributes() {15 return ['api-url'];16 }17 18 // Getter para obtener la URL de la API desde el atributo19 get apiUrl() {20 return this.getAttribute('api-url') || '';21 }22 23 // Se ejecuta cuando el componente se añade al DOM24 connectedCallback() {25 this.render();26 this.cargarProductos();27 }28 29 // ============================================30 // MÉTODOS PARA LLAMADAS A LA API (AJAX)31 // ============================================32 33 // GET - Obtener todos los productos34 async cargarProductos() {35 try {36 this.loading = true;37 this.error = null;38 this.render();39 40 const response = await fetch(this.apiUrl + '/productos');41 42 if (!response.ok) {43 throw new Error('Error ' + response.status + ': ' + response.statusText);44 }45 46 this.productos = await response.json();47 48 } catch (err) {49 this.error = err.message;50 console.error('Error cargando productos:', err);51 } finally {52 this.loading = false;53 this.render();54 this.setupEventListeners();55 }56 }57 58 // POST - Crear nuevo producto59 async crearProducto(producto) {60 try {61 const response = await fetch(this.apiUrl + '/productos', {62 method: 'POST',63 headers: {64 'Content-Type': 'application/json'65 },66 body: JSON.stringify(producto)67 });68 69 if (!response.ok) {70 throw new Error('Error al crear producto');71 }72 73 // Recargar la lista74 await this.cargarProductos();75 76 } catch (err) {77 alert('Error: ' + err.message);78 console.error(err);79 }80 }81 82 // PUT - Actualizar producto83 async actualizarProducto(id, datos) {84 try {85 const response = await fetch(this.apiUrl + '/productos/' + id, {86 method: 'PUT',87 headers: {88 'Content-Type': 'application/json'89 },90 body: JSON.stringify(datos)91 });92 93 if (!response.ok) {94 throw new Error('Error al actualizar');95 }96 97 await this.cargarProductos();98 99 } catch (err) {100 alert('Error: ' + err.message);101 }102 }103 104 // DELETE - Eliminar producto105 async eliminarProducto(id) {106 if (!confirm('¿Estás seguro de eliminar este producto?')) {107 return;108 }109 110 try {111 const response = await fetch(this.apiUrl + '/productos/' + id, {112 method: 'DELETE'113 });114 115 if (!response.ok) {116 throw new Error('Error al eliminar');117 }118 119 await this.cargarProductos();120 121 } catch (err) {122 alert('Error: ' + err.message);123 }124 }125 126 // ============================================127 // RENDERIZADO DEL HTML128 // ============================================129 130 render() {131 // Estado: Cargando132 if (this.loading) {133 this.innerHTML = '<div class="loading">Cargando productos...</div>';134 return;135 }136 137 // Estado: Error138 if (this.error) {139 this.innerHTML = '<div class="error">Error: ' + this.error + '</div>';140 return;141 }142 143 // Estado: Sin productos144 if (this.productos.length === 0) {145 this.innerHTML = '<p>No hay productos disponibles</p>';146 return;147 }148 149 // Estado: Mostrar productos150 this.innerHTML =151 '<div class="productos-grid">' +152 this.productos.map(p =>153 '<div class="producto-card" data-id="' + p.id + '">' +154 '<h3>' + p.nombre + '</h3>' +155 '<p class="precio">$' + p.precio + '</p>' +156 '<p class="descripcion">' + (p.descripcion || '') + '</p>' +157 '<div class="acciones">' +158 '<button class="btn-editar">Editar</button>' +159 '<button class="btn-eliminar">Eliminar</button>' +160 '</div>' +161 '</div>'162 ).join('') +163 '</div>';164 }165 166 // ============================================167 // EVENT LISTENERS168 // ============================================169 170 setupEventListeners() {171 // Botones de eliminar172 this.querySelectorAll('.btn-eliminar').forEach(btn => {173 btn.addEventListener('click', (e) => {174 const id = e.target.closest('.producto-card').dataset.id;175 this.eliminarProducto(id);176 });177 });178 179 // Botones de editar180 this.querySelectorAll('.btn-editar').forEach(btn => {181 btn.addEventListener('click', (e) => {182 const card = e.target.closest('.producto-card');183 const id = card.dataset.id;184 const nuevoNombre = prompt('Nuevo nombre:');185 const nuevoPrecio = prompt('Nuevo precio:');186 187 if (nuevoNombre && nuevoPrecio) {188 this.actualizarProducto(id, {189 nombre: nuevoNombre,190 precio: parseFloat(nuevoPrecio)191 });192 }193 });194 });195 }196}197 198// Registrar el componente con su nombre de etiqueta199// IMPORTANTE: El nombre DEBE contener un guión (-)200customElements.define('product-catalog', ProductCatalog);3. Usar el Componente en Astro
Ahora creamos la página src/pages/tienda.astro que usa el componente:
1---2// src/pages/tienda.astro3// Esta página es SSG (estática)4---5 6<!DOCTYPE html>7<html lang="es">8<head>9 <meta charset="UTF-8">10 <meta name="viewport" content="width=device-width, initial-scale=1.0">11 <title>Mi Tienda - Catálogo</title>12 13 <style>14 /* Estilos para el catálogo */15 .productos-grid {16 display: grid;17 grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));18 gap: 1rem;19 }20 21 .producto-card {22 border: 1px solid #ddd;23 border-radius: 8px;24 padding: 1rem;25 background: white;26 }27 28 .producto-card h3 {29 margin: 0 0 0.5rem 0;30 color: #333;31 }32 33 .precio {34 color: #27ae60;35 font-weight: bold;36 font-size: 1.2rem;37 }38 39 .descripcion {40 color: #666;41 font-size: 0.9rem;42 }43 44 .acciones {45 margin-top: 1rem;46 display: flex;47 gap: 0.5rem;48 }49 50 .btn-editar {51 background: #3498db;52 color: white;53 border: none;54 padding: 0.5rem 1rem;55 border-radius: 4px;56 cursor: pointer;57 }58 59 .btn-eliminar {60 background: #e74c3c;61 color: white;62 border: none;63 padding: 0.5rem 1rem;64 border-radius: 4px;65 cursor: pointer;66 }67 68 .loading {69 text-align: center;70 color: #666;71 padding: 2rem;72 }73 74 .error {75 background: #fee;76 color: #c00;77 padding: 1rem;78 border-radius: 4px;79 }80 </style>81</head>82<body>83 <h1>Catálogo de Productos</h1>84 85 <!-- Usar el Web Component -->86 <!-- La URL de la API se pasa como atributo -->87 <product-catalog api-url="https://tu-api-spring.railway.app/api"></product-catalog>88 89 <!-- Cargar el script del componente -->90 <script src="/js/components/ProductCatalog.js"></script>91</body>92</html>4. ¿Cómo Funciona? (Flujo Completo)
Build Time (npm run build)
Astro genera el HTML estático de tienda.astro. El resultado es un archivo HTML con la etiqueta <product-catalog> y el script que carga el componente.
dist/tienda/index.html → HTML estático listo para servir
Usuario visita la página
El navegador descarga el HTML y encuentra la etiqueta <product-catalog>. Como no es una etiqueta estándar, el navegador la ignora temporalmente.
Se carga el script del componente
El navegador descarga y ejecuta /js/components/ProductCatalog.js. Al ejecutar customElements.define('product-catalog', ProductCatalog), el navegador "aprende" qué es esa etiqueta.
Se ejecuta connectedCallback()
El navegador detecta que ya existe un <product-catalog> en el DOM, crea una instancia de la clase y llama a connectedCallback().
fetch() a la API de Spring Boot
El método cargarProductos() hace una petición HTTP GET a la URL que pasamos en el atributo api-url.
GET https://tu-api-spring.railway.app/api/productos Renderizado en el DOM
Cuando la API responde con los datos, el método render() genera el HTML de los productos y lo inserta dentro de <product-catalog> usando this.innerHTML.
Diagrama del Flujo
5. Anatomía del Web Component
constructor()
Se ejecuta cuando se crea la instancia. Aquí inicializamos el estado interno (variables).
constructor() {
super(); // Siempre llamar a super()
this.productos = [];
this.loading = true;
this.error = null;
} static get observedAttributes()
Define qué atributos HTML queremos observar. Si cambian, se llama a attributeChangedCallback.
static get observedAttributes() {
return ['api-url']; // Observamos el atributo api-url
} connectedCallback()
Se ejecuta cuando el elemento se añade al DOM. Es el lugar ideal para inicializar, hacer fetch, etc.
connectedCallback() {
this.render(); // Mostrar estado inicial (loading)
this.cargarProductos(); // Hacer fetch a la API
} this.getAttribute('nombre')
Obtiene el valor de un atributo HTML del componente.
// HTML: <product-catalog api-url="https://api.com">
get apiUrl() {
return this.getAttribute('api-url'); // "https://api.com"
} this.innerHTML = '...'
Reemplaza el contenido HTML interno del componente. Es como usar element.innerHTML en JS normal.
render() {
this.innerHTML = '<p>' + this.productos.length + ' productos</p>';
} customElements.define('tag-name', Clase)
Registra el componente con un nombre de etiqueta. El nombre DEBE contener un guión (-) para diferenciarlo de etiquetas HTML nativas.
// Correcto:
customElements.define('product-catalog', ProductCatalog);
customElements.define('my-button', MyButton);
// Incorrecto (sin guión):
// customElements.define('productcatalog', ProductCatalog); ❌ 6. Configurar CORS en Spring Boot
¿Por qué necesitas CORS?
Tu frontend (Astro en Vercel) y tu backend (Spring Boot en Railway) están en dominios diferentes. Por seguridad, el navegador bloquea estas peticiones a menos que el backend lo permita explícitamente con CORS.
1// src/main/java/com/tuapp/config/CorsConfig.java2package com.tuapp.config;3 4import org.springframework.context.annotation.Bean;5import org.springframework.context.annotation.Configuration;6import org.springframework.web.cors.CorsConfiguration;7import org.springframework.web.cors.UrlBasedCorsConfigurationSource;8import org.springframework.web.filter.CorsFilter;9 10@Configuration11public class CorsConfig {12 13 @Bean14 public CorsFilter corsFilter() {15 CorsConfiguration config = new CorsConfiguration();16 17 // Dominios permitidos18 config.addAllowedOrigin("https://tu-sitio.vercel.app");19 config.addAllowedOrigin("http://localhost:4321"); // Desarrollo20 21 // Métodos HTTP permitidos22 config.addAllowedMethod("GET");23 config.addAllowedMethod("POST");24 config.addAllowedMethod("PUT");25 config.addAllowedMethod("DELETE");26 27 // Headers permitidos28 config.addAllowedHeader("*");29 30 // Aplicar a rutas /api/*31 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();32 source.registerCorsConfiguration("/api/**", config);33 34 return new CorsFilter(source);35 }36}7. Buenas Prácticas
Estados de UI
- • Loading: Mostrar mientras carga
- • Error: Mostrar si falla
- • Empty: Si no hay datos
- • Success: Mostrar los datos
Web Components
- • Nombres con guión:
mi-componente - • Un componente por archivo
- • Usar connectedCallback para iniciar
- • Pasar datos via atributos
Manejo de Errores
- • Siempre usar try/catch
- • Verificar response.ok
- • Mostrar mensajes claros al usuario
- • Loggear en consola para debug
Organización
- • Componentes en
public/js/components/ - • Nombres descriptivos
- • Separar lógica de renderizado
- • Documentar atributos disponibles
Resumen Final
1. Creas el Web Component en public/js/components/NombreComponente.js
2. Defines la clase que extiende HTMLElement
3. Implementas connectedCallback() para iniciar el fetch
4. Registras con customElements.define('tag-name', Clase)
5. En Astro usas <tag-name api-url="..."> + <script src="...">
Este patrón te permite crear componentes reutilizables con JavaScript vanilla puro, perfectos para consumir APIs REST en Astro SSG.