Fix: SyntaxError: Unexpected token < in JSON at position 0

The Error

You call JSON.parse() or response.json() and get one of these errors:

Chrome / Node.js:

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

Older Chrome / Node versions:

SyntaxError: Unexpected token < in JSON at position 0

Firefox:

JSON.parse: unexpected character at line 1 column 1 of the JSON data

Empty response variant:

SyntaxError: Unexpected end of JSON input

Other token variants:

SyntaxError: Unexpected token 'u', "undefined" is not valid JSON
SyntaxError: Unexpected token 'N', "Not Found" is not valid JSON
SyntaxError: Expected property name or '}' in JSON at position 1

All of these mean the same thing: the string you passed to JSON.parse() (or that response.json() tried to parse) is not valid JSON. The token mentioned in the error (<, u, N, etc.) tells you what character the parser hit when it was expecting valid JSON.

Why This Happens

JSON.parse() expects a string that is strictly valid JSON. JSON is not JavaScript — it has its own, stricter syntax rules. When the parser encounters something that violates those rules, it throws a SyntaxError.

The most common scenario: your fetch() call gets back an HTML page instead of JSON. That HTML starts with <!DOCTYPE html> or <html>, so the first character the JSON parser sees is <. This typically happens when:

  • The API URL is wrong (404 page returned as HTML)
  • The server threw an error (500 page returned as HTML)
  • A proxy or CDN intercepted the request and returned its own HTML page
  • You’re hitting the wrong server entirely

But it can also be a JSON syntax problem — trailing commas, single quotes, comments, or encoding issues in the JSON string itself.

Fix 1: Your API Is Returning HTML, Not JSON

This is the cause 90% of the time. Your fetch() call is getting back an HTML page instead of a JSON response.

Step 1: Check what you’re actually receiving. Replace .json() with .text() temporarily:

// Instead of this:
const data = await response.json();

// Do this temporarily to debug:
const text = await response.text();
console.log(text);

If you see HTML (like <!DOCTYPE html> or an error page), the problem is not with JSON parsing — it’s with what the server is sending back.

Step 2: Figure out why you’re getting HTML. Open the Network tab in DevTools, find the request, and check:

  • Status code: A 404 means the URL is wrong. A 500 means the server crashed. A 200 with HTML means the server is returning a page instead of an API response.
  • Response body: Click the response to see what came back.
  • Request URL: Verify it matches what you expected.

Step 3: Fix the URL. Common mistakes:

// Wrong — relative URL hits your frontend server, not your API
fetch('/api/users')

// Wrong — missing protocol
fetch('api.example.com/users')

// Wrong — typo in path
fetch('http://localhost:3000/api/uers')

// Correct
fetch('http://localhost:5000/api/users')

If you’re using a relative URL like /api/users and your frontend is served by a dev server (Vite, Next.js, CRA), the request goes to the frontend server, which returns its HTML page for any unrecognized route. Either use the full API URL or set up a dev proxy.

Fix 2: Check the Response Status Before Parsing

Never call .json() without checking if the response was successful first. A non-2xx response often returns HTML error pages.

const response = await fetch('/api/users');

if (!response.ok) {
  // Don't try to parse — the response might be HTML
  const text = await response.text();
  throw new Error(`HTTP ${response.status}: ${text}`);
}

const data = await response.json();

With axios, the pattern is different because axios throws on non-2xx status codes automatically:

try {
  const { data } = await axios.get('/api/users');
} catch (error) {
  if (error.response) {
    console.error('Status:', error.response.status);
    console.error('Body:', error.response.data);
  } else {
    console.error('Network error:', error.message);
  }
}

Fix 3: Your Server Is Not Setting Content-Type Correctly

Sometimes the server returns JSON data, but without the correct Content-Type: application/json header. Some frameworks and proxies behave differently based on this header.

Make sure your server sets the Content-Type header:

Express / Node.js:

// res.json() sets Content-Type automatically
res.json({ message: 'ok' });

// If using res.send() or res.end(), set it manually
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: 'ok' }));

Python / Flask:

from flask import jsonify

@app.route('/api/data')
def get_data():
    return jsonify({"message": "ok"})  # Sets Content-Type automatically

