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

Your code asked for JSON and got an HTML page instead. <!DOCTYPE html> is how an HTML page starts, and no JSON document can start with <. Nothing is wrong with your parsing code. The problem is the response, so the fix is to find out why the server sent a page.

How the message appears

Chrome and Node first, then the older V8 wording, then Firefox, then Safari. The last one is what Safari says from response.json(), and it does not mention JSON at all. Every one of these is the same failure: the first character was <.

  • SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
  • SyntaxError: Failed to execute 'json' on 'Response': Unexpected token '<', "<!DOCTYPE "... is not valid JSON
  • SyntaxError: Unexpected token < in JSON at position 0
  • SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data
  • SyntaxError: JSON Parse error: Unrecognized token '<'
  • SyntaxError: The string did not match the expected pattern.

The fix

Check the response before you parse it. response.ok is false for every error status, and the Content-Type header should include application/json. When either check fails, read the body with response.text() and log it, so you can see the page that came back. Then fix whatever it points to: the URL, the proxy or the login.

Fails to parse
<!DOCTYPE html>
<html>
  <head><title>404 Not Found</title></head>
  <body>Not Found</body>
</html>
Parses
{
  "id": 42,
  "name": "Ada"
}

The example travels in the URL fragment, which browsers never send to a server.

What causes it

The URL is wrong, so you got the 404 page

A typo in the path, a missing /api prefix, or a trailing slash the server treats differently. Most frameworks answer an unknown URL with an HTML error page, not a JSON one. Open the exact URL from the Network tab in a new browser tab and you will see the page your code tried to parse.

A single-page app answered with index.html

Dev servers like Vite, and static hosts with a catch-all rewrite, answer every unknown path with index.html so client-side routing works. Ask for /api/users when no API is running there and you get the app shell with a 200. The status looks fine and the body is HTML.

The request needed a login

An expired session or a missing token often gets a redirect to the login page rather than a 401. fetch follows redirects by default, so your code receives the login form.

The server failed and sent its 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. fetch does not reject on an error status, so response.json() runs on that page and throws this instead of telling you about the 500.

Worth knowing

  • A body can only be read once. If you want the text for your logs when parsing fails, call response.text() first and then JSON.parse(text) inside a try, rather than calling response.json().
  • Safari words this differently. JSON.parse says Unrecognized token '<', and response.json() says The string did not match the expected pattern., which gives no hint that JSON is involved. It is the same problem.
  • The same failure in other languages: Python says Expecting value: line 1 column 1 (char 0) and Go says invalid character '<' looking for beginning of value. Both are in this reference.

Written by Vishnu Shankar.

Your JSON and text are processed entirely in your browser and are never sent anywhere. To understand how the site is used we record each page view (the page, the referrer and an approximate location from your IP address) and keep it for 90 days. Nothing is stored on your device unless you choose it. Accept to also store a device identifier here and record your browser and device characteristics, which is what lets a return visit be told from a new one. Decline and nothing is sent at all. We respect Do Not Track. See our privacy policy.