Сообщение переводится так: «неожиданный символ <, строка "<!DOCTYPE "... — это не JSON». Код вызвал response.json() или JSON.parse() и ждал данные в формате JSON, а получил HTML-страницу: с символа < начинается любая из них. Ошибка в разборе только видна, а случилась раньше: сервер прислал не то, что ты ждал. Обычно это страница 404 или 500, страница входа или index.html dev-сервера.
Один и тот же сбой браузеры описывают по-разному:
- Chrome, Edge и Node.js:
SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON; - Firefox:
SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data; - Safari:
SyntaxError: JSON Parse error: Unrecognized token '<'.
Ниже — как за минуту увидеть, что пришло на самом деле, и пять причин, из-за которых это бывает. Весь вывод снят на стенде: Vite 6 на порту 5173, бэкенд на Node.js на порту 3000 и браузеры Chromium и Firefox.
Что получил res.json() на самом деле
Сначала посмотри, что пришло
Не гадай по тексту ошибки: прочитай ответ как текст и выведи статус и тип содержимого. Временная замена res.json() на три строки:
const res = await fetch('http://localhost:5173/api/users')
console.log(res.status, res.headers.get('content-type'))
console.log((await res.text()).slice(0, 60))
200 text/html
<!doctype html>
<html lang="ru">
<head>
<script type="
Статус 200, но тип text/html, а в теле — начало HTML-страницы. В браузере то же самое видно без кода: DevTools → Network (Сеть) → нужный запрос → вкладки Headers (Заголовки) и Response (Ответ). Дальше всё зависит от того, какая это страница. Если текст ответа уже скопирован, вставь его в проверку JSON онлайн: она покажет, где именно ошибка, и узнает HTML-страницу.
Причина 1: dev-сервер отдал index.html вместо API
Самый частый случай во фронтенде. Страница открыта на dev-сервере Vite (порт 5173), API работает на другом порту (3000), а запрос идёт по относительному адресу /api/users:
const res = await fetch('http://localhost:5173/api/users')
const users = await res.json()
console.log(users)
<anonymous_script>:1
<!doctype html>
^
SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON
at JSON.parse (<anonymous>)
at parseJSONFromBytes (node:internal/deps/undici/undici:5959:19)
at successSteps (node:internal/deps/undici/undici:5940:27)
at fullyReadBody (node:internal/deps/undici/undici:4832:9)
at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
at async consumeBody (node:internal/deps/undici/undici:5949:7)
at async file:///app/users.mjs:2:15
Vite не знает путь /api/users и по правилам одностраничного приложения отвечает своим index.html с кодом 200. Отсюда и «200 text/html». Исправление — научить dev-сервер передавать запросы к API бэкенду. В vite.config.js:
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
})
После перезапуска Vite тот же код получает данные:
[ { id: 1, name: 'Анна' } ]
У webpack-dev-server то же самое настраивается в devServer.proxy. На проде роль прокси играет веб-сервер — например, блок location /api/ в nginx. Если его нет, а для всех путей стоит try_files $uri /index.html, на запрос к API снова придёт index.html.
Отсюда же ответ на вопрос «в Postman работает, а в браузере нет». Postman ходит прямо на бэкенд, на порт 3000, а страница в браузере обращается к dev-серверу на 5173, у которого нет прокси.
Причина 2: сервер вернул страницу ошибки 404 или 500
Опечатка в адресе или падение на сервере — и вместо JSON приходит HTML-страница ошибки. Обращение к /api/user вместо /api/users:
const res = await fetch('http://localhost:3000/api/user')
console.log(res.status)
const users = await res.json()
404
<anonymous_script>:1
<!DOCTYPE html><html><body><h1>Not Found</h1></body></html>
^
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
fetch не считает 404 и 500 ошибкой: промис выполняется, и res.json() честно пытается разобрать страницу ошибки. Почему так устроено, объяснено в статье про Failed to fetch. Поэтому перед разбором проверяй статус и тип содержимого и показывай понятную ошибку:
async function getJson(url, options) {
const res = await fetch(url, options)
if (res.status === 204) return null
const type = res.headers.get('content-type') ?? ''
if (!res.ok || !type.includes('application/json')) {
const text = await res.text()
throw new Error(`${res.status} ${type}: ${text.slice(0, 40)}`)
}
return res.json()
}
console.log(await getJson('http://localhost:5173/api/users'))
console.log(await getJson('http://localhost:5173/api/users/1', { method: 'DELETE' }))
console.log(await getJson('http://localhost:5173/api/report'))
[ { id: 1, name: 'Анна' } ]
null
file:///app/api.mjs:7
throw new Error(`${res.status} ${type}: ${text.slice(0, 40)}`)
^
Error: 500 text/html: <!DOCTYPE html><html><body><h1>Internal
at getJson (file:///app/api.mjs:7:11)
at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
at async file:///app/api.mjs:14:13
Вместо загадочного Unexpected token '<' ошибка сразу говорит: сервер ответил 500 и прислал HTML. Где ловить такие ошибки и как не потерять их в цепочке await, рассказано в статье о промисах и async/await.
Причина 3: редирект на страницу входа
Сессия истекла, сервер перенаправил запрос на /login, а fetch молча прошёл по редиректу и получил HTML-форму входа со статусом 200. Проверка res.ok такое пропустит, а проверка типа содержимого — поймает. Отличить этот случай помогают свойства res.redirected (оно станет true) и res.url (там окажется адрес страницы входа). Лечится на стороне API: на запрос к API без авторизации он должен отвечать 401 с JSON, а не перенаправлять на страницу.
Unexpected end of JSON input: ответ пустой
Если тело ответа пустое, ошибка другая — «неожиданный конец JSON». Чаще всего её получают после DELETE или PUT, когда сервер честно отвечает 204 No Content:
const res = await fetch('http://localhost:5173/api/users/1', { method: 'DELETE' })
console.log(res.status)
await res.json()
204
<anonymous_script>:1
SyntaxError: Unexpected end of JSON input
at JSON.parse (<anonymous>)
at parseJSONFromBytes (node:internal/deps/undici/undici:5959:19)
at successSteps (node:internal/deps/undici/undici:5940:27)
at consumeBody (node:internal/deps/undici/undici:5946:9)
at _Response.json (node:internal/deps/undici/undici:5886:18)
at file:///app/delete.mjs:3:11
at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
В Chrome то же звучит как Failed to execute 'json' on 'Response': Unexpected end of JSON input, в Firefox — JSON.parse: unexpected end of data at line 1 column 1 of the JSON data. Пустая строка — не JSON, поэтому на 204 разбирать нечего. Функция getJson выше возвращает в этом случае null.
”undefined” и “[object Object]” is not valid JSON
Эти две ошибки возникают без сервера: в JSON.parse передали не строку с JSON.
const user = { id: 1 }
for (const text of [undefined, user, "{'id': 1}"]) {
try {
JSON.parse(text)
} catch (error) {
console.log(String(error))
}
}
SyntaxError: "undefined" is not valid JSON
SyntaxError: "[object Object]" is not valid JSON
SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
"undefined" is not valid JSON— разбирают поле, которого нет:JSON.parse(data.settings), гдеsettingsне пришло. Проверь, что строка вообще есть."[object Object]" is not valid JSON— данные уже разобраны.res.json()и axios возвращают готовый объект, аJSON.parseпревращает его в строку[object Object]и спотыкается. ВторойJSON.parseпросто не нужен.Expected property name or '}'— JSON написан руками с одинарными кавычками. В JSON ключи и строки берутся только в двойные кавычки, а надёжнее всего получать строку черезJSON.stringify.
Uncaught SyntaxError: Unexpected token ’<’ без «is not valid JSON»
Если в сообщении нет слов про JSON, HTML получил не JSON.parse, а браузер, который загружал скрипт. Тег <script src="/js/app.js"> указывает на несуществующий файл, а сервер одностраничного приложения на любой путь отдаёт index.html. Браузер пытается выполнить HTML как JavaScript. Chrome пишет в консоль Uncaught SyntaxError: Unexpected token '<', Firefox — SyntaxError: expected expression, got '<'.
Проверь в Network, что пришло по адресу скрипта. Обычно виноват путь: файл не собрался, или сайт лежит в подпапке, а сборка ждёт корень. У Vite это лечит настройка base, например base: '/shop/'.
Python: Expecting value: line 1 column 1 (char 0)
В Python та же ситуация выглядит иначе: requests пытается разобрать HTML и сообщает, что не нашёл значения в первой же позиции. Сервер ответил на запрос к /api/report страницей ошибки 500:
python3 -c "import requests; requests.get('http://localhost:5173/api/report').json()" 2>&1 | tail -1
requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
Лечится так же: перед .json() проверь response.status_code и response.headers['Content-Type'], а при сомнениях выведи response.text[:200]. Как работать с JSON в Python, рассказано в статье про модуль json.
Ошибки, на которых спотыкаются
try/catch вокруг JSON.parse вместо проверки ответа. Ошибка пропадает, данные — тоже, и через пять строк падает уже Cannot read properties of undefined (разбор этой ошибки). Сначала выясни, что пришло, и только потом решай, что с этим делать.
Проверяют только res.ok. Dev-сервер без прокси и страница входа после редиректа отвечают 200 с HTML. Проверяй ещё и content-type.
Относительный адрес без начального слэша. fetch('api/users') со страницы /shop/cart уходит на /shop/api/users, а оттуда приходит index.html. Пиши /api/users.
Чинят парсер, а не адрес. Код разбора почти никогда не виноват. Виноват адрес, прокси или ответ сервера, и видно это во вкладке Network.
Частые вопросы
Как перевести Unexpected token is not valid JSON
«Неожиданный символ, это не JSON». Символ в кавычках — первое, на чём споткнулся разбор: < — пришёл HTML, u из undefined — передали не строку, o из [object Object] — передали объект.
Что значит Unexpected end of JSON input
JSON оборвался, не начавшись: тело ответа пустое. Обычно это ответ 204 No Content или ошибка сервера без тела. Проверь статус и не вызывай res.json(), если разбирать нечего.
Почему запрос работает в Postman, а в браузере нет
Postman обращается прямо к бэкенду, а страница в браузере — к серверу, с которого она открыта. Если это dev-сервер без прокси, на /api/... он отдаёт index.html. Сравни полный адрес запроса в Network с тем, что стоит в Postman.