Django:

from django.http import JsonResponse

def get_data(request):
    return JsonResponse({"message": "ok"})  # Sets Content-Type automatically

Fix 4: Invalid JSON Syntax in Static Files

If you’re parsing a JSON file (like package.json, tsconfig.json, or a config file) and hitting this error, the file contains invalid JSON syntax. JSON is stricter than JavaScript objects. Note that YAML config files have their own syntax pitfalls — see Fix: YAML mapping values not allowed here for similar issues in YAML.

Trailing commas are not allowed:

// INVALID
{
  "name": "my-app",
  "version": "1.0.0",
}
// VALID
{
  "name": "my-app",
  "version": "1.0.0"
}

Single quotes are not allowed:

// INVALID
{
  'name': 'my-app'
}
// VALID
{
  "name": "my-app"
}

Comments are not allowed:

// INVALID
{
  // This is a comment
  "name": "my-app"
}
// VALID
{
  "name": "my-app"
}

Note: tsconfig.json is a special case — TypeScript’s parser supports comments and trailing commas (it uses JSONC, not strict JSON). But if you read tsconfig.json with JSON.parse() or fs.readFileSync + JSON.parse, it will fail. Use TypeScript’s own API or a JSONC parser like jsonc-parser instead.

Unquoted keys are not allowed:

// INVALID
{
  name: "my-app"
}
// VALID
{
  "name": "my-app"
}

Use a JSON validator like jsonlint.com or your editor’s built-in JSON validation to find the exact syntax error. VS Code highlights JSON syntax errors with red squiggly lines.

Fix 5: Empty Response Body

If you get Unexpected end of JSON input, the response body is empty. Calling response.json() on an empty body fails because JSON.parse("") throws.

Common causes:

  • A 204 No Content response (which correctly has no body)
  • A DELETE or PUT endpoint that returns an empty response
  • A server error that returns a blank page

This can also happen when your API endpoint is behind a reverse proxy like Nginx that returns a 502 Bad Gateway with an empty body.

Fix — check for empty bodies before parsing:

const response = await fetch('/api/resource', { method: 'DELETE' });

if (response.status === 204 || response.headers.get('content-length') === '0') {
  // No body to parse
  return null;
}

const data = await response.json();

Or use a safe wrapper:

async function safeJson(response) {
  const text = await response.text();
  if (!text) return null;
  return JSON.parse(text);
}

Fix 6: CORS Proxy Returning HTML

If you’re using a CORS proxy (like cors-anywhere or a similar service), the proxy itself may be returning an HTML error page instead of forwarding your request. This happens when:

  • The proxy is rate-limited and returns a “too many requests” HTML page
  • The proxy URL format is wrong
  • The proxy service is down

Debug it by logging the raw response:

const response = await fetch('https://cors-proxy.example.com/https://api.example.com/data');
const text = await response.text();
console.log('Proxy response:', text);

If you see the proxy’s HTML page, you need a different proxy or a proper backend solution. Public CORS proxies are unreliable for production. See setting up proper CORS headers instead.

Fix 7: BOM Characters in JSON Files

A BOM (Byte Order Mark) is an invisible Unicode character (U+FEFF) that some text editors add at the beginning of files. It’s invisible in most editors, but JSON.parse() sees it and throws because \uFEFF is not valid JSON.

Check for a BOM:

const text = await response.text();
console.log('First char code:', text.charCodeAt(0)); // 65279 = BOM

Fix — strip the BOM before parsing:

function stripBom(text) {
  return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
}

const data = JSON.parse(stripBom(text));

If the BOM is in a static file, re-save the file as UTF-8 without BOM. In VS Code, click the encoding in the status bar (bottom right), select “Save with Encoding,” then choose “UTF-8.”

Fix 8: Parsing a Non-String Value

JSON.parse() expects a string. If you pass something else, JavaScript coerces it to a string first, which usually produces invalid JSON.

JSON.parse(undefined);   // SyntaxError: "undefined" is not valid JSON
JSON.parse(null);         // This actually works — returns null
JSON.parse({});           // SyntaxError: "[object Object]" is not valid JSON
JSON.parse(someBuffer);   // Might work, might not, depending on encoding

