← Todos los artículos

Unexpected Token < en JSON en la posición 0: tienes HTML

El error «Unexpected token <» significa que JSON.parse recibió una página HTML (un 404, una redirección de login o una URL equivocada), no JSON. Aquí el porqué, con ejemplos de fetch rotos y arreglados.

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON —— casi siempre significa una sola cosa: tu código llamó a JSON.parse() (o res.json()) sobre una página HTML, no JSON. El < en posición 0 es la apertura de <!DOCTYPE html> o <html>. Aquí están exactamente las causas y cómo arreglarlo.

Cómo se ve el error

// V8 (Chrome / Node / Edge)
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
// V8 más antiguo
SyntaxError: Unexpected token < in JSON at position 0

// Firefox
SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data

// Safari
SyntaxError: JSON Parse error: Unexpected identifier "<"

Posición 0 es la pista clave: el primer byte ya está mal, así que la respuesta nunca fue JSON para empezar.

Por qué pasa: recibiste HTML

Una llamada fetch o AJAX devolvió un documento HTML en vez del JSON esperado. Las fuentes habituales:

  • Una página de error 404 / 500 servida como HTML por tu framework o servidor web
  • Una redirección de login o auth a una página HTML (sesión caducada)
  • Una URL equivocada —— pegándole a la ruta del app/SPA en vez del endpoint de la API
  • Un proxy, CDN o captive portal devolviendo su propia página HTML

Ejemplo roto

const res  = await fetch('/api/user');   // el servidor devolvió una página HTML 404
const data = await res.json();           // ❌ Unexpected token '<' …
// res.json() intenta parsear "<!DOCTYPE html>…" como JSON

Ejemplo arreglado

const res = await fetch('/api/user');

// 1) Comprueba el estado antes de parsear
if (!res.ok) {
  throw new Error(`HTTP ${res.status} ${res.statusText}`);
}

// 2) Opcional: confirma que realmente es JSON
const type = res.headers.get('content-type') ?? '';
if (!type.includes('application/json')) {
  const text = await res.text();
  throw new Error(`Expected JSON, got: ${text.slice(0, 80)}…`);
}

const data = await res.json();           // ✅ seguro

Dos fuentes menos obvias que vale conocer

  • Respuestas bloqueadas por WAF o rate-limit. Un WAF, CDN o capa de protección DDoS puede interceptar la llamada y devolver su propia página HTML de challenge / bloqueo —— el interstitial de Cloudflare, la página de denegación de AWS WAF, la pared de un captive-portal de Wi-Fi público. El estado suele ser 403 / 429 / 503 con cuerpo HTML. Inspecciona la respuesta en la pestaña Network y mira si hay headers como CF-Ray o X-Amz-Cf-Id para detectarlo.
  • El fallback de historia de la SPA devolviendo index.html. Los hosts estáticos y servidores de desarrollo suelen reescribir cualquier ruta desconocida a /index.html para que el router del cliente tome el control. Si tu front-end hace fetch de /api/user como ruta relativa sin querer y el proxy no está configurado, el servidor devuelve silenciosamente el shell HTML de tu SPA con un 200 —— y obtienes una versión confusa «200 OK pero es HTML» del mismo error.

Cómo arreglarlo —— paso a paso

  1. Loguea el body crudo:console.log(await res.clone().text()). Si empieza con <!DOCTYPE o <html>, tienes HTML.
  2. Comprueba res.ok / res.status antes de llamar a res.json() —— un 4xx/5xx normalmente devuelve una página HTML de error.
  3. Verifica la URL en la pestaña Network. Una ruta relativa puede resolverse al index.html de tu SPA en vez de a la API.
  4. Revisa la autenticación —— una redirección a la página de login significa que tu token/sesión caducó.
  5. Inspecciona Content-Type —— ramifica sobre él para manejar respuestas no-JSON en vez de parsearlas a ciegas.

Preguntas frecuentes

¿Qué significa «Unexpected token < in JSON at position 0»?

El parser encontró < como primer carácter, lo que nunca es JSON válido. La respuesta era HTML (una página <!DOCTYPE> o <html>), no el JSON que esperaba tu código.

¿Por qué mi fetch devuelve HTML en vez de JSON?

Causas comunes: página de error 404/500, redirección de auth a una página de login, o URL equivocada que resuelve a tu front-end en vez de a la API. Comprueba res.ok y loguea await res.text() para ver qué volvió.

¿Cómo evito que rompa mi app?

Guard antes de parsear: comprueba res.ok y el header Content-Type, y envuelve JSON.parse()/res.json() en try/catch para que una respuesta HTML se convierta en un error manejado en vez de una excepción.

Arréglalo ahora

Si tienes un body de respuesta del que no estás seguro, pégalo en JSON Fix —— te dice al momento si es JSON válido o algo más (como HTML). Todo corre en tu navegador.