If you've hit SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON, you're not alone. This is one of the most searched JSON errors on the web, and the cause is always the same: your server returned HTML, not JSON.
Why this error happens
When you call response.json() in a fetch request, JavaScript's JSON parser reads the response body character by character. If the first character is < (the opening of an HTML tag), the parser throws immediately because < is not a valid way to start a JSON document.
This typically means you're receiving one of these instead of your expected JSON:
- A 404 Not Found HTML page — your API URL is wrong or the resource doesn't exist
- A 500 Internal Server Error page — the server crashed and returned an error page
- A login redirect — the session expired and the server is redirecting you to an HTML login page
- A proxy or CDN error page — an intermediate server is intercepting the request and returning its own HTML error
- A CORS preflight error page returned by a proxy
How to diagnose it
The fix starts with logging what the server actually sent back. Instead of calling .json() blindly, always check the response status first, and if it fails, call .text() to see the raw response body.
// Basic pattern — always check status before .json()
const res = await fetch('/api/users');
if (!res.ok) {
// Log the actual response body to see the HTML
const text = await res.text();
console.error('Server response:', text);
throw new Error(`HTTP error ${res.status}: ${res.statusText}`);
}
const data = await res.json(); // safe to call only if res.ok
When you log text, you'll see something like this — which immediately tells you the real problem:
<!DOCTYPE html> <html> <head><title>404 Not Found</title></head> <body>The requested resource was not found.</body> </html>
A more defensive fetch wrapper
Once you know the pattern, you can write a reusable helper that handles this correctly every time:
async function fetchJSON(url, options = {}) {
const res = await fetch(url, options);
if (!res.ok) {
let errorBody = '';
try {
errorBody = await res.text();
} catch (_) {}
throw new Error(
`Fetch failed: ${res.status} ${res.statusText}\n${errorBody.slice(0, 200)}`
);
}
return res.json();
}
// Usage:
const user = await fetchJSON('/api/users/42');
Common scenarios and their specific fixes
Wrong API URL (404)
Double-check the URL. Common mistakes include missing a leading slash, a typo in the path, or calling a staging URL from a production build. Check res.status — if it's 404, the URL is wrong.
Authentication expired (401 or 302 redirect)
If your app uses sessions and the session expires, some servers redirect to /login and return HTML. The fix is to check for a 401 status and redirect the user to log in, or refresh an OAuth token automatically.
if (res.status === 401) {
window.location.href = '/login';
return;
}
Server crash (500)
A 500 response means something went wrong on the server. The HTML page is the server's default error response. Fix the server-side bug — the API endpoint is throwing an unhandled exception. Check your server logs, not just the client-side error.
CORS issue returning a proxy error page
Some corporate proxies or load balancers return an HTML error page when a CORS preflight fails. You'll see a 200 status but get HTML. In this case, inspect the Content-Type header of the response before parsing:
const contentType = res.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await res.text();
console.error('Expected JSON but got:', text.slice(0, 200));
throw new Error('Response was not JSON');
}
Validate your JSON manually
If you're testing an API response and aren't sure whether it's valid JSON, paste it into the JSON Validator. It will parse the response and report the exact character position of any error, making it easy to confirm whether you received JSON or HTML.
For large API responses that are hard to read, the JSON Formatter will pretty-print the structure and highlight any issues in the tree view.