Fix — make sure you’re passing a string:

// If working with a Buffer (Node.js)
const data = JSON.parse(buffer.toString('utf-8'));

// If the value might be undefined
if (typeof text === 'string' && text.length > 0) {
  const data = JSON.parse(text);
}

Fix 9: Double Serialization

Sometimes data gets JSON.stringify()-ed twice. The result is a valid JSON string containing an escaped JSON string, and parsing it once gives you a string instead of an object:

const obj = { name: 'Alice' };
const double = JSON.stringify(JSON.stringify(obj));
// '"{\\"name\\":\\"Alice\\"}"'

JSON.parse(double);       // '{"name":"Alice"}' — a string, not an object
JSON.parse(JSON.parse(double)); // { name: 'Alice' } — works, but you shouldn't need this

If you find yourself calling JSON.parse() twice, the real fix is to find where the double serialization happens and remove one of the JSON.stringify() calls. Common culprits:

  • Storing already-serialized JSON in a database column, then serializing the whole row again
  • An API middleware that calls JSON.stringify() on a response body that’s already a string
  • localStorage.setItem('data', JSON.stringify(JSON.stringify(obj))) — an accidental double wrap

Fix 10: Parsing a Response You Already Read

The body of a Response object can only be consumed once. If you call .json() or .text() twice, the second call fails.

const response = await fetch('/api/data');
const text = await response.text();    // reads the body
const data = await response.json();    // TypeError: body used already

Fix — clone the response if you need to read it twice:

const response = await fetch('/api/data');
const cloned = response.clone();

const text = await cloned.text();
console.log('Raw:', text);

const data = await response.json();

Or parse the text yourself:

const response = await fetch('/api/data');
const text = await response.text();
console.log('Raw:', text);

const data = JSON.parse(text);

Still Not Working?

  1. Check the exact URL in the Network tab. Your code might construct the URL correctly, but a service worker, browser extension, or redirect could be changing it. Look at the actual request URL the browser sent, not the one in your code.

  2. Your API route might not exist in production. If it works in development but fails in production, your backend may not be deployed, the URL may be different, or your frontend server is serving its index.html as a catch-all for unknown routes (common with single-page app hosting on Netlify, Vercel, etc.).

  3. Hidden characters in the response. Beyond BOM characters, some APIs return invisible Unicode characters (zero-width spaces, soft hyphens) that break parsing. Inspect the raw bytes:

    const text = await response.text();
    console.log([...text].map(c => c.charCodeAt(0)));
  4. The response is gzipped but not decoded. In Node.js, if you’re using http.get() directly (not fetch or axios), the response might be compressed. You need to decompress it before parsing. Use fetch or axios instead — they handle decompression automatically.

  5. Your server returns different Content-Type based on Accept header. Some APIs return HTML when the Accept header isn’t set to application/json. Set it explicitly:

    fetch('/api/data', {
      headers: {
        'Accept': 'application/json'
      }
    });
  6. Your ESLint config is pointing to a corrupted JSON file. If the error happens during a build step and the stack trace mentions a config file like .eslintrc.json or babel.config.json, open that file and validate its syntax.

  7. The JSON is truncated. If a large response is cut off mid-stream (network timeout, proxy buffer limit), you get Unexpected end of JSON input because the JSON is incomplete. Check the Content-Length header against the actual body length. If they don’t match, there’s a network or proxy issue.

  8. You’re running JSON.parse() on a JavaScript object that’s already parsed. This happens when a library (like axios) has already parsed the response for you. Check the type before parsing:

    // axios already parses JSON responses
    const { data } = await axios.get('/api/users');
    // data is already an object — don't call JSON.parse(data)
    
    console.log(typeof data); // 'object', not 'string'
  9. Your localhost server isn’t running. If the API server is down, your request might hit the frontend dev server instead, which returns HTML. Make sure your backend is running before making requests.


Related: If you’re getting CORS errors on the same API call, fix CORS first — a CORS proxy returning HTML is a common cause of JSON parse errors. If the error involves accessing a property on the parsed result and you get Cannot read properties of undefined, the JSON parsed successfully but has a different structure than your code expects.

Related Articles