Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

Unexpected token '

A support ticket I have seen more than any other, in different disguises: "The API works in Postman. In our app it throws Unexpected token '<' in JSON at position 0." Every time, the same thing has happened: the app neve

A support ticket I have seen more than any other, in different disguises:
"The API works in Postman. In our app it throws Unexpected token '<' in JSON at position 0."
Every time, the same thing has happened: the app never received JSON. It received an HTML page, and the parser choked on the first character before it could learn anything about the payload.
That distinction sounds trivial. It costs people an afternoon, because the error message points at a character in a document that was never the problem.
Position 0 means "I never read a value"
A JSON document can only begin with one of seven things:
{ [ " - 0-9 t (true) f (false) n (null)
A strict parser reads the first non-whitespace character, checks it against that list, and stops immediately if it does not match.
An angle bracket matches nothing on that list. So the parser gives up before reading a value, reports position 0, and has no information about your data β€” because it never touched your data. The response is not malformed JSON. It is not JSON.
Which means: editing the payload will do nothing. The bug is upstream of the payload.
What actually arrived
Seven causes cover almost every case. The middle column is the part worth memorising, because it decides whether your code can even tell that something went wrong.
Cause
Body you received
How to recognise it
Route does not exist
HTML 404 page
Status 404, body starts with <!DOCTYPE or contains Cannot GET
Handler threw
HTML 500 page
Status 500, in development usually a stack trace
SPA catch-all rewrite
index.html, status 200
Any unknown path returns the app shell
Auth redirect
The login page
fetch followed the 302, so you get its 200
Reverse proxy / CDN
nginx, Cloudflare or LB page
502–504, server: nginx header, app never ran
Trailing slash mismatch
A 301, then the page
Check res.url, not the URL you requested
Wrong base URL
Whatever lives at that host
A typo, or http where you meant https
Only three of those come with a non-200 status. The other four are the interesting ones.
The 200 that lies
Deploy a static host with the standard single-page-app rewrite and every unknown path answers with the app shell and a 200.
So a request for a route that does not exist returns a response that looks perfectly healthy. fetch resolves. res.ok is true. res.statusText is OK.
The only thing that fails is res.json(), and its message blames a character.
This is the case where checking the status code cannot save you, because the status code is correct. What still tells the truth is the content-type header β€” which is why the fix below checks it before it parses anything.
Three ways to see the body in ten seconds
From a terminal. -i prints the status line and headers above the body, which is normally all you need:
curl -i -H 'Accept: application/json' https://api.example.com/v1/orders
Without re-running the request. clone() gives you a second readable stream:
const text = await res.clone().text();
console.log(res.status, res.headers.get("content-type"));
console.log(text.slice(0, 200));
In DevTools. Network panel, filter to Fetch/XHR, sort by status, open the failing row, read the Response tab. Not the Preview tab β€” Preview tries to render the body, and it will happily render a 404 page as if it were a document.
The rule that prevents the next one
Never call .json() on a response you have not inspected. Read the text, decide whether it is JSON, parse it yourself β€” so that when it fails, the error message contains evidence instead of a character.
async function readJson(res) {
const text = await res.text();
const type = res.headers.get("content-type") ?? "";

if (!type.includes("json")) {
throw new Error(
${res.status} ${res.url} returned "${type || "no content-type"}": +
text.slice(0, 120),
);
}
return JSON.parse(text);
}
The part that matters is text.slice(0, 120). The next time this happens, your log contains <!DOCTYPE html>

404 or a Cloudflare banner or a login form. That is a cause. A character is not.<br> Two smaller habits worth keeping:</p> <ul> <li>Treat 204 No Content as success with no body. It is the correct response to a DELETE, so a client that always calls .json() fails on a successful request. That failure has a different message β€” Unexpected end of JSON input β€” because an empty string has no first character to be unexpected.</li> <li>Strip a leading U+FEFF from anything that came from a file rather than the network. A byte-order mark is invisible in every editor, is not whitespace to a strict parser, and produces Unexpected token 'ο»Ώ', "ο»Ώ{"a":1}" is not valid JSON β€” where the quoted character looks empty. "At position 0" is legacy wording I captured these by running each input through Node 22.22.2. None of them are paraphrased: Engine Message Node 22 / current V8 Unexpected token '<', "<!DOCTYPE "... is not valid JSON Older Chrome, Safari Unexpected token < in JSON at position 0 Firefox JSON.parse: unexpected character at line 1 column 1 of the JSON data Any engine, empty body Unexpected end of JSON input Any engine, plain-text error Unexpected token 'E', "Error: not found" is not valid JSON Current V8 no longer says "at position 0" β€” it quotes the first ten characters instead. That is why half the Stack Overflow answers you will find describe a message your browser stopped printing a few versions ago. The behaviour is identical; only the wording moved. Which leads to the practical rule: never parse the message. Catch the exception, treat it as "invalid", surface the text to a human. A regex against Unexpected token will break on the next runtime upgrade. If the response came from your own server Once you have the body, the cause is normally one of five, and every one of them is a routing or header problem rather than a JSON problem:</li> <li>The catch-all is registered before the API route. app.get("*") in Express 4, app.get("/*splat") in Express 5, or a static handler above the router, swallows the request and serves the shell.</li> <li>The error handler returns HTML. A framework's default error page does exactly that. If the request accepted JSON, answer with JSON.</li> <li>The rewrite rule covers too much. /* β†’ /index.html must exclude your API prefix.</li> <li>Auth is handled by a redirect. A session guard answers an expired token with a 302 to the login page. An API should answer 401 and let the client decide.</li> <li>A proxy owns the error. If the body is stamped nginx or Cloudflare, your application never ran. Look at upstream health, not at code. The one-line version If the first byte of the body is { or [, it is JSON and the mistake is inside it. If it is <, it is a web page and the mistake is in your routing, your auth, or your base URL. Everything else in this post is a consequence of that sentence. I run a small set of browser-only JSON tools β€” formatter, validator, diff, minifier β€” and a longer version of this post, with the full table of engine differences, lives here: Unexpected token '<' in JSON at position 0. Nothing is uploaded; the parsing happens in the tab.</li> </ul>
πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.