You call an API, try to parse the response as JSON, and get SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON. The server returned an HTML page instead of JSON. This guide covers every reason this happens and how to fix each one.
Try it directly in your browser:
Check your JSON response →Quick diagnostic
Before looking for the cause, log the raw response:
const response = await fetch('/api/data');
console.log(response.status); // HTTP status code
console.log(response.headers.get('content-type')); // Should be application/json
const text = await response.text();
console.log(text); // See exactly what was returned
The status and the first line of text will point you to the correct cause below.
Cause 1: Wrong URL (404 Not Found)
The most common cause. A typo in the endpoint, a missing version prefix (/api/v2/ vs /api/), or a trailing slash difference routes the request to a 404 page instead of your API handler.
Status: 404
Content-Type: text/html
// Wrong
fetch('/api/users'); // 404 — endpoint doesn't exist
// Correct
fetch('/api/v1/users'); // 200 JSON
Fix: Open the Network tab in DevTools, click the request, and check the Request URL. Compare it against the API documentation. (More on HTTP 404 Not Found, including the 404 Client Error: Not Found for url requests error.)
Cause 2: Server Error (500 Internal Server Error)
An unhandled exception on the server causes the framework (Express, Django, Rails, etc.) to return an HTML error page with a stack trace. This is the default error handler for most frameworks.
Status: 500
Content-Type: text/html
// Express default behaviour — returns HTML error page
app.get('/api/data', (req, res) => {
throw new Error('Something broke!'); // → HTML 500 page
});
// Correct — return JSON errors
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
Fix: Add a JSON error handler middleware. Check your server logs for the exception causing the 500. See HTTP 500 Internal Server Error; if a proxy is involved you may instead get a 502 Bad Gateway.
Cause 3: Authentication Redirect (Login Page)
When your session expires or your token is invalid, many servers redirect to a login page (301/302). fetch() follows redirects automatically and you receive the HTML login page instead of a 401 JSON error.
Status: 200 (after redirect) — but Content-Type: text/html
Body: HTML login page
// Check for auth failure before parsing
const response = await fetch('/api/protected', {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
}
const data = await response.json();
Fix: Always send the correct Authorization header. Handle 401 and 403 explicitly before calling .json(). Use a JWT Decoder to check whether your token has expired.
Cause 4: CORS Preflight Failure
For cross-origin requests, the browser sends an OPTIONS preflight. If the server does not handle OPTIONS correctly, some servers or proxies return an HTML error. The actual JSON request never happens.
Symptom: The Network tab shows a failed OPTIONS request, not a failed GET/POST.
// Server must respond to OPTIONS with the right headers
app.options('/api/*', cors()); // Express example
Fix: Enable CORS on the server for the correct origin. In development, use a proxy (Vite's server.proxy, Create React App's proxy) so requests appear same-origin.
Cause 5: Development Proxy Not Configured
In local development with a frontend dev server (Vite, webpack-dev-server), API calls to /api/... need to be proxied to your backend. Without the proxy, the dev server handles the request and returns a 404 HTML page.
// vite.config.js
export default {
server: {
proxy: {
'/api': 'http://localhost:3000'
}
}
};
Fix: Configure your dev server proxy. This is one of the most common causes in React/Vue/Svelte projects.
Cause 6: Wrong Content-Type on Request
Some APIs check the request Content-Type header and return an HTML error if it's wrong or missing. This is common with Rails and Django REST Framework.
// Missing Content-Type — may trigger HTML response
fetch('/api/data', {
method: 'POST',
body: JSON.stringify(data)
});
// Correct
fetch('/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
Cause 7: CDN or Load Balancer Error Page
A CDN (Cloudflare, AWS CloudFront) or load balancer in front of your API can intercept requests and return its own HTML error page — a 503 Service Unavailable or a Cloudflare error page — before the request ever reaches your server.
Fix: Configure the CDN to return JSON error responses, or check whether your origin server is actually reachable.
Defensive coding pattern
Use this pattern to catch all of the above cases in one place:
async function fetchJSON(url, options = {}) {
const response = await fetch(url, options);
const contentType = response.headers.get('content-type') || '';
if (!response.ok || !contentType.includes('application/json')) {
const body = await response.text();
throw new Error(
`API error ${response.status}: expected JSON but got ${contentType}.\n` +
body.slice(0, 300)
);
}
return response.json();
}
This wrapper logs the actual response body when something goes wrong, so you can see immediately whether you got a 404 page, a login redirect, or a server crash message.
Validate the JSON you do receive
Once you have confirmed the API is returning JSON, paste the response into the JSON Validator to check for syntax errors, or use the JSON Formatter to make the structure readable.
Ready to check your json response?
Open JSON Validator →