You called response.json() or JSON.parse() and got one of these:
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON SyntaxError: Unexpected token < in JSON at position 0 SyntaxError: Unexpected end of JSON input
The parser is telling you exactly what's wrong: the text you gave it isn't JSON. The useful clues are the token and the position.
Read the token first
| Token | What you received | Usual cause |
|---|---|---|
< at position 0 | HTML | 404, 500, login page, wrong URL, dev server fallback |
u at position 0 | the string undefined | You parsed a variable that was never set |
o at position 1 | [object Object] | You parsed something that was already an object |
end of JSON input | empty string | 204 response, empty body, network cut |
, or } late in the string | almost-JSON | trailing comma or comment in a JSON file |
(invisible) at 0 | a byte order mark | file saved as "UTF-8 with BOM" |
Step 1: Look at the real response
Swap .json() for .text() for a moment:
const res = await fetch("/api/user"); const body = await res.text(); console.log(res.status, res.headers.get("content-type"), body.slice(0, 300));
Nine times out of ten you'll see 404 and <!DOCTYPE html>. You can also open DevTools → Network, click the request, and read the Response tab.
Step 2: Fix the cause
The URL is wrong
A relative URL like fetch("api/user") (no leading slash) resolves against the current page, so on /dashboard/settings it requests /dashboard/api/user. That returns your app's HTML 404 page. Use /api/user.
In single-page apps the dev server often returns index.html for any unknown path, so a typo returns HTML with a 200 status, which is extra confusing.
The server threw an error
A 500 from many frameworks is an HTML error page. Fix the server error, and make your API return JSON errors:
// Next.js route handler return Response.json({ error: "Not found" }, { status: 404 });
You're not logged in
An expired session can redirect to a login page. fetch follows the redirect and you parse the login HTML. Check res.redirected or the final res.url.
The body is empty
Some endpoints return 204 No Content. Don't parse those:
const data = res.status === 204 ? null : await res.json();
It's already an object
JSON.parse(data); // data is already { ... } → "[object Object]" is not valid JSON
Axios and many SDKs parse JSON for you. Use the value directly.
Step 3: Write a safer fetch helper
Check the status and content type before parsing so you get an error you can actually read:
export async function getJson<T>(url: string, init?: RequestInit): Promise<T> { const res = await fetch(url, init); const type = res.headers.get("content-type") ?? ""; if (!res.ok || !type.includes("application/json")) { const text = await res.text(); throw new Error(`${res.status} from ${url}: ${text.slice(0, 200)}`); } return res.json() as Promise<T>; }
When the problem is a JSON file
If you're parsing a file rather than a response, the usual culprits are a trailing comma, a comment, single quotes, or unquoted keys. All four are valid JavaScript but invalid JSON. Paste the file into the JSON Formatter and it will point to the line. If you want comments in config, see JSON vs YAML vs TOML.