SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON means JavaScript tried to parse an HTML page as JSON. It almost always comes from await res.json() after a fetch() that hit the wrong URL, a 404 or 500 error page, a login redirect, or a single-page-app fallback that returns index.html. Check res.ok and the Content-Type header before you call res.json(), and log await res.text() to see what the server really sent.
The same family of errors has other wordings: Unexpected end of JSON input, "[object Object]" is not valid JSON, "undefined" is not valid JSON and Expected property name or '}'. Each one points to a different mistake, and the message tells you which one if you know how to read it.
Every error below was produced on my machine with Node.js v24.22.1, using a tiny local HTTP server for the fetch() cases. I also ran the main messages in Chrome 156, which shares the V8 engine with Node, and got the same wording.
In this guide:
- How to read the error message
- Cause 1: The server returned an HTML page instead of JSON
- Where the HTML usually comes from
- Cause 2: Unexpected end of JSON input (an empty string)
- Cause 3: A 204 No Content response
- Cause 4: "[object Object]" is not valid JSON
- Cause 5: "undefined" is not valid JSON
- Cause 6: Single quotes and unquoted keys
- Cause 7: Trailing commas
- Cause 8: Comments in a JSON file
- Cause 9: NaN, unquoted keys and leading zeros
- Cause 10: A byte order mark at the start of a file
- A safe JSON.parse helper
- Same errors in Chrome
- Quick reference: message, cause and fix
- Frequently asked questions
How to read the error message
Modern V8 messages have three useful parts. Take Unexpected token '<', "<!DOCTYPE "... is not valid JSON:
- The token, here
<, is the first character the parser could not accept. - The snippet,
"<!DOCTYPE "..., is the start of the text you passed in. Seeing HTML there settles it. - A position, when present, like
at position 1 (line 1 column 2), tells you exactly where parsing stopped.
The MDN reference for JSON.parse() confirms it throws a SyntaxError whenever the string is not valid JSON. Firefox and Safari word their messages differently, so if a user reports a different text, match it by meaning rather than exact words.
Cause 1: The server returned an HTML page instead of JSON
This is the classic. The API path is wrong or the route crashed, and the server answers with an HTML 404 page. res.json() does not care about the status code, so it tries to parse the HTML:
import http from "node:http";
// A tiny server that answers /api/user with an HTML 404 page, like many hosts do
const server = http.createServer((req, res) => {
res.writeHead(404, { "Content-Type": "text/html" });
res.end("<!DOCTYPE html><html><body><h1>404 Not Found</h1></body></html>");
});
server.listen(0, async () => {
const { port } = server.address();
try {
const res = await fetch(`http://127.0.0.1:${port}/api/user`);
const data = await res.json();
console.log(data);
} catch (e) {
console.log(`${e.name}: ${e.message}`);
} finally {
server.close();
}
});
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
The fix is to check the response before parsing. This helper treats 204 as "no data", rejects non-2xx statuses and anything that is not labelled as JSON, and includes the first part of the body in the error so you can see what came back:
import http from "node:http";
async function getJSON(url, options) {
const res = await fetch(url, options);
if (res.status === 204) return null;
const type = res.headers.get("content-type") || "";
if (!res.ok || !type.includes("application/json")) {
const body = await res.text();
throw new Error(`HTTP ${res.status} (${type || "no content-type"}): ${body.slice(0, 60)}`);
}
return res.json();
}
const server = http.createServer((req, res) => {
if (req.url === "/api/user") {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ id: 1, name: "Ana" }));
} else {
res.writeHead(404, { "Content-Type": "text/html" });
res.end("<!DOCTYPE html><html><body><h1>404 Not Found</h1></body></html>");
}
});
server.listen(0, async () => {
const base = `http://127.0.0.1:${server.address().port}`;
console.log(await getJSON(`${base}/api/user`));
try {
await getJSON(`${base}/api/users`);
} catch (e) {
console.log(e.message);
}
server.close();
});
{ id: 1, name: 'Ana' }
HTTP 404 (text/html): <!DOCTYPE html><html><body><h1>404 Not Found</h1></body></ht
The good route returned the object, and the typo in /api/users produced a readable error with the status code and the start of the HTML page instead of a confusing SyntaxError. The Response.ok documentation explains that ok is true only for statuses 200 to 299.
Where the HTML usually comes from
Once you know the body is HTML, these are the usual suspects, roughly in the order I see them:
- Wrong URL or missing route. A relative URL like
fetch("api/user") resolves against the current page path, so it breaks on nested pages. Start it with /. - Dev server fallback. Vite and Create React App dev servers return
index.html for unknown paths, so a missing proxy setting looks like this error with status 200. Next.js sends its HTML 404 page instead. - Server error page. A crashed backend returns an HTML 500 page. Check the server log, not the browser.
- Login redirect. An expired session redirects to the login page, and
fetch() follows the redirect silently. - PHP warnings before the JSON. A notice printed before
echo json_encode(...) puts <br /> at the start of the body.
That last case is common on shared hosting. My guide to fixing headers already sent in PHP shows how stray output gets into a response.
Cause 2: Unexpected end of JSON input (an empty string)
An empty string, or one with only spaces, has nothing to parse:
for (const input of ["", " "]) {
try {
JSON.parse(input);
} catch (e) {
console.log(JSON.stringify(input), "->", `${e.name}: ${e.message}`);
}
}
"" -> SyntaxError: Unexpected end of JSON input
" " -> SyntaxError: Unexpected end of JSON input
You get this when a file is empty, when localStorage holds an empty string, or when you parse a response body that has no content. Check for an empty string before parsing, or use the safeParse helper further down.
Cause 3: A 204 No Content response
APIs often answer a DELETE or PUT with 204 and no body. Calling res.json() on it fails even though the request succeeded:
import http from "node:http";
// The API deletes the item and replies 204 No Content with an empty body
const server = http.createServer((req, res) => {
res.writeHead(204);
res.end();
});
server.listen(0, async () => {
const { port } = server.address();
try {
const res = await fetch(`http://127.0.0.1:${port}/api/items/7`, { method: "DELETE" });
console.log("status:", res.status);
const data = await res.json();
console.log(data);
} catch (e) {
console.log(`${e.name}: ${e.message}`);
} finally {
server.close();
}
});
status: 204
SyntaxError: Unexpected end of JSON input
The status was 204, a success, yet the code crashed. The MDN page for 204 No Content states that this status has no body. The getJSON helper from cause 1 returns null for it.
Cause 4: "[object Object]" is not valid JSON
Here the data is already a JavaScript object, and it goes through JSON.parse() a second time:
const user = { name: "Ana" };
try {
JSON.parse(user);
} catch (e) {
console.log(`${e.name}: ${e.message}`);
}
SyntaxError: "[object Object]" is not valid JSON
JSON.parse() converts its argument to a string first, and any plain object becomes "[object Object]". This usually happens after res.json(), Axios or a framework has already parsed the response for you. Use the object directly. If you are unsure what you have, log typeof value:
const user = { name: "Ana" };
const text = JSON.stringify(user);
console.log(typeof text, text);
console.log(JSON.parse(text).name);
// Double-encoded JSON: a string that contains a JSON string
const twice = JSON.stringify(text);
console.log(twice);
console.log(typeof JSON.parse(twice), typeof JSON.parse(JSON.parse(twice)));
string {"name":"Ana"}
Ana
"{\"name\":\"Ana\"}"
string object
The last line shows double-encoded JSON: a string that contains a JSON string. One JSON.parse() gives back a string, and a second one gives the object. The real fix is to stop the server from calling JSON.stringify twice.
Cause 5: "undefined" is not valid JSON
Passing a missing value gives a very specific message:
const settings = {};
try {
JSON.parse(settings.theme);
} catch (e) {
console.log(`${e.name}: ${e.message}`);
}
console.log("JSON.parse(null) ->", JSON.parse(null));
SyntaxError: "undefined" is not valid JSON
JSON.parse(null) -> null
undefined becomes the string "undefined", which is not JSON. Note the second line: JSON.parse(null) does not throw. It turns null into the string "null", which is valid JSON, and returns null. That is why JSON.parse(localStorage.getItem("missing")) quietly gives null, and your code later fails somewhere else. My post on null vs undefined in JavaScript explains why the two behave so differently.
Cause 6: Single quotes and unquoted keys
JSON is stricter than JavaScript object syntax. Keys and strings need double quotes:
try {
JSON.parse("{'name': 'Ana'}");
} catch (e) {
console.log(`${e.name}: ${e.message}`);
}
SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
Position 1 is the single quote right after the brace. This shows up when someone writes JSON by hand, or when Python code prints a dict with str() instead of json.dumps(). The JSON standard, RFC 8259, defines strings as starting and ending with double quotes, so there is no setting to allow single quotes. Fix the code that produces the text.
Cause 7: Trailing commas
A comma after the last item is fine in JavaScript, but not in JSON:
const samples = ['{"a": 1,}', '[1, 2, 3,]'];
for (const s of samples) {
try {
JSON.parse(s);
} catch (e) {
console.log(s, "->", `${e.name}: ${e.message}`);
}
}
{"a": 1,} -> SyntaxError: Expected double-quoted property name in JSON at position 8 (line 1 column 9)
[1, 2, 3,] -> SyntaxError: Unexpected token ']', "[1, 2, 3,]" is not valid JSON
The object case says Expected double-quoted property name because, after a comma, the parser expects another key. The array case uses the shorter Unexpected token form. For a multi-line file, the line and column numbers help you find the spot quickly:
const text = `{
"id": 1,
"name": "Ana",
}`;
try {
JSON.parse(text);
} catch (e) {
console.log(e.message);
const m = e.message.match(/line (\d+) column (\d+)/);
if (m) {
const [line, col] = [Number(m[1]), Number(m[2])];
console.log(text.split("\n")[line - 1]);
console.log(" ".repeat(col - 1) + "^ line " + line + ", column " + col);
}
}
Expected double-quoted property name in JSON at position 30 (line 4 column 1)
}
^ line 4, column 1
The parser stops at the closing brace on line 4, one line after the real mistake. With trailing commas, always look just before the position it reports.
Cause 8: Comments in a JSON file
Config files often pick up comments, and standard JSON has none:
const config = `{
// port for the dev server
"port": 3000
}`;
try {
JSON.parse(config);
} catch (e) {
console.log(`${e.name}: ${e.message}`);
}
SyntaxError: Expected property name or '}' in JSON at position 4 (line 2 column 3)
Line 2, column 3 is the first slash of the comment. Remove the comments, or switch to a format that supports them, such as JSONC (used by VS Code settings and tsconfig.json) or JSON5, with a matching parser. Plain JSON.parse() will never accept them.
Cause 9: NaN, unquoted keys and leading zeros
A few JavaScript values and number formats are not valid JSON either:
for (const s of ["{name: \"Ana\"}", '{"n": NaN}', '{"big": 01}']) {
try {
JSON.parse(s);
} catch (e) {
console.log(s, "->", `${e.name}: ${e.message}`);
}
}
{name: "Ana"} -> SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
{"n": NaN} -> SyntaxError: Unexpected token 'N', "{"n": NaN}" is not valid JSON
{"big": 01} -> SyntaxError: Unexpected number in JSON at position 9 (line 1 column 10)
NaN, Infinity and undefined do not exist in JSON, and numbers cannot start with a zero. JSON.stringify() avoids this on its own by writing NaN as null, so this text usually comes from hand-built strings or another language's output. The free tools page has more browser-based helpers for everyday developer checks.
Cause 10: A byte order mark at the start of a file
Files saved by some Windows editors start with an invisible byte order mark (BOM), U+FEFF:
import { writeFileSync, readFileSync } from "node:fs";
writeFileSync("/tmp/bom.json", "\uFEFF" + '{"ok": true}', "utf8");
const text = readFileSync("/tmp/bom.json", "utf8");
console.log("first char code:", text.charCodeAt(0).toString(16));
try {
JSON.parse(text);
} catch (e) {
console.log(`${e.name}: ${e.message}`);
}
first char code: feff
SyntaxError: Unexpected token '', "{"ok": true}" is not valid JSON
The message says Unexpected token '' with what looks like nothing between the quotes, because the BOM is invisible. The first character code, feff, gives it away. Strip it with text.replace(/^\uFEFF/, "") before parsing. The Node.js fs documentation covers readFileSync encodings, and utf8 keeps the BOM rather than removing it.
A safe JSON.parse helper
For data you do not control, such as localStorage, query strings or files from users, wrap the parse in one small function that checks the type, strips a BOM and returns a fallback:
function safeParse(text, fallback = null) {
if (typeof text !== "string") return fallback;
try {
return JSON.parse(text.replace(/^\uFEFF/, ""));
} catch (e) {
console.warn("Invalid JSON:", e.message);
return fallback;
}
}
console.log(safeParse('{"ok": true}'));
console.log(safeParse("\uFEFF" + '{"bom": "removed"}'));
console.log(safeParse(""));
console.log(safeParse(undefined, {}));
console.log(safeParse("{'bad': 1}", { theme: "light" }));
{ ok: true }
{ bom: 'removed' }
Invalid JSON: Unexpected end of JSON input
null
{}
Invalid JSON: Expected property name or '}' in JSON at position 1 (line 1 column 2)
{ theme: 'light' }
Every bad input returned its fallback instead of crashing the page, and the warning still tells you what went wrong. Do not use this to hide errors from your own API, though. There, the getJSON check from cause 1 is better because it reports the status code. For more on handling errors from await, see my async/await tutorial.
Same errors in Chrome
Node and Chrome both use V8, so the messages match. I ran the four most common inputs in headless Chrome to confirm:
Chrome 156.0.8078.47
JSON.parse('<!DOCTYPE html><html>') -> SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
JSON.parse('') -> SyntaxError: Unexpected end of JSON input
JSON.parse("{'a': 1}") -> SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
JSON.parse(undefined) -> SyntaxError: "undefined" is not valid JSON
The MDN JSON bad parsing page lists the Firefox versions of these messages, which start with JSON.parse:. I did not run Firefox or Safari for this post.
Quick reference: message, cause and fix
| Message you see | Most likely cause | Fix |
|---|
Unexpected token '<', "<!DOCTYPE "... is not valid JSON | The server sent an HTML page (404, 500, login or SPA fallback) | Check the URL, res.ok and the Content-Type |
Unexpected end of JSON input | Empty string or empty body (204, failed request) | Handle empty responses before parsing |
"[object Object]" is not valid JSON | You parsed something that is already an object | Use the object directly |
"undefined" is not valid JSON | A missing variable or property was passed in | Check the value first or use a fallback |
Expected property name or '}' | Single quotes, unquoted keys or comments | Fix the JSON source or the generator |
Expected double-quoted property name | Trailing comma after the last property | Remove the comma |
Unexpected token '' | File starts with a byte order mark | Strip \uFEFF before parsing |
Unexpected token 'N' | NaN, undefined or Infinity in the data | Send null or a string instead |
If you send JSON yourself, my fetch POST JSON example shows the matching headers and body on the sending side.
Frequently asked questions
What does Unexpected token '<', "<!DOCTYPE "... is not valid JSON mean?
Your code tried to parse an HTML page as JSON. The server returned HTML, usually a 404 page, an error page, a login page or a dev server's index.html, and res.json() or JSON.parse() could not read it.
How do I fix Unexpected end of JSON input?
Something passed an empty string to JSON.parse(), or you called res.json() on an empty body such as a 204 response. Check that the text is not empty, and skip parsing for 204 responses.
Why does JSON.parse say [object Object] is not valid JSON?
The value is already an object. JSON.parse() converts it to the string [object Object] before parsing. Use the object as it is, because something has already parsed it.
How do I see what the server actually returned?
Call const text = await res.text() instead of res.json() and log it along with res.status and res.headers.get("content-type"). Your browser DevTools Network tab also shows the raw response.
Does res.json() check the HTTP status code?
No. It reads and parses the body whatever the status is. Check res.ok or res.status first, otherwise an HTML error page produces a SyntaxError.
Can JSON have comments or trailing commas?
No. Standard JSON allows neither. Remove them, or use JSONC or JSON5 with a parser that supports those formats.
Why does JSON.parse(null) not throw an error?
null becomes the string "null", which is valid JSON, so JSON.parse returns null. JSON.parse(undefined) does throw, because "undefined" is not valid JSON.
Last checked: 9 November 2026. Figures, settings and guidance can change, so confirm anything important with the sources linked above.