Skip to content
Fixing API errors

When an API returns HTML instead of JSON

"Unexpected token < in JSON at position 0" means you parsed an error page as data. Here is how to find what the server actually sent, and why it sent it.

SandyPublished 7 min read
green and brown bamboo sticks, illustrating when an api returns html instead of json
Photo by Ryutaro Uozumi on Unsplash.
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

Older browsers phrase it as Unexpected token < in JSON at position 0. Either way, the meaning is the same and it is not really a JSON problem.

Short answer

The response body begins with <, so it is HTML rather than JSON. Your code called response.json() on an error page, a login redirect or a proxy notice. Read the body with response.text() instead and log the first few hundred characters. The HTML will normally tell you exactly what went wrong.

See what actually arrived

Stop guessing and read the body as text:

const response = await fetch(url);
const body = await response.text();
 
console.log('status:', response.status);
console.log('content-type:', response.headers.get('content-type'));
console.log('body starts:', body.slice(0, 300));

That single log usually ends the investigation. The HTML almost always names the problem in its <title> or first heading.

What the HTML is telling you

A 404 page

<!DOCTYPE html><html><head><title>404 Not Found</title>

The URL is wrong. A typo, a missing version prefix such as /v1, a doubled slash from string concatenation, or a trailing slash the router treats as a different route.

// Concatenation hides these. new URL does not.
const url = new URL('/v1/items', 'https://api.example.com');

A login page

<!DOCTYPE html><html><head><title>Sign in</title>

The request was unauthenticated and the server redirected to a login form rather than returning 401. fetch follows redirects by default, so you end up parsing the login page.

This is a server-side design flaw, but you have to cope with it. Send Accept: application/json so a well-behaved server returns a JSON error, and check for the redirect:

const response = await fetch(url, {
  headers: { Accept: 'application/json' },
  redirect: 'manual', // surface the redirect instead of following it
});
 
if (response.type === 'opaqueredirect' || response.status === 0) {
  throw new Error('Request was redirected, probably to a login page');
}

A gateway error

<html><head><title>502 Bad Gateway</title></head>
<body><center><h1>502 Bad Gateway</h1></center><hr><center>nginx</center>

The API's own infrastructure failed before reaching the application. Nothing on your side caused this and nothing on your side will fix it. Retry with backoff, and if it persists the API is down.

A rate limit or firewall page

Cloudflare and similar services return full HTML pages for blocked requests, often mentioning a challenge or a ray ID. If this appears only from your server and not your laptop, your deployment platform's IP range is probably being treated as suspicious. A descriptive User-Agent sometimes resolves it.

Your own SPA's index.html

The sneakiest case, because the status is 200:

<!DOCTYPE html><html><head><title>My App</title>

Static hosts configured for client-side routing serve index.html for any unmatched path. A request to /api/items when no such route exists returns your app shell with a success status. Everything looks fine until the parser runs.

This is why response.ok alone is insufficient.

Parsing defensively

A small wrapper turns every one of these into a message that names the real problem:

