Why "Unexpected token '<'" means your API returned HTML

You called an endpoint, expected data, and the parser stopped on the very first character. The message is not about your payload — it is about the shape of the response, and the response was a web page.

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

Older engines report the same failure with different wording, and a search for any of these strings means the same thing:

SyntaxError: Unexpected token < in JSON at position 0

What the parser is telling you

A JSON document may begin with {, [, ", a digit, a minus sign, or one of the literals true, false and null. Leading whitespace is allowed. Nothing else is. A < is not in that set, so a body that starts with <!DOCTYPE html> fails at offset zero.

Newer V8 builds — Chrome, Edge and Node 20 and later — make the message far more useful by quoting the beginning of what they actually received. Those five characters, "<!DOCTYPE ", are the whole diagnosis: something gave you a hypertext document where you expected a data envelope.

The five things that actually happened

  1. The endpoint returned an error page. A 404, a 500, or a framework's HTML error renderer answered instead of the route you meant. A path segment is missing, the route only exists for a different HTTP method, or the resource genuinely is not there.
  2. A single-page app fallback answered instead of the API. Dev servers and static hosts commonly serve index.html for any unmatched path, so a request to a typo such as /api/uers returns the application shell — status 200, content type text/html. This is the most frequent cause in local development, and it looks exactly like a successful request.
  3. You were redirected to sign-in. An expired session or a missing token turns the API call into a redirect to a login page, and fetch follows redirects silently. The parser then receives the sign-in form.
  4. Something in front of the API blocked the request. A WAF, a corporate proxy, a CDN security rule or a captive portal serves an HTML interstitial, frequently with a status that does not read like a failure.
  5. The base URL points at the marketing site. A missing /api prefix, an environment variable still set to the root domain, or a trailing-slash difference lands the request on a page that is perfectly valid HTML — just not the one you wanted.

Confirm it in one request

Print the status, the content type and the first bytes before parsing. This is the step that turns the error from a puzzle into a fact:

const res = await fetch(url, {
    headers: { Accept: 'application/json' }
});

const body = await res.text();

console.log(res.status, res.headers.get('content-type'));
console.log(body.slice(0, 120));

if (!res.ok) {
    throw new Error(`HTTP ${res.status} from ${res.url}: ${body.slice(0, 200)}`);
}
if (!res.headers.get('content-type')?.includes('json')) {
    throw new Error(`Expected JSON, got ${res.headers.get('content-type')}`);
}

const data = JSON.parse(body);

Reading with text() and parsing it yourself is what makes this work. Calling res.json() directly throws the same unhelpful syntax error and discards the body, which is why the cause stays hidden.

From a shell, the headers and the first line are enough:

curl -sS -i -H 'Accept: application/json' https://api.example.com/v1/users | head -n 20

The first line gives the status and content-type gives the type. If the body begins with <!DOCTYPE, you have your answer. Run it again without -L: curl will then print the redirect and its location header instead of following it, which is how you catch the login bounce that fetch hid from you.

Why sending Accept: application/json does not prevent this

It is a request header, not a contract. Servers are free to ignore it, and error paths in particular tend to bypass content negotiation altogether. It is still worth sending — some gateways route on it — but the reliable check is the status and the content type above, not the header you hoped would be honoured.

It works in Postman but fails in the browser

Two explanations cover nearly every case. Either Postman sends an API key or bearer token while the browser sends session cookies, so only the browser gets redirected to sign-in; or the server chooses its response format based on X-Requested-With, which fetch does not set by default. Compare the raw response rather than the Pretty view — Postman renders HTML without complaint.

Do not fix this by stripping the HTML

It is tempting to add a repair step that slices everything before the first { and parses the remainder. That converts a loud failure into a quiet one. The request is still wrong, and once the error page changes shape you will be debugging a half-parsed object instead of a 404. Fix the path, the authentication or the routing, and keep the guard from the snippet above so the next occurrence fails where you can see it.

Wording across engines

The same defect is reported differently depending on what parsed it. If your message is not the one at the top of this page, it is probably in this table:

EngineMessage
Chrome, Edge, Node 20+Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Chrome, Node before v20Unexpected token < in JSON at position 0
FirefoxJSON.parse: unexpected character at line 1 column 1 of the JSON data
Safari, JavaScriptCoreJSON Parse error: Unexpected identifier "<!DOCTYPE"
Python json.loadsjson.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Where the formatter fits

Once the endpoint returns data, paste it into the JSON formatter to read the structure. If you are not yet sure whether a body is JSON at all, paste it in as it arrived: a < reported at position 0 is a definitive answer that the content type was lying, and the validator gives you the line and column rather than a bare offset.