Fix this error · Browser and network
Fix: SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Your API returned an HTML page. Find out why (404, SPA rewrite, login redirect), fix the URL, and check res.ok and content-type before res.json().
Your code called res.json() or JSON.parse() on a response that is an HTML page. The < is the first character of <!DOCTYPE html>. The API URL returned a 404 page, your app's index.html from a fallback rewrite, or a login page after a redirect. Fix the URL or the server, and check res.ok and the Content-Type before you parse.
| Fact | Detail |
|---|---|
| Error | SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON |
| Other forms | Unexpected token '<', "<html><hea"... is not valid JSON · Unexpected end of JSON input (empty body) |
| Firefox | SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data |
| Safari | SyntaxError: JSON Parse error: Unrecognized token '<' |
| Where you see it | Browser console, Node.js logs, server logs for code that calls another API |
| Usual cause | The request URL served HTML: a 404 page, a single-page-app fallback, or a login redirect |
| Usual fix | Call the right URL, then check res.ok and content-type before res.json() |
| BugTape catches it | Yes, automatically, when the parse error is uncaught. A 400+ response after a click is reported too |
What the error means
res.json() reads the body and runs it through the JSON parser. JSON must start with {, [, a quote, a number, true, false or null. When the body starts with <, the parser stops at the first character. V8 (Chrome, Edge, Node.js) prints the token it found and the first 10 characters of the text, which is how you can tell it was an HTML document.
Unexpected end of JSON input is the same mistake with an empty body, for example a 204 No Content response or an error with no body.
The parse error is a symptom. The useful question is why that URL returned HTML.
Common causes
- The API route does not exist, so the server returned its 404 page. A typo, a missing
/apiprefix, a route that was not deployed, or a wrong base URL. - A single-page-app fallback rewrite. Hosting rules like "send every path to
/index.html" also catch/api/...paths that the server does not handle, and return your app's HTML with status 200. - An auth redirect. The session expired, the request was redirected to
/login, andfetchfollowed the redirect and got the login page. - The dev server answered instead of the API. In development,
fetch('/api/items')goes to the Vite or webpack dev server, which returnsindex.htmlwhen there is no proxy for/api. - A server error page. A crash, a gateway timeout or a proxy error returns an HTML error page from the host or CDN.
- A relative URL resolved against the current page.
fetch('api/items')without a leading slash on/settings/profilerequests/settings/api/items. - The body is empty.
204,304, or an error response with no body, which givesUnexpected end of JSON input.
How to fix it
See what the server actually returned
Open DevTools, then the Network tab, and click the request. Check the status, the Content-Type header, the final URL (after redirects) and the Response tab. The page title in the HTML usually tells you which of the causes above it is.
In code, read the body as text once while you debug:
const res = await fetch('/api/items');
const text = await res.text();
console.log(res.status, res.url, res.headers.get('content-type'), text.slice(0, 200));Check the status and content type before parsing
Make one helper that every request uses. It gives a clear error that names the URL and the status.
export async function getJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
const type = res.headers.get('content-type') ?? '';
if (!res.ok) {
const body = await res.text();
throw new Error(`${init?.method ?? 'GET'} ${url} returned ${res.status}: ${body.slice(0, 120)}`);
}
if (res.status === 204) return undefined as T;
if (!type.includes('application/json')) {
throw new Error(`${url} returned ${type || 'no content type'} instead of JSON (final URL ${res.url})`);
}
return res.json() as Promise<T>;
}Fix the URL
Use a leading slash for same-origin API routes, and build absolute URLs from one setting.
fetch('/api/items'); // from the site root
fetch(new URL('/items', import.meta.env.VITE_API_URL)); // separate API hostIn the Next.js App Router, an API route lives in app/api/items/route.ts and answers /api/items. A page.tsx at that path returns HTML.
Keep API paths out of the fallback rewrite
Write the single-page-app fallback so it skips your API and returns a real 404 for unknown API paths. On Vercel with a Vite app:
{
"rewrites": [
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}On Netlify, put the API rule before the catch-all, because the first matching rule wins:
/api/* https://api.example.com/:splat 200
/* /index.html 200In Express, mount the API router before the static fallback, and answer unknown API paths with JSON:
app.use('/api', apiRouter);
app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' }));
app.get('*splat', (req, res) => res.sendFile(path.join(dist, 'index.html'))); // Express 5 syntaxProxy the API in the dev server
In Vite, send /api to your backend so the dev server stops answering with index.html:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: { '/api': 'http://localhost:8000' },
},
});Return JSON errors from your API, and 401 instead of a redirect
If the session expired, an API should answer 401 with a JSON body. A redirect to an HTML login page is right for page requests and wrong for API requests. In your client, send the user to the login page when you see a 401.
if (res.status === 401) {
window.location.assign(`/login?next=${encodeURIComponent(location.pathname)}`);
return;
}How to catch it in production with BugTape
When the parse error is not caught, the browser fires unhandledrejection (from an await res.json() in an async function) or error. The BugTape browser SDK listens for both and reports Unhandled promise rejection: Unexpected token '<', "<!DOCTYPE "... is not valid JSON with the stack, the page URL, the clicks before it, and the network requests in the report. The request list shows which URL answered and with what status, which is usually the answer. Repeats with the same message on the same page become one issue with a count.
There are two more paths:
- If the HTML came with a 404 or another status of 400 or more, and the request followed a click within 8 seconds, BugTape also reports the request itself, as Broken request: /api/items returned 404 or User-triggered request failed: /api/items (500).
- A single-page-app fallback returns HTML with status 200. BugTape does not treat a 200 as failed, so only the parse error is reported, and only when it is uncaught.
If your code catches the error, as it should, report it on purpose. reportError() fires the window error event the SDK listens for:
try {
const items = await getJson<Item[]>('/api/items');
setItems(items);
} catch (err) {
reportError(err);
setError('Could not load items.');
}On a Node.js server that calls another API, an uncaught error reaches BugTape through the Node.js SDK's uncaught exception and unhandled rejection handlers, the Express handler (5xx responses) or Next.js onRequestError on the Node.js runtime. Errors you catch need bugtape.captureException(err). The SDK records nothing before init() runs.
Set release in init(). When you mark the issue fixed, a report from a newer release reopens it as a regression, which catches a rewrite rule that comes back in a later deploy. Your coding agent can read the issue over MCP (npx -y bugtape connect claude, or cursor or codex), see the URL that returned HTML, and propose the fix. You review the pull request.
FAQ
What does "Unexpected token '<'" mean?
The text your code parsed as JSON starts with <. That is almost always an HTML document: <!DOCTYPE html> or <html>. The server returned a web page at the URL you expected to return JSON.
Why does it work locally and fail in production?
The hosting setup differs. Production often has a fallback rewrite that serves index.html for unknown paths, a different API base URL, or an auth layer that redirects. Locally a dev proxy or a different server may answer the same path with JSON.
How do I fix "Unexpected end of JSON input"?
The body was empty. Check for 204 No Content and for error responses with no body before you call res.json(), or read res.text() first and parse it only when it is not empty.
Should I wrap res.json() in try/catch?
Yes, or use a helper that checks res.ok and content-type first. Then the error says which URL returned what, instead of a parser message about <.
Sources
- V8 message templates (
JsonParseUnexpectedTokenStartStringWithContext,JsonParseUnexpectedEOS), checked 2 Nov 2026. - SpiderMonkey error messages (
JSMSG_JSON_BAD_PARSE) and JavaScriptCore LiteralParser.cpp, checked 2 Nov 2026. - MDN: Response.json() and MDN: SyntaxError: JSON.parse: bad parsing, checked 2 Nov 2026.
- Vite: server.proxy, checked 2 Nov 2026.
Related errors
- TypeError: Failed to fetch:
TypeError: Failed to fetch - Cannot read properties of undefined:
TypeError: Cannot read properties of undefined (reading 'map') - ChunkLoadError: Loading chunk failed:
ChunkLoadError: Loading chunk 123 failed. - CORS: No 'Access-Control-Allow-Origin' header:
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. - "Script error." from cross-origin scripts:
Script error.
All guides: Fix an error.
Start free. Free plan · No credit card · Set up in minutes. Or try the live demo first.