SyntaxError: Unexpected token < in JSON at position 0 (or u, or <!DOCTYPE, depending on the browser) means JSON.parse() was handed something that is not JSON, and position 0 means it failed on the very first character. The character named in the error tells you what you actually got back: < almost always means an HTML page, u almost always means the literal word undefined got stringified and sent, and an empty message with position 0 usually means the response body was blank. This is not a JSON-parsing bug, it is a request that did not get the response the code expected, and the fix is finding out what actually came back rather than adjusting how you parse it.
What the error actually means
fetch() and axios (in older configurations) do not verify that a response is JSON before you try to parse it. If your code calls response.json() or JSON.parse(text) on a body that is actually an HTML error page, a redirect target, or an empty string, JSON.parse fails immediately, at position 0, because HTML starts with < and none of that is valid JSON syntax. The parser is doing its job correctly. The problem is upstream, in what the server actually sent back.
The three common causes, ranked
1. A wrong or dead endpoint that returns a 404 page
A typo in the URL, a route that was renamed, or an endpoint that moved returns the server’s or platform’s own 404 or error page, and that page is HTML. This is by far the most common cause, especially right after a deploy or an API route change.
Fix: check the actual HTTP status code first, before looking at the body at all. A fetch call that gets a 404 or 500 will still resolve without throwing, so response.ok (or the status code) has to be checked explicitly before parsing:
const response = await fetch('/api/user');
if (!response.ok) {
const text = await response.text();
throw new Error(`Request failed (${response.status}): ${text.slice(0, 200)}`);
}
const data = await response.json();
2. An authentication redirect
An expired session or a missing auth header causes the server (or a hosting platform in front of it) to redirect to a login page instead of returning the API response. The browser follows the redirect, the fetch resolves with a 200, and the body is the login page’s HTML, not the JSON the code expected. This is the case that is most likely to be mistaken for a JSON bug, because the status code looks fine.
Fix: check for the redirect (response.redirected in the Fetch API, or comparing response.url to the request URL) and treat it the same as an auth failure, not a parse failure. If the API is behind a login wall, confirm the auth token or session cookie is actually being sent with the request.
3. A server error with no JSON error handler
The backend threw an unhandled exception, and instead of returning a JSON error body, the framework’s default error page, an HTML stack trace, or a plain-text message came back instead. This is common with servers that have a JSON API layer but fall back to a default HTML error handler for anything that was not explicitly caught.
Fix: on the server side, make sure every route, including error paths, returns Content-Type: application/json with a JSON body, even for 500s. On the client, the same status-check-before-parse pattern above catches this too, since the status code will usually be 500.
What none of these guides tell you to check first: the Content-Type header
Checking the status code narrows it to “something is wrong,” but the fastest single diagnostic is the response’s Content-Type header, and it is the step almost every guide skips in favor of jumping straight to try/catch blocks around JSON.parse. If Content-Type is text/html, you already know you got a page, not data, before you read a single byte of the body:
const response = await fetch('/api/user');
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
const text = await response.text();
console.error('Expected JSON, got:', contentType, text.slice(0, 200));
throw new Error('Unexpected response type');
}
const data = await response.json();
This turns a cryptic parse error into a clear, logged fact: what endpoint, what status, what content type, what the first 200 characters actually were. That is almost always enough to identify which of the three causes above you are looking at without more guessing. Once you know what actually came back, Reading a Stack Trace You Did Not Write covers how to read the rest of the failure if this error shows up nested inside a longer trace rather than as a standalone message.
Why try { JSON.parse() } catch {} alone does not fix this
Wrapping the parse in a try/catch stops the uncaught exception, but it does not tell you why the response was not JSON, and it usually leaves the calling code with no data and no clear error to show the user. It converts a loud, specific crash into a silent failure somewhere downstream, and that silent failure often resurfaces a few lines later as Cannot Read Properties of Undefined, once the code tries to read a field off the empty or fallback object the catch block left behind. Check the status and content type before parsing, not instead of handling a parse failure gracefully.
If the browser’s own network tab is available, the Response tab on the failing request shows the exact same thing this code checks, an HTML page or empty body, which is often the fastest first check during local development, before adding any logging at all.
This same failure mode is a frequent neighbor of CORS errors: a misconfigured CORS setup can cause a browser to receive an opaque or redirected response that looks like this error once the code tries to parse it, so if the request is cross-origin, rule out CORS before assuming the endpoint itself is broken.
FAQ
Why does the error say position 0 specifically?
Because JSON.parse failed on the very first character it read. Valid JSON must start with {, [, ", a digit, true, false, or null; anything else, most often < from an HTML document, fails immediately.
Is this a bug in JSON.parse or fetch?
No. Both are working correctly. The bug is that the response body was never JSON in the first place; JSON.parse cannot parse what was never sent as JSON, and fetch does not validate content types for you.
Why does this only happen in production, not locally?
Usually because local development points at a different base URL, a dev proxy, or has different auth handling than production, so the auth-redirect or wrong-endpoint cause only triggers once the app talks to the real deployed environment. Reproducing it locally means pointing the app at the same production URL and headers that are failing.
Can this happen with unexpected token u in JSON at position 0 instead of <?
Yes. u at position 0 means the literal string undefined was passed to JSON.parse, which usually means a variable that was supposed to hold a JSON string, or the response body itself, was undefined at the point it was parsed, often because the network request failed before the body was ever read.
Does this ever mean the server sent broken JSON, not HTML?
Occasionally. A server that manually builds a JSON string and has a bug in that code (an unescaped character, a trailing comma, an incomplete write) can produce invalid JSON that also fails at some position. That is less common than the HTML-page cause covered above, but checking the raw response text, not just the content type, rules it out.
