← All articles

Unexpected Token < in JSON at Position 0: Fix HTML Responses in fetch()

Fix Unexpected token < in JSON at position 0 by proving whether fetch() returned HTML: 404 pages, login redirects, SPA fallbacks, dev proxies, WAF blocks, and wrong API URLs.

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON means your code tried to parse an HTML document as JSON. The < is the first character of <!DOCTYPE html>, <html>, or another HTML tag. In fetch code, this usually means the request hit a 404 page, login page, SPA fallback, proxy error page, WAF challenge, or the wrong URL instead of the API endpoint you expected.

The fix is not to "repair" the JSON. There is no JSON yet. First prove what the server actually returned, then fix the route, auth, proxy, or response handling.

The 30-Second Fix

When you see Unexpected token < in JSON at position 0, stop calling response.json() for one run and inspect the raw response.

const response = await fetch("/api/user");
const text = await response.clone().text();

console.log({
  status: response.status,
  ok: response.ok,
  url: response.url,
  contentType: response.headers.get("content-type"),
  preview: text.slice(0, 160)
});

Then use the preview:

Preview starts with What it usually is Where to look
<!DOCTYPE html> Framework error page, static host fallback, or full HTML document Network tab Response, request URL, server route
<html Login page, app shell, captive portal, CDN page response.url, auth cookies, redirects
<pre> Express/dev error page Server logs and stack trace
<script or app root markup SPA index.html fallback Dev proxy, rewrite rules, API path
<Error> XML error document from storage/CDN Cloud storage/CDN config, status code
JSON-looking text but wrong header Server may be returning JSON as text/html Fix Content-Type or parse deliberately

If the first character is <, the response body is not JSON. If the first character is u, o, or the input is empty, you are in a different error family.

What the Error Looks Like

Different JavaScript engines phrase the same bug differently:

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
SyntaxError: Unexpected token < in JSON at position 0
SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data
SyntaxError: JSON Parse error: Unexpected identifier "<"

The exact wording is less important than the token and position. < at position 0 means the very first character was already illegal for JSON.

