Unexpected token '<', "<!DOCTYPE "... is not valid JSON — как исправить

опубликовано
читать
≈ 8 мин
автор
среда и версия
Node.js 22, Vite 6.4, Chromium 148, Firefox 150, Bun 1.4 (движок Safari); Python 3.11 и requests 2.28
содержание решать задачи Современный JavaScript

Сообщение переводится так: «неожиданный символ <, строка "<!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.

JavaScript / 01

Что получил res.json() на самом деле

Что получил res.json() на самом делеДве дорожки одного запроса fetch('/api/users') со страницы на dev-сервере Vite с портом 5173. Первая, без proxy: Vite не знает путь /api/users и отдаёт index.html с кодом 200 и типом text/html, res.json() видит строку <!doctype html> и падает с ошибкой Unexpected token '<'. Вторая, с настройкой proxy /api на порт 3000: Vite передаёт запрос бэкенду, тот отвечает application/json, и res.json() возвращает массив пользователей.запрос со страницыdev-серверчто дошло до res.json()fetch('/api/users')01 без proxyVite :5173пути нет → index.html200 · text/htmlUnexpected token '<'fetch('/api/users')02 proxy /api → :3000Vite :5173передаёт бэкенду :3000200 · application/json[{ id: 1, name: 'Анна' }]
Без прокси dev-сервер не знает путь /api/users и отдаёт свой index.html с кодом 200 — res.json() получает HTML. С прокси тот же запрос уходит на бэкенд и возвращает 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.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru