DEV Community

Vishnu Shankar
Vishnu Shankar

Posted on AI-assisted

Unexpected token '<' means your server sent you a web page

Paste this into your browser console:

new Response('<!DOCTYPE html><html><body>Not Found</body></html>', { status: 404 })
  .json()
  .catch((e) => console.log(`${e.name}: ${e.message}`));
Enter fullscreen mode Exit fullscreen mode

Chrome 145:

SyntaxError: Failed to execute 'json' on 'Response': Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Enter fullscreen mode Exit fullscreen mode

Firefox 146:

SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data
Enter fullscreen mode Exit fullscreen mode

Safari (WebKit 26):

SyntaxError: The string did not match the expected pattern.
Enter fullscreen mode Exit fullscreen mode

Three browsers, three messages, and one cause. Your code asked for JSON and got a web page back. Safari's version does not even mention JSON, which is why searching for it gets you nowhere.

Why this happens

< is the first character of <!DOCTYPE html>. No JSON document can start with it, so the parser gives up on the very first character. Nothing is wrong with your parsing code. The problem is the response.

What catches people out is that fetch lets the page through. It does not reject on a 404 or a 500, and it follows redirects without asking. So the first line that fails is res.json(), and the error it throws says nothing about the status code.

The status was there the whole time:

const res = new Response('<!DOCTYPE html><html><body>Not Found</body></html>', { status: 404 });
console.log(res.ok, res.status); // false 404
Enter fullscreen mode Exit fullscreen mode

res.ok was false. Nothing looked at it.

Where the HTML comes from

Four places, in roughly the order I would check them.

A wrong URL. A typo, a missing /api prefix, or a trailing slash the server treats differently. Most frameworks answer an unknown path with an HTML 404 page.

Your own app answering for the API. Dev servers like Vite, and static hosts with a catch-all rewrite, send index.html for any path they do not recognise, so client-side routing works. Ask for /api/users when no API is running there and you get your app's HTML with a 200. The status looks fine. The body is not.

A login redirect. An expired session often gets a redirect to the login page instead of a 401. fetch follows it and hands you the login form.

A server error page. A 500 from your framework, a 502 from a proxy or a 503 from a load balancer usually comes with an HTML body.

Look before you guess

Open DevTools, go to the Network tab and click the failing request. The Response tab shows the page that came back. A 404 page, your login screen or your app's own index.html each tell you straight away which of the four you have.

The fix

Check the response before you parse it, and keep the body so you can log it.

async function getJson(url, options) {
  const res = await fetch(url, options);
  if (res.status === 204) return null;

  const text = await res.text();
  const type = res.headers.get('content-type') ?? '';

  if (!res.ok || !type.includes('json')) {
    throw new Error(
      `${res.status} from ${url} (${type || 'no content-type'}): ${text.slice(0, 200)}`
    );
  }
  return JSON.parse(text);
}
Enter fullscreen mode Exit fullscreen mode

Three things in there matter.

res.text() first, not res.json(). A body can only be read once. If res.json() throws, the text is gone and you cannot log what the server actually said. Read it as text, then parse it yourself.

includes('json'), not === 'application/json'. The header usually carries a charset, like application/json; charset=utf-8. A strict comparison would turn away every good response that has one.

The 204. A 204 No Content has no body, so there is nothing to parse. Without that line, every successful DELETE that returns 204 would throw.

Now the same bad request fails with something you can act on:

Error: 404 from /api/users (text/html; charset=utf-8): <!DOCTYPE html><html><body>Not Found</body></html>
Enter fullscreen mode Exit fullscreen mode

The login redirect is caught too. It arrives as a 200, but with text/html, so the content type stops it.

Other languages, same bug

This is not a JavaScript problem. Python's json module says Expecting value: line 1 column 1 (char 0). Go says invalid character '<' looking for beginning of value. Different words, same <, same fix: check the status and the content type before you parse.

In short

Unexpected token '<' is not a JSON bug. It is a web page arriving where you expected data. fetch will not stop it, so check res.ok and the Content-Type yourself, read the body as text so you can log it, and look at the Network tab before you touch your parsing code.

I keep a reference of JSON parse errors on Express Lint, and this one is there with every browser's wording. If you have a document that will not parse, the validator on the same site gives you the line and column. Free, no account, and what you paste stays in the browser.

Top comments (0)