Valid JSON can start with {, [, ", a number, t, f, or n. It cannot start with <.

Why fetch().json() Throws This

response.json() reads the response body and parses that text as JSON. It does not first guarantee that the server sent JSON.

This code is common, but too trusting:

const response = await fetch("/api/user");
const user = await response.json();

If /api/user returns an HTML 404 page, response.json() tries to parse:

<!DOCTYPE html>
<html>
  <body>Not Found</body>
</html>

The parser sees < immediately and throws.

Use a Safer JSON Response Helper

For production code, read the body once, check the status and Content-Type, then parse. Include a short preview in the error so the next person does not have to rediscover the same bug.

async function readJsonResponse(response) {
  const text = await response.text();
  const contentType = response.headers.get("content-type") || "";

  if (!response.ok) {
    throw new Error(
      `HTTP ${response.status} from ${response.url}: ${text.slice(0, 180)}`
    );
  }

  if (!contentType.toLowerCase().includes("application/json")) {
    throw new Error(
      `Expected JSON from ${response.url}, got ${contentType || "no content-type"}: ${text.slice(0, 180)}`
    );
  }

  try {
    return JSON.parse(text);
  } catch (error) {
    throw new Error(
      `Invalid JSON from ${response.url}: ${error instanceof Error ? error.message : String(error)}`
    );
  }
}

const response = await fetch("/api/user");
const user = await readJsonResponse(response);

While debugging, prefer response.text() over response.json(). Once the body stream is consumed, you cannot read it again unless you cloned the response first.

Cause 1: Wrong API URL

The simplest cause is a wrong path:

await fetch("/api/users/42"); // real route is /api/user/42

Your framework serves an HTML 404 page, not JSON. The browser still receives a response, so the next line fails when it tries to parse the page.

Check these details:

  • Did the request URL in the Network tab match the route you intended?
  • Did a base URL or environment variable point to the front-end host instead of the API host?
  • Did a missing leading slash turn api/user into a route relative to the current page?
  • Did a trailing slash or route parameter mismatch hit a static page instead of an API handler?

If the URL is wrong, fix the URL first. Adding more parsing guards only makes the error prettier.

Cause 2: Login Redirect or Expired Session

Auth failures often return HTML:

  1. Front end calls /api/account.
  2. Session is expired.
  3. Server redirects to /login.
  4. Fetch follows the redirect.
  5. response.json() parses the login page and throws on <.

Look at response.url and the Network tab redirect chain:

const response = await fetch("/api/account", { credentials: "include" });
console.log(response.status, response.url, response.redirected);

If response.url ends with /login, /signin, /auth, or an SSO provider page, your JSON parser is innocent. Refresh the session, send credentials/cookies correctly, or make the API return a JSON 401 instead of an HTML login screen.

For APIs, this is usually better:

export async function GET() {
  return Response.json(
    { error: "unauthorized", message: "Sign in required" },
    { status: 401 }
  );
}

Returning a redirect is fine for browser navigation. It is awkward for fetch() callers expecting JSON.

Cause 3: SPA Fallback Returned index.html

Static hosts and dev servers often rewrite unknown paths to index.html so React Router, Vue Router, SvelteKit, or another client-side router can handle them.

That is helpful for pages:

/settings/profile -> /index.html

It is painful when the missing path was supposed to be an API:

/api/user -> /index.html

This version is especially confusing because the status may be 200 OK. The body is still HTML.

Signs you hit the SPA shell:

  • response.ok is true.
  • content-type is text/html.
  • The preview contains <div id="root">, __NEXT_DATA__, a bundled script tag, or your app title.
  • Opening the API URL in the browser shows the front-end app.

Fix the host or dev server so API paths are excluded from the history fallback and forwarded to the API server.

Cause 4: Dev Proxy Not Forwarding /api

In local development, the front-end server and API server often run on different ports:

http://localhost:5173  front end
http://localhost:3000  API

If the dev proxy is missing, fetch("/api/user") goes to the front-end dev server. Many dev servers answer unknown paths with HTML.

For Vite, make sure server.proxy forwards the API path:

export default {
  server: {
    proxy: {
      "/api": "http://localhost:3000"
    }
  }
};

For Next.js, check route handlers, rewrites, and whether the request is hitting the app route you think it is:

const nextConfig = {
  async rewrites() {
    return [
      {
        source: "/api/:path*",
        destination: "http://localhost:3000/api/:path*"
      }
    ];
  }
};

export default nextConfig;

After changing proxy config, restart the dev server. Many tools read proxy rules at startup.

Cause 5: Server Error Handler Returns HTML

Backends often return JSON on success and HTML on errors. That inconsistency creates parser crashes.

For example, an Express app might have an HTML catch-all route before the API 404 handler:

app.get("*", sendIndexHtml);
app.use("/api", apiRouter);

Put API routes first and return JSON for API errors:

app.use("/api", apiRouter);

app.use("/api", (req, res) => {
  res.status(404).json({ error: "not_found" });
});

app.get("*", sendIndexHtml);

The principle applies beyond Express: API routes should return API-shaped errors. Page routes can return page-shaped errors.

Cause 6: CDN, WAF, Rate Limit, or Captive Portal

Sometimes your app did everything right, but an infrastructure layer returned HTML before the request reached your API:

  • CDN branded 502/503 page.
  • Cloudflare or another WAF challenge page.
  • Rate-limit block page.
  • Corporate proxy warning page.
  • Airport/hotel Wi-Fi captive portal login page.

Clues:

  • Status is 403, 429, 502, or 503.
  • Response body mentions a CDN, firewall, challenge, or "enable JavaScript".
  • Headers include vendor IDs such as cf-ray, x-cache, via, or CDN request IDs.
  • The issue appears only on one network, region, or environment.

The front-end fix is still to check status and content type before parsing. The root fix is in the infrastructure rule, allowlist, rate limit, DNS, or origin health.

Is This a CORS Error?

Usually, no. A pure CORS failure does not give your JavaScript an HTML body to parse. The browser blocks the response and fetch() rejects with a CORS-related error.

If you are seeing Unexpected token <, your code received a response body and tried to parse it. That response may be an HTML error page caused by a bad proxy or auth setup, but the immediate parser failure is not CORS itself.

The distinction matters:

  • CORS failure: fetch() rejects; inspect console CORS message and response headers.
  • HTML-as-JSON failure: fetch() resolves; response.json() or JSON.parse() throws.

What to Log in Production

Log enough to identify the source, not enough to leak secrets:

  • Route or endpoint name.
  • HTTP status.
  • Final response.url.
  • content-type.
  • Whether response.redirected was true.
  • A short body preview only when the data is known to be non-sensitive.
  • A request ID or trace ID for server-side lookup.

Avoid logging full response bodies from auth, payment, webhook, customer, or admin endpoints. HTML error pages can still contain email addresses, internal IDs, CSRF tokens, or stack traces.

Fix It Online

If you already have the response body, paste it into JSON Fix. If it starts with <, the tool will show that it is not valid JSON. Use HTML Viewer or HTML Formatter to inspect the returned page, then go back and fix the URL, auth, proxy, or server response.

If the body is almost JSON rather than HTML, use JSON Validator to locate the syntax problem or JSON Fix to repair common pasted-input mistakes.

Frequently Asked Questions

What does "Unexpected token < in JSON at position 0" mean?

It means the parser found < as the first character, which is not valid at the start of JSON. In fetch code, the response body is usually HTML: a <!DOCTYPE html> page, <html> document, login page, error page, SPA shell, or proxy block page.

Why is my fetch returning HTML instead of JSON?

Common causes are a wrong API URL, 404 or 500 HTML error page, login redirect, expired session, SPA history fallback, dev proxy misconfiguration, CDN/WAF block page, or backend error handler that returns HTML for API routes.

How do I see what came back?

Temporarily read await response.clone().text() and log safe metadata: status, final URL, content-type, redirect flag, and the first 100 to 160 characters. You can also open the request in the browser DevTools Network tab and inspect the Response panel.

How do I stop it from crashing my app?

Do not call response.json() blindly. Read the body, check response.ok, check that content-type includes application/json, then parse inside a try/catch so non-JSON responses become handled errors.

Why does this happen with status 200?

A status of 200 only means the server returned a successful HTTP response. It does not mean the body is JSON. SPA fallbacks, login pages, and some proxies can return an HTML page with 200 OK.

Is it a CORS problem?

Usually no. With a true CORS failure, fetch rejects and your code does not receive a body to parse. If you see Unexpected token <, your code received HTML and attempted to parse it as JSON.

Should the backend return JSON errors?

Yes for API routes. A browser page can show an HTML error page, but an API endpoint should return a JSON error object with the correct status and application/json content type.

Can JSON Fix repair this?

No. HTML is not broken JSON; it is the wrong response format. JSON Fix can confirm that the body is not JSON, but the real fix is the route, auth, proxy, CDN, or server error handling.

Related Tools & Guides

Sources

Last reviewed July 2026.