Skip to content
Tools

Unexpected Token '<', "<!DOCTYPE "... Is Not Valid JSON: How to Fix It

· 5 min read

Unexpected token '<', "<!DOCTYPE "... is not valid JSON means your code called response.json() or JSON.parse on an HTML page. The JSON is not broken: the server (or something in front of it) answered with a 404 page, an error page, a login page or your app's index.html. Log the status code and the first 200 characters of the body to see which one; if the body does turn out to be JSON-like text, check it in the JSON validator.

The < is the first character of <!DOCTYPE html> or <html>, which is why the error always sits at position 0. Fixing the parser will never help; you have to fix the request or the server.

The same error in every runtime

RuntimeMessage
Chrome 110+, Edge, Node 20+SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Chrome and Node 18 and earlierSyntaxError: Unexpected token < in JSON at position 0
FirefoxSyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data
Safari, Bun (JavaScriptCore)SyntaxError: JSON Parse error: Unrecognized token '<'
Some Safari versions, from response.json()SyntaxError: The string did not match the expected pattern.

If the excerpt shows "<!doctype "... (lowercase) or "<html><hea"..., it is the same problem with a different HTML template. Python's requests reports the same situation as JSONDecodeError: Expecting value: line 1 column 1 (char 0); see Python JSONDecodeError explained.

Minimal reproduction

// Any URL that returns an HTML page reproduces it:
const res = await fetch("/api/user");   // typo: the real route is /api/users
const data = await res.json();
// SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

Which HTML page did you get? Causes, most common first

1. A 404 page from a wrong URL

A typo in the path, a missing /api prefix, a relative URL resolved against the current page (fetch("api/users") on /settings/ requests /settings/api/users), or an environment variable for the API base URL that is empty in production. The status is 404, though some hosts return 200 with a custom page.

2. The single-page app fallback

Dev servers and static hosts are often configured to serve index.html for any unknown path so client-side routing works. A request to a missing API route then gets your app shell with status 200, so a status check alone will not catch it. This is the classic cause in local development when the dev-server proxy to the backend is missing or points at the wrong port.

3. A login page after a redirect

Expired session, missing cookie, or a missing Authorization header. The server answers 302 to /login, fetch follows the redirect automatically, and you receive the login form with status 200. Check res.redirected and res.url; for cross-origin requests that need cookies, set credentials: "include".

4. A server error page

An unhandled exception makes many frameworks render an HTML error page with status 500. Proxies do the same for 502, 503 and 504 when the backend is down or slow. The server logs, not the client, hold the real error.

5. A CDN, firewall or maintenance page

Bot-protection challenges, rate-limit pages, captive Wi-Fi portals and maintenance banners are HTML. They often appear only in production or only for some users, which makes the bug look random.

The fix: check before you parse

Read the body as text, then decide. This turns a confusing SyntaxError into an error that names the status, the final URL and what came back:

Works in browsers and Node 18+. The Accept header asks frameworks that negotiate content to send JSON errors instead of HTML.
async function getJson(url, init) {
  const res = await fetch(url, { headers: { Accept: "application/json" }, ...init });
  const type = res.headers.get("content-type") || "";
  const body = await res.text();

  if (!res.ok) {
    throw new Error("HTTP " + res.status + " from " + res.url + ": " + body.slice(0, 200));
  }
  if (!type.includes("json")) {
    throw new Error("Expected JSON from " + res.url + " but got " + (type || "no content type") + ": " + body.slice(0, 200));
  }
  return body ? JSON.parse(body) : null;
}

Matching on "json" accepts application/json, application/problem+json and similar types. Returning null for an empty body avoids a second classic failure, covered in Unexpected end of JSON input.

Debug it in two minutes

  1. Open DevTools, Network tab, click the failing request. Check Status, the Response tab, and whether there was a redirect.
  2. Copy the request as cURL and run it with -i to see headers: curl -i https://example.com/api/users. If curl gets JSON and the browser does not, the difference is cookies, auth headers or the base URL.
  3. Compare res.url with the URL you meant to call. A trailing slash, a missing /api or a wrong port shows up immediately.
  4. If the response is JSON but still fails, paste it into the validator; the problem is then a syntax error, covered in Unexpected token in JSON.

Fixes by cause

What you see in the bodyFix
Your app's own HTML shellFix the API path or the dev-server proxy; exclude /api from the SPA fallback rewrite.
A generic "Not Found" pageFix the URL; use an absolute base URL from configuration rather than a relative path.
A login or SSO pageSend credentials (credentials: "include" or an Authorization header) and handle 401 explicitly.
"Internal Server Error" or a stack traceFix the server bug; make the API return JSON errors with a proper status.
"Bad Gateway" / "Service Unavailable"The backend is down or timing out behind a proxy; add retries with backoff for 502-504.
A challenge or rate-limit pageRespect rate limits; ask the CDN owner to allow API traffic.

If you own the API, make every response, including errors and 404s on API routes, return JSON with the right status code. Clients then get a parseable error body instead of a page meant for humans.

FAQ

Why does response.json() fail with Unexpected token '<'?

Because the response body is HTML, not JSON. response.json() parses the body without checking the status or content type, so a 404, error or login page reaches JSON.parse and fails on its first character.

The status is 200, so why am I getting HTML?

A single-page-app fallback or a followed login redirect both return HTML with status 200. Check the Content-Type header and res.redirected as well as res.ok.

It works locally but fails in production. Why?

Usually the API base URL is not set in the production environment, so requests go to the frontend host and get its HTML; or a CDN, firewall or auth layer in production intercepts the request.

How do I see what the server actually returned?

Call res.text() instead of res.json() and log the first few hundred characters, or open the request in the browser's Network tab and look at the Response tab.

More JSON guides