Tema
Fetch y APIs REST
El clima de tu ciudad, el tipo de cambio del dólar, los productos de una tienda, los pagos con Stripe, los mapas de Google: todo eso llega a tu página a través de APIs. En esta lección aprenderás a consumir APIs REST con fetch, a enviar datos y, sobre todo, a manejar errores como lo hace un profesional.
Concepto
¿Qué es una API?
Una API (Application Programming Interface) es como el mesero de un restaurante: tú (el frontend) no entras a la cocina (el servidor y la base de datos); le haces un pedido al mesero en un formato acordado, y él te trae la respuesta.
¿Qué es REST?
REST es un estilo para diseñar APIs sobre HTTP. Sus ideas principales:
- Cada recurso tiene una URL:
/users,/users/5,/posts/3/comments. - La acción la indica el método (verbo) HTTP.
- Los datos viajan normalmente en JSON.
| Verbo | Acción | Ejemplo |
|---|---|---|
GET | Leer | GET /posts (lista) · GET /posts/1 (uno) |
POST | Crear | POST /posts con los datos en el cuerpo |
PUT | Reemplazar completo | PUT /posts/1 |
PATCH | Actualizar parcialmente | PATCH /posts/1 con { "title": "Nuevo" } |
DELETE | Eliminar | DELETE /posts/1 |
Códigos de estado
La respuesta trae un número que indica cómo salió todo:
| Rango | Significado | Ejemplos |
|---|---|---|
| 2xx | Éxito | 200 OK, 201 Created, 204 No Content |
| 3xx | Redirección | 301 Moved Permanently, 304 Not Modified |
| 4xx | Error del cliente (tú) | 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 422 Unprocessable, 429 Too Many Requests |
| 5xx | Error del servidor | 500 Internal Server Error, 503 Service Unavailable |
En esta lección usaremos JSONPlaceholder (https://jsonplaceholder.typicode.com), una API gratuita y falsa para practicar: acepta todas las operaciones, aunque no guarda los cambios de verdad.
Tu primer fetch (GET)
js
async function obtenerUsuarios() {
const respuesta = await fetch('https://jsonplaceholder.typicode.com/users');
const usuarios = await respuesta.json(); // convierte el cuerpo JSON a objeto
console.log(usuarios);
}
obtenerUsuarios();fetch hace la petición y devuelve una promesa con un objeto Response. El cuerpo se lee aparte (también es asíncrono) con:
respuesta.json()→ objeto o arrayrespuesta.text()→ stringrespuesta.blob()→ archivos binarios (imágenes, PDF)
Propiedades útiles del Response:
js
respuesta.ok; // true si el status está entre 200 y 299
respuesta.status; // 200, 404, 500...
respuesta.statusText; // 'OK', 'Not Found'...
respuesta.headers.get('Content-Type');Manejo de errores: la trampa de fetch
Error común (muy común)
fetch sólo rechaza la promesa si hay un error de red (sin internet, DNS que falla, CORS bloqueado). Un 404 o un 500 no caen en el catch: la promesa se cumple normalmente con respuesta.ok === false. Si no lo revisas, intentarás procesar una página de error como si fueran datos.
js
async function obtenerUsuario(id) {
try {
const respuesta = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
// ¡Siempre revisa ok!
if (!respuesta.ok) {
throw new Error(`Error HTTP ${respuesta.status}`);
}
const usuario = await respuesta.json();
return usuario;
} catch (error) {
// Aquí llegan: errores de red, el throw de arriba y JSON inválido
console.error('No se pudo obtener el usuario:', error.message);
throw error; // relanzar para que quien llama decida qué mostrar
}
}
obtenerUsuario(9999).catch(() => {
mostrarMensaje('Usuario no encontrado');
});Estados de la interfaz: cargando, éxito, error, vacío
Una buena interfaz siempre muestra en qué estado está la petición. Nunca dejes al usuario mirando una pantalla en blanco.
js
const contenedor = document.querySelector('#usuarios');
async function cargarUsuarios() {
contenedor.innerHTML = '<p class="cargando">Cargando usuarios...</p>';
try {
const respuesta = await fetch('https://jsonplaceholder.typicode.com/users');
if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
const usuarios = await respuesta.json();
if (usuarios.length === 0) {
contenedor.innerHTML = '<p>No hay usuarios todavía.</p>';
return;
}
contenedor.replaceChildren(...usuarios.map(crearTarjeta));
} catch (error) {
contenedor.innerHTML = `
<div class="error">
<p>No pudimos cargar los usuarios.</p>
<button id="reintentar">Reintentar</button>
</div>`;
document.querySelector('#reintentar').addEventListener('click', cargarUsuarios);
}
}
function crearTarjeta(usuario) {
const article = document.createElement('article');
const h3 = document.createElement('h3');
h3.textContent = usuario.name; // datos externos → textContent
const p = document.createElement('p');
p.textContent = `${usuario.email} · ${usuario.address.city}`;
article.append(h3, p);
return article;
}
cargarUsuarios();Enviar datos: POST, PUT, PATCH y DELETE
Para enviar datos pasas un segundo argumento con opciones:
js
// CREAR (POST)
async function crearPost(datos) {
const respuesta = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json' // le dices al servidor que envías JSON
},
body: JSON.stringify(datos) // el cuerpo debe ser texto
});
if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
return respuesta.json(); // JSONPlaceholder responde 201 con el objeto y un id
}
const nuevo = await crearPost({ title: 'Hola', body: 'Mi primer post', userId: 1 });
console.log(nuevo); // { id: 101, title: 'Hola', ... }
// ACTUALIZAR PARCIALMENTE (PATCH)
await fetch('https://jsonplaceholder.typicode.com/posts/1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Título corregido' })
});
// REEMPLAZAR (PUT)
await fetch('https://jsonplaceholder.typicode.com/posts/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: 1, title: 'Nuevo', body: 'Todo nuevo', userId: 1 })
});
// ELIMINAR (DELETE)
const r = await fetch('https://jsonplaceholder.typicode.com/posts/1', { method: 'DELETE' });
console.log(r.ok); // trueEnviar un formulario o archivos
js
const form = document.querySelector('#subir-foto');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const datos = new FormData(form); // incluye archivos de <input type="file">
await fetch('/api/fotos', {
method: 'POST',
body: datos // NO pongas Content-Type: el navegador lo hace
});
});Headers, autenticación y query params
js
// Token de autenticación (APIs privadas)
await fetch('https://api.ejemplo.com/pedidos', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json'
}
});
// Parámetros de búsqueda: construye la URL con URLSearchParams (escapa por ti)
const params = new URLSearchParams({ q: 'café & pan', page: 2, limit: 10 });
const url = `https://api.ejemplo.com/productos?${params}`;
// ...?q=caf%C3%A9+%26+pan&page=2&limit=10
// O con el objeto URL
const u = new URL('https://jsonplaceholder.typicode.com/posts');
u.searchParams.set('userId', 1);
await fetch(u);Seguridad
Nunca pongas claves secretas de API (Stripe secret key, OpenAI, etc.) en el código del navegador: cualquiera puede verlas en DevTools. Las peticiones que requieren claves secretas deben pasar por tu propio backend.
Cancelar peticiones y timeouts: AbortController
js
// Timeout: cancelar si tarda más de 5 segundos
async function fetchConTimeout(url, ms = 5000) {
const respuesta = await fetch(url, { signal: AbortSignal.timeout(ms) });
if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
return respuesta.json();
}
// Cancelar manualmente (por ejemplo, la búsqueda anterior al escribir otra)
let controlador;
async function buscar(texto) {
controlador?.abort(); // cancela la petición anterior
controlador = new AbortController();
try {
const r = await fetch(`/api/buscar?q=${encodeURIComponent(texto)}`, {
signal: controlador.signal
});
return await r.json();
} catch (error) {
if (error.name === 'AbortError') return; // cancelada a propósito: ignorar
throw error;
}
}Esto evita un bug clásico: el usuario escribe "pan" y luego "pantalón"; si la respuesta de "pan" llega después, sobrescribiría los resultados correctos. Lo aplicarás en el proyecto del buscador.
CORS explicado
Si ves en la consola "blocked by CORS policy", no es un error de tu código: es una protección del navegador. Por seguridad, una página en misitio.com sólo puede leer respuestas de otro dominio (api.otro.com) si ese servidor lo autoriza con el header Access-Control-Allow-Origin.
- Las APIs públicas (JSONPlaceholder, PokeAPI) ya lo permiten.
- Si la API es tuya, configura CORS en tu backend.
- Si es de un tercero que no lo permite, haz la petición desde tu servidor (un "proxy").
- Abrir tu HTML con doble clic (
file://) también causa problemas: usa un servidor local (Live Server o Vite).
Un helper reutilizable para toda tu app
En proyectos reales no repites fetch + ok + json en cada lugar. Crea un pequeño cliente:
js
// api.js
const BASE_URL = 'https://jsonplaceholder.typicode.com';
export class ApiError extends Error {
constructor(mensaje, status, datos) {
super(mensaje);
this.name = 'ApiError';
this.status = status;
this.datos = datos;
}
}
export async function api(ruta, { method = 'GET', body, headers = {}, timeout = 8000 } = {}) {
const opciones = {
method,
headers: { Accept: 'application/json', ...headers },
signal: AbortSignal.timeout(timeout)
};
if (body !== undefined) {
opciones.headers['Content-Type'] = 'application/json';
opciones.body = JSON.stringify(body);
}
let respuesta;
try {
respuesta = await fetch(`${BASE_URL}${ruta}`, opciones);
} catch (error) {
const mensaje = error.name === 'TimeoutError'
? 'El servidor tardó demasiado en responder.'
: 'No hay conexión. Revisa tu internet.';
throw new ApiError(mensaje, 0);
}
// 204 No Content: no hay cuerpo que leer
const datos = respuesta.status === 204 ? null : await respuesta.json().catch(() => null);
if (!respuesta.ok) {
const mensajes = {
400: 'Los datos enviados no son válidos.',
401: 'Tu sesión expiró. Inicia sesión de nuevo.',
403: 'No tienes permiso para esta acción.',
404: 'No encontramos lo que buscas.',
429: 'Demasiadas solicitudes. Espera un momento.'
};
throw new ApiError(
mensajes[respuesta.status] ?? 'Ocurrió un error en el servidor.',
respuesta.status,
datos
);
}
return datos;
}
// Atajos
export const get = (ruta) => api(ruta);
export const post = (ruta, body) => api(ruta, { method: 'POST', body });
export const patch = (ruta, body) => api(ruta, { method: 'PATCH', body });
export const del = (ruta) => api(ruta, { method: 'DELETE' });js
// Uso en cualquier parte de la app
import { get, post, ApiError } from './api.js';
try {
const posts = await get('/posts?userId=1');
const creado = await post('/posts', { title: 'Hola', body: '...', userId: 1 });
} catch (error) {
if (error instanceof ApiError) mostrarToast(error.message);
else throw error;
}Buena práctica
Centralizar las peticiones te permite cambiar la URL base, añadir el token de autenticación o registrar errores en un solo lugar. Es lo que hacen librerías como axios o ky, pero con fetch nativo no necesitas dependencias.
Ejercicios
- Obtén los posts del usuario 1 (
/posts?userId=1) y muestra sus títulos en una lista<ul>. - Crea una función
obtenerTodo(id)para/todos/:idque lance un error con mensaje claro si la respuesta no esok. Pruébala conid = 500. - Carga en paralelo el usuario 1 y sus álbumes (
/users/1y/albums?userId=1) y muestra "Leanne Graham tiene 10 álbumes". - Crea un formulario con título y contenido que haga POST a
/postsy muestre el id devuelto. Deshabilita el botón mientras se envía. - Haz que una petición se cancele si tarda más de 3 segundos y muestre "Tiempo agotado".
Ver solución
js
const BASE = 'https://jsonplaceholder.typicode.com';
// 1
async function listarTitulos() {
const r = await fetch(`${BASE}/posts?userId=1`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const posts = await r.json();
const ul = document.querySelector('#posts');
ul.replaceChildren(...posts.map((p) => {
const li = document.createElement('li');
li.textContent = p.title;
return li;
}));
}
// 2
async function obtenerTodo(id) {
const r = await fetch(`${BASE}/todos/${id}`);
if (!r.ok) throw new Error(`No existe la tarea ${id} (HTTP ${r.status})`);
return r.json();
}
obtenerTodo(500).catch((e) => console.error(e.message));
// 3
async function resumenUsuario() {
const [usuario, albumes] = await Promise.all([
fetch(`${BASE}/users/1`).then((r) => r.json()),
fetch(`${BASE}/albums?userId=1`).then((r) => r.json())
]);
return `${usuario.name} tiene ${albumes.length} álbumes`;
}
// 4
const form = document.querySelector('#nuevo-post');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const boton = form.querySelector('button');
boton.disabled = true;
try {
const r = await fetch(`${BASE}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...Object.fromEntries(new FormData(form)), userId: 1 })
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const { id } = await r.json();
document.querySelector('#resultado').textContent = `Creado con id ${id}`;
} catch {
document.querySelector('#resultado').textContent = 'No se pudo crear.';
} finally {
boton.disabled = false;
}
});
// 5
try {
await fetch(`${BASE}/photos`, { signal: AbortSignal.timeout(3000) });
} catch (e) {
if (e.name === 'TimeoutError') console.log('Tiempo agotado');
}Resumen
- Una API REST expone recursos en URLs y usa verbos HTTP: GET, POST, PUT, PATCH, DELETE.
fetchdevuelve una promesa con unResponse; lee el cuerpo con.json().fetchno falla con 404 o 500: siempre revisarespuesta.ok.- Muestra estados de carga, error (con reintento) y vacío.
- Para enviar JSON:
method, headerContent-Type: application/jsonybody: JSON.stringify(datos). - Usa
AbortController/AbortSignal.timeoutpara cancelar y poner límites de tiempo. - Centraliza las peticiones en un helper con manejo de errores.
Siguiente: Módulos ES