Volver al inicio
Lección 7: Fetch y API REST

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:

public/js/components/ProductCatalog.jsjavascript
1// public/js/components/ProductCatalog.js
2
3class ProductCatalog extends HTMLElement {
4
5 constructor() {
6 super();
7 // Estado interno del componente
8 this.productos = [];
9 this.loading = true;
10 this.error = null;
11 }
12
13 // Atributos que el componente puede recibir
14 static get observedAttributes() {
15 return ['api-url'];
16 }
17
18 // Getter para obtener la URL de la API desde el atributo
19 get apiUrl() {
20 return this.getAttribute('api-url') || '';
21 }
22
23 // Se ejecuta cuando el componente se añade al DOM
24 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 productos
34 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 producto
59 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 lista
74 await this.cargarProductos();
75
76 } catch (err) {
77 alert('Error: ' + err.message);
78 console.error(err);
79 }
80 }
81
82 // PUT - Actualizar producto
83 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 producto
105 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 HTML
128 // ============================================
129
130 render() {
131 // Estado: Cargando
132 if (this.loading) {
133 this.innerHTML = '<div class="loading">Cargando productos...</div>';
134 return;
135 }
136
137 // Estado: Error
138 if (this.error) {
139 this.innerHTML = '<div class="error">Error: ' + this.error + '</div>';
140 return;
141 }
142
143 // Estado: Sin productos
144 if (this.productos.length === 0) {
145 this.innerHTML = '<p>No hay productos disponibles</p>';
146 return;
147 }
148
149 // Estado: Mostrar productos
150 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 LISTENERS
168 // ============================================
169
170 setupEventListeners() {
171 // Botones de eliminar
172 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 editar
180 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 etiqueta
199// 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:

src/pages/tienda.astroastro
1---
2// src/pages/tienda.astro
3// 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)

1

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
2

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.

El navegador muestra la página pero <product-catalog> está vacío
3

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.

El navegador registra: "product-catalog" = clase ProductCatalog
4

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

connectedCallback() → render() + cargarProductos()
5

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
6

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.

El usuario ve los productos en pantalla

Diagrama del Flujo

Build (Astro)
HTML estático
Navegador carga JS
fetch() a API
Render productos

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.

Spring Boot - Configuración CORSjava
1// src/main/java/com/tuapp/config/CorsConfig.java
2package 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@Configuration
11public class CorsConfig {
12
13 @Bean
14 public CorsFilter corsFilter() {
15 CorsConfiguration config = new CorsConfiguration();
16
17 // Dominios permitidos
18 config.addAllowedOrigin("https://tu-sitio.vercel.app");
19 config.addAllowedOrigin("http://localhost:4321"); // Desarrollo
20
21 // Métodos HTTP permitidos
22 config.addAllowedMethod("GET");
23 config.addAllowedMethod("POST");
24 config.addAllowedMethod("PUT");
25 config.addAllowedMethod("DELETE");
26
27 // Headers permitidos
28 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.