← All posts
Web developmentPublished Β· 9 min readAlso on dev.to β†—

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

The message says JSON, so it looks like a JSON problem. It is not. No JSON was involved: the server sent an HTML page and the parser stopped on the very first character, `<`. This guide covers how to read the ten characters the message quotes, how to tell apart the five different causes that all print this identical line, and the wrapper that catches the problem before the parser does. Every message and number here was measured on one machine.

Free tool featured in this guide
JSON formatter and validator

Paste the response body and it tells you whether it is JSON at all, and if it is, which line is wrong. Everything runs in your browser, so nothing is uploaded.

Check whether your response is JSON β†’

This is not a JSON syntax error

The word JSON is right there in the message, so the natural reaction is to go looking for a stray comma. There is nothing to find. No JSON was ever parsed: the server returned an HTML page meant for a human, and the parser quit the moment it saw <. A JSON value can only begin with {, [, a double quote, a digit, a minus sign, t, f or n, and < is not on that list.

Here is the check, run on this site's own dev server (Next.js 16). A route that does not exist, requested with an explicit ask for JSON:

bash
curl -s -i -H "Accept: application/json" http://localhost:3000/api/nope
text
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
...

<!DOCTYPE html><html lang="ko">    <- 26,283 bytes of body

The Accept: application/json header made no difference: the response was text/html and the body was a 26,283-byte 404 page. The same request against the deployed production site came back as 20,672 bytes of HTML. Whatever you put in the request headers, a path that does not exist gets you a page built for a person to read.

πŸ’‘

res.json() hands that HTML straight to the JSON parser. The exception surfaces at the parsing line, but the thing to fix is the request. Rewriting the parsing code changes nothing.

The ten quoted characters name the culprit

The most useful part of this message is what sits inside the quotes. V8 (Chrome, Edge, Node.js) echoes the beginning of the string it failed on. So "<!DOCTYPE " is not the parser's paraphrase β€” it is the first ten characters the server actually sent. You can tell what arrived without opening the response at all.

What sits inside the quotes is a verbatim copy of the response body's first ten characters. The `...` marks everything that was left out.

The truncation rule is fixed. Growing the input one character at a time on node v24.13.0 showed the boundary exactly: a body of 20 characters or fewer is quoted in full, and from 21 characters only the first ten survive, followed by ....

javascript
JSON.parse('<!DOCTYPE html>')
// Unexpected token '<', "<!DOCTYPE html>" is not valid JSON   <- 15 chars: quoted whole

JSON.parse('<!DOCTYPE html>\n<html lang="ko">')
// Unexpected token '<', "<!DOCTYPE "... is not valid JSON      <- 32 chars: first ten only

So a trailing ... means the response was longer than 20 characters, and a message that ends without one means the quotes contain the entire response. Short responses are fully diagnosable from the error line alone.

Stated precisely, the quoted window runs to ten characters past the point where parsing failed, and is at most 20 characters wide. An HTML body fails on its very first character, so that window is simply the first ten. Push the failure further in and the quote grows with it: an array of objects stringified into [object Object],[object Object] parses [ as the start of an array and only stops at index 1, which yields "[object Obj"... β€” one character longer.

Quoted ten charactersWhat the body started withWhere that usually comes from
<!DOCTYPE An ordinary HTML documentA 404 or 500 page, a login page, an SPA's index.html
<script srHTML starting with <script src=A script a dev server injected ahead of the page, or a CDN or security appliance's interstitial
<?xml versAn XML documentAn <Error><Code> response from cloud object storage
<br /><b>A warning printed ahead of the bodyA PHP server with error output turned on
Internal SOne line of plain textA 500 or 502 produced by a proxy or gateway
undefinedThe string "undefined" was parsedNot the server β€” your own code passed a missing value
[object Object]An object stringified on the way in (15 chars, quoted whole)JSON.parse called a second time on something already parsed
ℹ️

The last two rows print without any Unexpected token prefix β€” just "undefined" is not valid JSON β€” because there is no token to name. Seeing that shape means the problem is in your own code, not on the wire. The first two columns are the messages each of those bodies actually produced when fed to JSON.parse (node v24.13.0 / V8 13.6). The third column is where such bodies typically originate, and that varies by stack.

Five causes, one message β€” the headers tell them apart

This is what makes the error tedious. There are roughly five ways to end up here and all of them print the identical line. The message alone cannot narrow it down, so you need three other things: the status code, the Content-Type, and the final URL.

The five routes by which one request comes back as HTML, and the signal that identifies each.

1. A relative path missing its leading slash

fetch("api/data") and fetch("/api/data") are different addresses. Without the leading slash the path is appended to the folder of the page you are currently on. Checked in Chrome 152 from the page at /guides/json-parse-errors:

javascript
// run from /guides/json-parse-errors
new URL('api/data', location.href).href
// -> 'http://localhost:3000/guides/api/data'

The request goes to /guides/api/data, nothing lives there, and an HTML 404 comes back. The characteristic symptom is that it works while you are testing from the root page and only breaks once you navigate into a sub-path.

2. The route genuinely is not there

Same message, boring cause: a typo, wrong casing, a trailing slash that matters, or a file that did not make it into the deploy. The quickest test is to paste the URL straight into the browser address bar. JSON on screen means the server is fine and the bug is in your code; a 404 page means the path is wrong.

3. The session expired and you got the login page

This is the one that costs the most time. fetch follows redirects without telling you. When auth lapses, the API redirects to a login page, and your code receives that page's HTML and parses it as if it were the API's answer. Two properties give it away:

javascript
const res = await fetch('http://coding-now.com/api/nope');
res.redirected   // true
res.url          // 'https://www.coding-now.com/api/nope'  <- not what you asked for

That example reproduces it with a plain http-to-https redirect, but the mechanism is the same one a login redirect uses. If res.redirected is true, or res.url differs from the URL you passed in, the response did not come from where you think it did. Log res.url and the login page shows itself immediately.

4. A dev server or proxy handed you index.html

SPA dev servers are configured to answer unknown paths with index.html. If the API proxy rule is missing or points at the wrong target, /api/... requests fall into that catch-all too. What makes this case nasty is that the status code is 200, so an res.ok guard passes happily. Here Content-Type is the only thing that gives it away.

5. The server rendered an error page

When server code throws, the framework renders an HTML error page for a human. Unlike the four above, this one is read the server logs territory. If the status is 500, 502 or 504, no amount of client-side work will help.

fetch does not throw on a 404

This is why the failure lands on the parsing line even though you wrapped everything in try { await res.json() } catch. As far as fetch is concerned, reaching the server is success β€” whatever the status code says. A 404 is a perfectly good response, and so is a 500. Here is the same code pointed at five different places:

Requeststatusres.okredirectedContent-Typeres.json()
Missing API route, dev server404falsefalsetext/htmlSyntaxError
Missing API route, production404falsefalsetext/htmlSyntaxError
http to https redirect404falsetruetext/htmlSyntaxError
A healthy JSON API200truefalseapplication/jsonparsed fine
A port with nothing on itβ€”β€”β€”β€”TypeError

In the first four rows fetch itself never threw once. Every exception came from res.json(). So even though the message points at JSON, the real signal was the status and Content-Type you already had in hand one line earlier.

⚠️

The last row is a different failure entirely. When the server cannot be reached there is no res at all and fetch throws β€” TypeError: fetch failed on Node, TypeError: Failed to fetch in Chrome (both measured). That message points at the address, the port, CORS, or a server that is not running.

The wrapper that catches it before the parser does

There is one sensible fix: check that the response is JSON before parsing it, and when it is not, put the beginning of the body into the error you throw. Next time it happens, the log line alone tells you which of the five causes you are looking at.

javascript
async function getJson(url) {
  const res = await fetch(url, { headers: { Accept: 'application/json' } });
  const type = res.headers.get('content-type') || '';
  const body = await res.text();          // read it as text first

  if (!type.includes('application/json')) {
    throw new Error(
      `Not JSON: ${res.status} ${type}\n` +
      `final URL: ${res.url}${res.redirected ? ' (redirected)' : ''}\n` +
      `first 200 chars: ${body.slice(0, 200)}`
    );
  }
  if (!res.ok) throw new Error(`${res.status}: ${body.slice(0, 200)}`);
  return JSON.parse(body);
}

Reading res.text() instead of res.json() is the part that matters. A response body can only be consumed once, so calling res.text() after res.json() has already failed throws a second error about the body being used. Reversing the order means the body is still in your hand when parsing fails, which is exactly when you want it.

πŸ’‘

The Content-Type check is what catches cause 4 above, where the status is 200 and the body is HTML. Code that only inspects res.ok walks straight past it.

Python will not tell you what went wrong

The same situation in Python produces a far less helpful message. Four bodies that fail for four different reasons were passed to json.loads, and all four printed the same line:

text
'<!DOCTYPE html>\n<html>'   -> Expecting value: line 1 column 1 (char 0)
''                          -> Expecting value: line 1 column 1 (char 0)
'Internal Server Error'     -> Expecting value: line 1 column 1 (char 0)
'undefined'                 -> Expecting value: line 1 column 1 (char 0)

(python 3.11.9)

char 0 only says that the first character was not the start of a value; it never says which character that was. In JavaScript the message alone distinguishes HTML from plain text. In Python it does not, so you have to print the body yourself.

python
import json, requests

r = requests.get(url, headers={"Accept": "application/json"})
try:
    data = r.json()
except json.JSONDecodeError:
    print(r.status_code, r.headers.get("Content-Type"))
    print(r.url)                # a redirect shows up right here
    print(r.text[:200])
    raise
ℹ️

urllib behaves the opposite way from fetch: it raises HTTPError on 404 and 500 (measured). To see the body, call e.read() inside except urllib.error.HTTPError as e:. requests, like fetch, does not raise on status codes, so check r.ok yourself.

Messages that look similar but are not

MessageWhat it meansWhere to look
Unexpected end of JSON inputThere was nothing to parseA 204 with no body, a zero-byte file
"undefined" is not valid JSONThe thing parsed was not a stringCode that passed a missing value into JSON.parse
TypeError: Failed to fetchThe server was never reachedAddress and port, CORS, a server that is down
Unexpected token 'I', "Internal S"...The body was a plain sentenceA response written by a proxy or gateway

Messages about JSON that really is malformed β€” Expected double-quoted property name, Bad control character, anything quoting a position 26 β€” come from a different problem entirely. Those are covered message by message in the other post on this blog, 'How to Fix JSON Parse Errors β€” Unexpected token and More'.

A five-minute checklist

  1. 1Copy the URL from the failing line and paste it into the browser address bar. JSON on screen means the server is fine and the bug is in your code.
  2. 2In DevTools, Network tab, find the request and read Status and Content-Type. text/html confirms you are in this article.
  3. 3Open the Response tab and read the first line of the body. A 404 page, a login page and an index.html are three different answers.
  4. 4Log res.url and compare it with the URL you requested. A difference means a redirect was followed.
  5. 5If the status is in the 500s, move to the server logs. There is nothing to fix on the client.
  6. 6Once you know the cause, keep the getJson wrapper above so that the Content-Type check stays in place. Next time this is a single log line.

FAQ

Q. Nothing in my code changed and this started happening.

An expired login session is the usual answer. When auth lapses the API redirects to a login page and that HTML arrives where JSON should be; logging res.url and res.redirected exposes it immediately. A failed deploy that dropped the route produces the same symptom, except the status code is 404.

Q. It works locally and breaks after deploying.

Most often the proxy rule that existed only in the dev server. In development /api/... was forwarded to a backend; in production no such path exists, so you get the HTML 404 page. Opening /api/... on the deployed URL in a browser settles it in seconds.

Q. My message quotes `<script sr` instead of `<!DOCTYPE `.

It means the body is HTML that begins with <script src=. Usually that is a script a dev server injected at the top of the page, or an interstitial served by a CDN or security appliance. Either way HTML arrived instead of JSON, so the same checklist applies.

Q. The quotes contain the whole response and there is no `...`.

The body was 20 characters or fewer, so V8 quoted it in full (measured on node v24.13.0). What you see between the quotes is everything the server sent β€” typically a one-line message written by a proxy or gateway.

Q. I wrapped it in try/catch and it still is not handled.

Usually the assumption that fetch throws. A 404 is a normal response, so the only thing reaching catch is the parsing failure. A missing await also moves the rejection outside that try, which is worth checking. To branch on status codes, test res.ok explicitly.

Free tool featured in this guide
JSON formatter and validator

Paste the response body and it tells you whether it is JSON at all, and if it is, which line is wrong. Everything runs in your browser, so nothing is uploaded.

Check whether your response is JSON β†’

More posts