Skip to content
API2026-09-084 min read

Unexpected token '<' is not valid JSON — 5 Reasons Your API Returned HTML Instead

Symptom: the request "worked," the parse didn't

Uncaught (in promise) SyntaxError: Unexpected token '<', "<html>... is not valid JSON

The code probably looks like this:

const res = await fetch('/api/user');
const data = await res.json();   // ← throws here

One-line diagnosis: the response body starts with <, meaning the server sent an HTML page, and res.json() died on the very first character. This is not a JSON syntax problem — something went wrong at the HTTP layer, and your API endpoint was never actually reached.

Unlike Unexpected token u (undefined) or Unexpected token , (trailing comma), the '<' token almost always points in one direction: you received a web page.

Five causes, ranked by frequency

1. SPA history fallback: 404 rewritten to index.html (most common)

The classic nginx config for SPAs:

location / {
  try_files $uri $uri/ /index.html;   # unknown paths all fall back to the app shell
}

If /api/user lands in this location (the /api proxy_pass is missing, or the path prefix doesn't match), nginx can't find a file — so it serves index.html with status 200. The frontend happily calls .json() and explodes.

Verify: open DevTools → Network → Response for that request. A full HTML document containing <div id="root"> is the smoking gun.

2. Expired session: a 302 bounced you to the login page

Server-side auth fails → 302 redirect → the browser follows it automatically → you receive the login page's HTML with status 200. fetch defaults to redirect: 'follow', so the 302 is invisible to your code.

// Make redirects observable
const res = await fetch('/api/user', { redirect: 'manual' });
console.log(res.type);   // "opaqueredirect" means a redirect happened

Fix: have the backend answer /api/** with a 401 JSON instead of a 302, and intercept 401 globally on the frontend to route to login.

3. Gateway error pages: nginx emits HTML on 502/503

The backend process is down or timing out, so the gateway returns its built-in error page:

<html>
<head><title>502 Bad Gateway</title></head>
...

Many frontends only catch the JSON parse error and never look at the status code — the actual failure reason is thrown away.

4. Wrong baseURL or port

// Dev proxy not active, so the request hits the frontend dev server itself
fetch('/api/user')                       // ❌ vite proxy not configured
fetch('http://localhost:3000/api/user')  // ❌ 3000 is the frontend; API lives on 8080

Env-switching between .env.development and .env.production with a mistyped prefix is a repeat offender. Verify: compare the actual request URL in the Network panel against what you assumed.

5. Server error branch forgot it was an API

// Express example: the error branch returns HTML
app.get('/api/user', (req, res) => {
  if (!req.session.user) {
    return res.redirect('/login');   // ❌ HTML response
  }
  res.json(user);
});

The three-step diagnostic (do it in this order)

# 1. Bypass the frontend and look at the raw response — status, Content-Type, first lines
curl -i 'https://your-site.com/api/user' | head -20

# 2. Send the same headers the frontend sends (many 302s are triggered by a missing Cookie/Token)
curl -i -H 'Cookie: session=xxx' 'https://your-site.com/api/user'

# 3. Inspect just the first 200 bytes of the body
curl -s 'https://your-site.com/api/user' | head -c 200

curl -i answers three questions at once: what's the status (200/302/502), what's the Content-Type (text/html vs application/json), and how does the body start (<!DOCTYPE or {"). Nine times out of ten the cause is obvious from that output. You can also paste the URL into an online API tester to inspect the full response headers.

Defensive frontend pattern: check res.ok before parsing

async function safeJson(res) {
  const text = await res.text();
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${text.slice(0, 100)}`);
  }
  try {
    return JSON.parse(text);
  } catch {
    throw new Error(`Not JSON: ${text.slice(0, 100)}`);
  }
}

Two key points:

  1. Check res.ok / res.status before calling .json() — error pages usually travel with 404/401/502, and the status gives you the real error message;
  2. Call .text() first, then JSON.parse, so a failure can print the first 100 characters of the body — instantly revealing whether it's HTML.

How this differs from a JSON syntax error

If the status is 200, the Content-Type is application/json, and you still get Unexpected token (with a token other than <) — then it's genuinely malformed JSON: trailing commas, single quotes, comments, BOM, and friends. See 5 common JSON parse errors and fixes, and use a JSON formatter to pinpoint the exact error position.

Checklist

  1. Network panel: what's the status? 200 doesn't mean OK — SPA fallback is also 200
  2. Response body: does it start with <!DOCTYPE html> or {?
  3. curl -i directly: does it reproduce? What about with the session cookie?
  4. Check nginx: does the /api proxy_pass match before the try_files fallback?
  5. Check baseURL: are the dev/prod env vars spelled right?
  6. Add res.ok checks and a .text() fallback — never surface a raw SyntaxError to users

Provided by ToolVault. Related tools: API Tester, JSON Formatter, HTTP Status Codes, curl to Code. See the homepage for more developer tools.


Advertisement