Skip to content

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.
VerboAcciónEjemplo
GETLeerGET /posts (lista) · GET /posts/1 (uno)
POSTCrearPOST /posts con los datos en el cuerpo
PUTReemplazar completoPUT /posts/1
PATCHActualizar parcialmentePATCH /posts/1 con { "title": "Nuevo" }
DELETEEliminarDELETE /posts/1

Códigos de estado ​

La respuesta trae un número que indica cómo salió todo:

RangoSignificadoEjemplos
2xxÉxito200 OK, 201 Created, 204 No Content
3xxRedirección301 Moved Permanently, 304 Not Modified
4xxError del cliente (tú)400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 422 Unprocessable, 429 Too Many Requests
5xxError del servidor500 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 array
  • respuesta.text() → string
  • respuesta.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();
Consumir una API con fetch

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); // true

Enviar 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 ​

  1. Obtén los posts del usuario 1 (/posts?userId=1) y muestra sus títulos en una lista <ul>.
  2. Crea una función obtenerTodo(id) para /todos/:id que lance un error con mensaje claro si la respuesta no es ok. Pruébala con id = 500.
  3. Carga en paralelo el usuario 1 y sus álbumes (/users/1 y /albums?userId=1) y muestra "Leanne Graham tiene 10 álbumes".
  4. Crea un formulario con título y contenido que haga POST a /posts y muestre el id devuelto. Deshabilita el botón mientras se envía.
  5. 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.
  • fetch devuelve una promesa con un Response; lee el cuerpo con .json().
  • fetch no falla con 404 o 500: siempre revisa respuesta.ok.
  • Muestra estados de carga, error (con reintento) y vacío.
  • Para enviar JSON: method, header Content-Type: application/json y body: JSON.stringify(datos).
  • Usa AbortController / AbortSignal.timeout para cancelar y poner límites de tiempo.
  • Centraliza las peticiones en un helper con manejo de errores.

Siguiente: Módulos ES

Academia Web — aprende, copia y construye.