lib/fetch-json.ts
export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
  const response = await fetch(url, {
    ...init,
    headers: { Accept: 'application/json', ...init?.headers },
  });
 
  const contentType = response.headers.get('content-type') ?? '';
  const body = await response.text();
 
  if (!contentType.includes('json')) {
    const preview = body.trim().slice(0, 200).replace(/\s+/g, ' ');
    throw new Error(
      `Expected JSON from ${url} but received ${contentType || 'no content type'} ` +
        `(HTTP ${response.status}). Body starts: ${preview}`,
    );
  }
 
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} from ${url}: ${body.slice(0, 200)}`);
  }
 
  try {
    return JSON.parse(body) as T;
  } catch {
    throw new Error(`Malformed JSON from ${url}: ${body.slice(0, 200)}`);
  }
}

Three things this does that the naive version does not:

Checks Content-Type before parsing. A server that returns HTML almost always labels it text/html, so this catches the problem before the parser does.

Includes a body preview in the error. The difference between Unexpected token < and received text/html (HTTP 404). Body starts: 404 Not Found is the difference between ten minutes of debugging and none.

Sends Accept: application/json. Many servers honour it and return a JSON error instead of an HTML page. Note this header is CORS-safelisted, so it does not trigger a preflight.

Check the content type before writing the integration

You can find out in one command whether an endpoint returns JSON at all:

curl -sS -D - -o /dev/null -H "Accept: application/json" \
  https://api.example.com/v1/items | grep -i content-type
content-type: application/json; charset=utf-8

If that says text/html, you have either the wrong URL or an endpoint that is not an API.

Every listing shows whether it currently responds and where it redirects to, so a dead endpoint is visible before you write any code against it.

Quick reference

Body starts withStatusLikely cause
<!DOCTYPE html> with "404"404Wrong URL or missing version prefix
<!DOCTYPE html> with a sign-in form200Redirected to login; request was unauthenticated
<html> with "502" or "504"502/504Upstream infrastructure failure; retry
Cloudflare challenge page403/503Firewall blocked your IP or user agent
Your own app shell200SPA fallback; the API route does not exist
Empty string204No content, and JSON.parse('') throws

That last row is worth noting separately: a 204 No Content has an empty body, and parsing an empty string throws a similar-looking error. Check for response.status === 204 before parsing anything.

When it only happens in one environment

A particularly confusing variant: the same code works on your machine and fails in production, or works in production and fails locally. The cause is almost always something between your code and the API that differs between the two, and there are four usual suspects.

A development proxy. Vite, Create React App and Angular all support proxying /api to a backend during development. That proxy exists only when the dev server is running. Deploy the same build to static hosting and those paths now hit your own host, which returns the application shell for any unmatched route — an HTML document, with a 200 status. The request that worked all week starts returning your own homepage.

A single-page application fallback. Related, and the reason a 200 is so common here. Static hosts are configured to serve index.html for anything they do not recognise, so the router can handle the path client-side. That rule catches /api/whatever too, so a mistyped or missing API route quietly returns your own page instead of a 404.

A corporate network. Proxies that inspect traffic frequently return HTML error pages, and some rewrite responses entirely. If a colleague on a different network cannot reproduce your failure, this is high on the list.

Server versus browser. Code that runs in both places — a Next.js component, a shared data layer — sends different requests depending on where it executes. A relative URL means nothing on the server, and a server-side request carries no cookies unless you forward them. Either can produce a redirect to a login page, which is HTML.

The way to tell these apart quickly is to run the same request outside your application. If curl from your terminal returns JSON and your app returns HTML, the difference is something in your application's environment rather than the API. If curl also returns HTML, the API or the URL is the problem and you can stop looking at your own code.

The empty-body variant

One near-relative that produces a similar message and has a different cause, so it is worth recognising separately.

A 204 No Content response has no body by design, and JSON.parse('') throws. So does a 200 with a zero-length body, which some APIs return for a successful delete or an accepted write. The error mentions unexpected end of input rather than an unexpected token, and the fix is to check the status and the content length before parsing rather than to change how you parse.

The same applies to a HEAD request, which never has a body regardless of status. Calling .json() on one always fails, and the mistake usually comes from a helper that assumes every response can be parsed.

Why this error wastes so much time

Worth saying explicitly, because the disproportion between how simple the cause usually is and how long it takes to find is the real story here.

response.json() performs no checks. It takes whatever the body contains, hands it to a JSON parser, and the parser objects to the first character it cannot accept. For an HTML document that is the opening angle bracket, so the message describes position zero of a document that was never going to parse. It is accurate at the character level and silent about the thing that actually went wrong, which was the request.

The wording varies by runtime, which makes searching for it unreliable. Chrome and Node name the unexpected token. Firefox reports unexpected characters at line 1 column 1. Safari phrases it differently again. All three mean the body was not JSON.

Underneath it is fetch's central design decision: any completed HTTP exchange is a success, including a 404 and a 500. The promise rejects only when no response arrived at all. So a failed request followed by .json() surfaces as a parse error rather than an HTTP error, and developers end up debugging their JSON handling when the URL was wrong all along. Checking response.ok before touching the body is the habit that prevents the entire category, and it is why every example in this article does it first.

Common questions

What does 'Unexpected token < in JSON at position 0' mean?

The response body starts with a less-than sign, which almost always means it begins with <!DOCTYPE html> or <html>. You asked to parse JSON and received an HTML page, usually an error page, a login redirect or a proxy notice.

How do I see what the server actually sent?

Read the body as text instead of JSON. Call response.text() and log the first few hundred characters. The HTML page will normally state the real problem in its title or heading.

Why does this happen only in production?

Production sits behind more infrastructure. Load balancers, WAFs, CDNs and authentication proxies all return HTML error pages, and none of them exist on your laptop.

Can a 200 response still be an HTML error page?

Yes. Single-page app hosting often returns index.html with a 200 for any unmatched path, so a mistyped API route yields a successful status and a page of HTML. Checking response.ok is not enough on its own.

How do I prevent this in my own code?

Check the Content-Type header before parsing, and include a snippet of the body in the error you throw. That turns an opaque parser error into a message that names the real problem.

Sources

Written by

Sandy

I build and run this site on my own: the crawler that assembles the catalogue, the checker that probes every listing, and the writing. Before this I built SaveFromInternet and GrabReels, which meant living with other people’s APIs full time — parsers breaking when a platform shipped a change, rate limits arriving without warning, endpoints disappearing overnight. This directory exists because I got tired of free API lists that had never been checked.

APIs mentioned in this article

DummyJSON

Test Data

Fake REST API with products, users, posts, comments, todos and more

No keyCORSHTTPS

Verified 4 days ago: 100% uptime

View Details

RandomUser

Test Data

API for generating random user data like names, emails, addresses, and more. Provides JSON, XML, CSV, or YAML objects.

No keyCORSHTTPS

Verified 4 days ago: 100% uptime

View Details

Postcodes.io

Geocoding

Free UK postcode lookup API and datasets. Search, validate and reverse geocode postcodes. Open sourced project.

No keyCORSHTTPS

Verified 4 days ago: 100% uptime

View Details

Read next