Why it happens
JSON never starts with <; HTML almost always does. Most common sources first:
1. A wrong URL returned a 404 page. The checker holds Express’s default 404 response, Cannot GET /api/invoices. Look for a missing /api prefix, a typo, or a relative path: fetch('api/invoices') on /dashboard/ requests /dashboard/api/invoices.
2. The frontend server answered instead of the API. Single-page app dev servers and hosts return index.html for unknown paths, with status 200, so res.ok alone does not catch it. A lowercase "<!doctype "... is the same case.
3. The server or a proxy failed. An app crash, or a 502 Bad Gateway from nginx or a load balancer while the backend is down, returns an HTML error page.
4. A login redirect. An expired session or a missing token redirects to a sign-in page, and fetch() follows it. Captive portals on hotel and office networks do the same.
How to fix Unexpected token ‘<’
Log the status, the final URL and the first characters of the body; together they name the cause. To inspect a copied response, paste it into the JSON validator, which reports HTML as HTML.
fetch
Check the status and the Content-Type before calling res.json():
const res = await fetch('/api/invoices');
const type = res.headers.get('content-type') ?? '';
if (!res.ok || !type.includes('application/json')) {
const preview = (await res.text()).slice(0, 150);
throw new Error(`${res.status} from ${res.url} (${type}): ${preview}`);
}
const invoices = await res.json();
res.url is the address after redirects, so a login redirect shows up as /login, and res.redirected is true.
axios
axios rejects 4xx and 5xx responses itself, so HTML that reaches your code came with a 200: an SPA fallback or a login page. It does not throw on that HTML. The string lands in response.data, and the failure surfaces later as something like data.map is not a function. Check the type:
const { data, headers } = await axios.get('/api/invoices');
if (typeof data === 'string') {
throw new Error(`Expected JSON, got ${headers['content-type']}: ${data.slice(0, 150)}`);
}
Dev server proxy
In Vite, forward API paths to the backend:
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: { '/api': 'http://localhost:3000' },
},
});
Create React App uses a "proxy": "http://localhost:3000" field in package.json. In production, put the host’s catch-all rewrite to index.html after the API routes.
Express
Return JSON for unknown API routes. Register this after them:
app.use('/api', (req, res) => {
res.status(404).json({ error: `No route for ${req.method} ${req.originalUrl}` });
});
Expired sessions
Treat 401, 403 and res.redirected as signed out: send the user to the login page instead of parsing.
Same error in other languages
- Firefox: JSON.parse: unexpected character at line 1 column 1
- Safari and React Native: JSON Parse error: Unrecognized token ‘<’
- Python requests:
response.json()raises JSONDecodeError: Expecting value: line 1 column 1 (char 0) - Go: invalid character ‘<’ looking for beginning of value
Other messages and what they mean: common JSON errors.