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:
- Check
res.ok/res.statusbefore calling.json()— error pages usually travel with 404/401/502, and the status gives you the real error message; - Call
.text()first, thenJSON.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
- Network panel: what's the status? 200 doesn't mean OK — SPA fallback is also 200
- Response body: does it start with
<!DOCTYPE html>or{? curl -idirectly: does it reproduce? What about with the session cookie?- Check nginx: does the
/apiproxy_pass match before thetry_filesfallback? - Check baseURL: are the dev/prod env vars spelled right?
- Add
res.okchecks 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.
Related Tools
Related Articles
Complete Guide to HTTP API Testing: From Basics to Advanced Debugging Techniques
Master the core skills of HTTP API testing, including GET/POST/PUT/DELETE requests, header configuration, request body formats, authentication methods, and debugging techniques. Boost your development efficiency with an online API testing tool.
Online JWT Generator/Decoder: JSON Web Token Tool
Learn JWT (JSON Web Token) structure, how it works, and use cases. Understand how to use an online tool to generate and decode JWTs, plus security best practices.
Online Certificate Decoder: Parse SSL/TLS Certificate Information
Learn how to use an online tool to decode and view SSL/TLS certificate details. Understand certificate structure, common fields, and how to verify certificate validity.