Unexpected token < in JSON at position 0 Means You Got HTML

The angle bracket is the first character of an HTML error page. Your API returned a page, not JSON, and here is how to find out which page.

Share
Unexpected token < in JSON at position 0 Means You Got HTML. Abstract javascript illustration in orange and dark grey on debugly.dev

The short answer

SyntaxError: Unexpected token < in JSON at position 0

Or in newer V8:

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

< is the first character of <!DOCTYPE html>. Your request returned an HTML page instead of JSON. Almost always a 404 page, a 500 error page, a login redirect, or a proxy error page.

Stop parsing and look at the response:

const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText} from ${url}`);
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) {
  throw new Error(`Expected JSON, got ${ct}: ${(await res.text()).slice(0, 200)}`);
}
return res.json();

The newer error message is genuinely better because it shows you the content. If you are on an older runtime, the snippet above gets you the same information.

Tested on Node 22.14 and Chrome 133.

Why fetch does not help you

The core problem is a fetch design decision that catches everyone once.

fetch does not reject on HTTP errors. A 404, a 500, a 502 all resolve successfully. The promise only rejects on network failure, meaning DNS failure, connection refused, or a CORS block.

So this code:

const data = await fetch("/api/users").then(r => r.json());

happily takes a 404 HTML page and tries to parse it. The error you see is a JSON parse error, three layers away from the actual problem, which is that the URL is wrong.

Axios rejects on non-2xx by default, which is why people migrating from axios to fetch hit this immediately.

The five causes

1. The URL is wrong

The most common. A typo, a missing prefix, or a relative path resolving somewhere unexpected.

fetch("api/users")     // relative to the current path
fetch("/api/users")    // relative to the origin

From a page at /dashboard/settings, the first resolves to /dashboard/api/users, gets your SPA's catch-all HTML route, and returns 200 with a document.

That last detail is what makes this hard. A single page application's server usually returns index.html with a 200 status for any unmatched path, so you cannot even rely on the status code. You get a successful response containing a web page.

2. Your dev proxy is not routing

Working locally through a Vite or Next proxy, the path does not match a proxy rule, so the dev server serves your app's HTML instead of forwarding to the API.

// vite.config.js
export default {
  server: {
    proxy: { "/api": { target: "http://localhost:8080", changeOrigin: true } },
  },
};

A request to /v1/users does not match /api, so it never reaches the backend. Works in production where both are on the same origin, fails in development, which is the reverse of the usual pattern and therefore confusing.

3. Authentication redirect

Your session expired. The API redirects to a login page. fetch follows redirects by default, so you get the login page's HTML with a 200 status.

const res = await fetch("/api/orders", { redirect: "manual" });
if (res.type === "opaqueredirect" || res.status === 302) {
  window.location.href = "/login";
  return;
}

For an API, the correct server behaviour is a 401 with a JSON body, not a redirect. If you control the backend, fix it there. A redirect to HTML is appropriate for a browser navigating and wrong for an XHR expecting JSON.

4. A proxy or CDN returned its own error page

Cloudflare, nginx, or a load balancer intercepting the request and serving a branded error page.

<!DOCTYPE html><html><head><title>502 Bad Gateway</title></head>

Your backend is down or timed out, and the layer in front returned its own page. The tell is that the HTML does not look like your application's.

Related: a request body exceeding a proxy limit produces a 413 page, and a slow endpoint produces a 504 page. Both parse as HTML.

5. The server threw and your framework rendered a stack trace page

Express, Flask, and Rails all render an HTML debug page on unhandled exceptions in development. The API endpoint genuinely errored and the error handler produced a document.

This one is actually useful once you look at it, because the HTML contains the real stack trace. The JSON parse error is hiding a perfectly good error message.

Making the failure legible

The general fix is to never call .json() without checking what you have. A small wrapper is worth it:

export async function apiFetch(url, options = {}) {
  const res = await fetch(url, {
    ...options,
    headers: { Accept: "application/json", ...options.headers },
  });

  const contentType = res.headers.get("content-type") ?? "";
  const isJson = contentType.includes("application/json");
  const body = isJson ? await res.json().catch(() => null) : await res.text();

  if (!res.ok) {
    const detail = isJson ? JSON.stringify(body) : String(body).slice(0, 300);
    throw new ApiError(`${res.status} ${res.statusText} ${url}: ${detail}`, res.status, body);
  }

  if (!isJson) {
    throw new ApiError(
      `Expected JSON from ${url}, got ${contentType || "no content-type"}: ` +
      `${String(body).slice(0, 200)}`,
      res.status, body
    );
  }

  return body;
}

Two details worth noting. Sending Accept: application/json gives well behaved servers the chance to return a JSON error instead of HTML. And including the URL and the first 200 characters of the body in the message means the next person reading the log knows immediately whether they got a 404 page or a Cloudflare page.

This is the error messages as user interface argument applied to a specific case. The program has the URL, the status, and the body. Including them costs one line and removes an entire debugging session.

Diagnosing it right now

Open the Network tab, find the request, and click Response. You will see the HTML and know immediately which of the five causes you have.

If it is server side and you have no browser:

curl -i -H "Accept: application/json" https://api.example.com/v1/users | head -30

-i includes headers, so you see the status and content type before the body. That is usually enough.

For a request that only fails in production, log the first part of the response body when parsing fails. A truncated body in a log is far more useful than a parse error, and it costs nothing:

try {
  return JSON.parse(text);
} catch {
  logger.error("json parse failed", { url, status, snippet: text.slice(0, 300) });
  throw new Error(`Invalid JSON from ${url} (${status})`);
}

The variants

Same underlying problem, different first character:

Error First character Usually
Unexpected token < < HTML page
Unexpected end of JSON input nothing Empty body, often a 204
Unexpected token o in JSON at position 1 o You called JSON.parse on an object
Unexpected non-whitespace character after JSON varies Two JSON documents concatenated, or a BOM

The o one is worth flagging. JSON.parse(someObject) stringifies the object to [object Object] and then fails at position 1 on the o. It means you are parsing something that is already parsed, which happens constantly with libraries that auto-parse.

The empty body case is common with 204 No Content and with a DELETE that returns nothing. Guard for it:

if (res.status === 204 || res.headers.get("content-length") === "0") return null;

Prevention

Use a typed client generated from your OpenAPI schema. The generated client handles status codes and content types, and you stop writing this logic by hand.

Make your API return JSON errors. Every non-2xx response from an API should have Content-Type: application/json and a body with a machine readable code and a human readable message. An API that returns HTML on error is broken regardless of what the client does.

Return 401 with JSON rather than redirecting for API routes. Redirects are for browsers navigating, not for fetch calls.

Check Accept on the server and branch. A request with Accept: application/json should never get HTML back, including from your error handler.