Why Is My API Returning HTML Instead of JSON?

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 →
About the author

Pasindu Ishan is a software developer based in Sri Lanka. He builds developer tools at JSON Dev Tools.

Frequently Asked Questions

Why does my fetch() call return HTML instead of JSON?

The server is returning an HTML error page (404, 500, or a login redirect) instead of your expected JSON. The URL might be wrong, the server may have crashed, or you need to authenticate before the API will respond with data.

How do I check what my server actually returned?

Call response.text() instead of response.json() and log the result. This gives you the raw response body — usually an HTML error page — which shows you exactly what went wrong. Also check response.status and response.headers.get('content-type').

How do I fix 'Unexpected token < in JSON at position 0'?

This error means your server returned HTML (which starts with <) and you tried to parse it as JSON. Fix the underlying cause: correct the URL, handle auth, or fix the server error. Check the HTTP status code before calling .json() to catch error responses early.

Why would an authenticated API return HTML?

When your session expires or your token is missing or invalid, many APIs redirect to a login page (301/302) instead of returning a 401 JSON error. fetch() follows the redirect automatically and you receive the HTML login page. Always send the correct Authorization header and check for 401/403 status codes.