Posted in Blog · Reading time ~6 min

What "Unexpected token < in JSON at position 0" actually means

The error means your code called JSON.parse() on something that starts with the character < — almost always an HTML error page returned by your API instead of the JSON you expected. The fix is to check the HTTP status code and Content-Type header before parsing, which surfaces the real problem (a 500, a 404, a misrouted request) instead of pretending it's a JSON syntax issue.

I've seen this error in five production apps now and the reaction is always the same. Someone pings the Slack channel: "the API is broken, JSON.parse is throwing." Twenty minutes go by while three people argue about the parser, the API, and the JSON spec. The actual problem takes ten seconds to find.

This post is for the next person in that situation.

The error means exactly what it says

JavaScript's JSON.parse looks at the first character of the input. If that character can start a valid JSON value — {, [, ", a digit, t (for true), f (for false), or n (for null) — it tries to parse. Otherwise it throws SyntaxError: Unexpected token X in JSON at position 0.

When the unexpected token is <, you have a giant hint: your input starts with <!doctype html> or <html> or <title> or some other markup. You're not parsing JSON; you're parsing an HTML page.

The actual question is: why is the thing you thought returned JSON returning HTML instead? There are four answers.

1. Your server returned an error page

Your fetch hit a backend that crashed, hit a wrong route, or threw an unhandled exception. The framework's default error response is an HTML page — Express, Django, Rails, FastAPI's debug mode all ship default HTML error pages. Your client code didn't check response.ok, so it tried to parse the error page as JSON.

// What people write
const data = await fetch('/api/users').then(r => r.json());

// What you should write
const r = await fetch('/api/users');
if (!r.ok) throw new Error(`HTTP ${r.status}: ${(await r.text()).slice(0, 200)}`);
const data = await r.json();

This single change turns an opaque Unexpected token < into HTTP 500: <!doctype html><html><head><title>Internal Server Error</title>... and you immediately know what to look at.

2. The 404 page

You misspelled the URL, deployed to the wrong path, or your reverse proxy sent the request to the wrong upstream. Nginx, S3 static hosting, and every SPA dev server return their default 404 HTML when an unknown path is requested. Same fix as above: check the response status first.

3. Your SPA dev server's catch-all fallback

This is the sneaky one. SPAs configure their dev server to return index.html for unknown routes (so client-side routing works). If your API call accidentally hits a URL the dev server doesn't proxy, you get back… index.html. Which starts with <.

I once spent an afternoon on this. The fix was a typo in vite.config.js's proxy settings. The HTML I was "parsing" was my own React app's shell.

4. CORS preflight returning HTML

Rare but real. Cross-origin POSTs trigger a preflight OPTIONS request. If your server doesn't handle OPTIONS explicitly, the framework may return its default response — sometimes an HTML 405 Method Not Allowed page in older configs. The browser sees the preflight fail and the actual request never happens; your error handler logs the preflight body as the response.

The 30-second diagnosis

In browser DevTools:

  1. Network tab → find the request → click it → Response sub-tab.
  2. Look at the first character. Is it <, or {/[?
  3. Check the Status code and Content-Type header.

If Status is not 2xx, your code shouldn't be parsing the body as JSON in the first place. Fix the call site, not the JSON.

If Status is 200 but Content-Type is text/html, your server is misconfigured — it succeeded but returned the wrong kind of body. Fix the server.

If neither of those, you genuinely have something weird (a transparent proxy injecting an interstitial page, an authentication redirect chain, a captive portal on a hotel network). Investigate the proxy/auth layer.

The fix pattern

For every fetch that expects JSON, this is the minimum:

async function getJson(url, options) {
  const r = await fetch(url, options);
  if (!r.ok) {
    const body = await r.text();
    throw new Error(`HTTP ${r.status} ${r.statusText}: ${body.slice(0, 200)}`);
  }
  const ct = r.headers.get('content-type') || '';
  if (!ct.includes('application/json')) {
    const body = await r.text();
    throw new Error(`Expected JSON, got ${ct}: ${body.slice(0, 200)}`);
  }
  return r.json();
}

Three checks: status, content type, parse. Any failure throws a message you can actually act on — not Unexpected token < in JSON at position 0.

When the error handler itself crashes

Bonus level: your error handler is also written assuming JSON. It runs after the fetch fails, tries to parse the error response, and crashes again — this time with the same useless message. The wrapper above avoids that because it grabs r.text(), not r.json(), on the error path.

If you're handed code you can't change and need to debug it, paste the response body into the JSON Validator — it'll tell you instantly whether you have valid JSON, and if not, what's wrong. Often what looks like JSON to a tired developer is actually an HTML error page that happens to have a { somewhere in the middle.

Same bug, other languages

The shape repeats, the error message changes:

  • Python: json.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
  • Go: invalid character '<' looking for beginning of value
  • Rust (serde_json): expected value at line 1 column 1
  • Java (Jackson): Unrecognized token '<': was expecting (JSON String, Number, ...)

Fix is identical: check the response status and content type before decoding. If you've ever seen other common JSON parse errors, you'll notice the pattern — most of them are "you parsed something that wasn't JSON," not "your JSON is broken."

One more thing

If you control the API and you're returning HTML on errors, stop. Return JSON for everything, even errors:

{ "error": "user_not_found", "message": "No user with id 42" }

Your client code stays simple, your error messages stay structured, and nobody else will ever land on a Stack Overflow page Googling "Unexpected token < in JSON at position 0" because your API misbehaved.

Got a response body that you're sure should be valid JSON but isn't? Drop it into the JSON Validator — it pinpoints the exact line and column the parser tripped on, which is usually the fastest way to spot a stray < or BOM hiding in the data.