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.
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.
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:
curl -s -i -H "Accept: application/json" http://localhost:3000/api/nopeHTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
...
<!DOCTYPE html><html lang="ko"> <- 26,283 bytes of bodyThe 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.
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 ....
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 onlySo 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 characters | What the body started with | Where that usually comes from |
|---|---|---|
<!DOCTYPE | An ordinary HTML document | A 404 or 500 page, a login page, an SPA's index.html |
<script sr | HTML starting with <script src= | A script a dev server injected ahead of the page, or a CDN or security appliance's interstitial |
<?xml vers | An XML document | An <Error><Code> response from cloud object storage |
<br /><b> | A warning printed ahead of the body | A PHP server with error output turned on |
Internal S | One line of plain text | A 500 or 502 produced by a proxy or gateway |
undefined | The string "undefined" was parsed | Not 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.
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:
// 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:
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 forThat 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:
| Request | status | res.ok | redirected | Content-Type | res.json() |
|---|---|---|---|---|---|
| Missing API route, dev server | 404 | false | false | text/html | SyntaxError |
| Missing API route, production | 404 | false | false | text/html | SyntaxError |
| http to https redirect | 404 | false | true | text/html | SyntaxError |
| A healthy JSON API | 200 | true | false | application/json | parsed 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.
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:
'<!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.
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])
raiseurllib 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
| Message | What it means | Where to look |
|---|---|---|
Unexpected end of JSON input | There was nothing to parse | A 204 with no body, a zero-byte file |
"undefined" is not valid JSON | The thing parsed was not a string | Code that passed a missing value into JSON.parse |
TypeError: Failed to fetch | The server was never reached | Address and port, CORS, a server that is down |
Unexpected token 'I', "Internal S"... | The body was a plain sentence | A 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
- 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.
- 2In DevTools, Network tab, find the request and read Status and Content-Type.
text/htmlconfirms you are in this article. - 3Open the Response tab and read the first line of the body. A 404 page, a login page and an
index.htmlare three different answers. - 4Log
res.urland compare it with the URL you requested. A difference means a redirect was followed. - 5If the status is in the 500s, move to the server logs. There is nothing to fix on the client.
- 6Once you know the cause, keep the
getJsonwrapper above so that theContent-Typecheck 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.
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.