JavaScriptでAPIを呼び出したとき、次のようなエラーが表示されることがあります。
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSONJSONの書き方を見直しても直らない場合、問題はJSONの中身ではなく、JSONを期待した処理へHTMLが返っていることかもしれません。
この記事では、Chrome DevToolsの「Network」パネルを使い、ステータスコード、Content-Type、Responseの順に原因を切り分けます。404や500だけでなく、HTTP通信は成功しているように見える「200+HTML」や、認証切れでログイン画面へ移動したケースも扱います。
先に要点をまとめると、response.json()を直す前に、実際に返ってきたレスポンスを確認するのが近道です。
Unexpected token ‘<‘ の原因を先に確認
Unexpected token '<'は、JavaScriptがJSONとして解釈できない位置で「<」を見つけた、という意味です。
JSONは、一般的にオブジェクトなら「{」、配列なら「[」から始まります。一方、HTMLは「<!DOCTYPE html>」や「<html>」から始まります。
期待していたJSON
{
"status": "ok",
"data": []
}実際に返ったHTML
<!DOCTYPE html>
<html>
<body>Not Found</body>
</html>JSONを期待した処理へHTMLが返り、エラーになるまでの流れを図にすると次のようになります。

エラーが表示されているのはresponse.json()の実行時ですが、原因はその前に返されたHTMLです。JSONの構文だけを見直すのではなく、まず実際のレスポンスを確認します。
fetchは404や500でも自動的に失敗扱いにならない
fetch()は、404 Not Foundや500 Internal Server Errorを受け取っても、通常はPromiseをrejectしません。ネットワークエラーなどとは違い、サーバーからレスポンスを受け取れているためです。
そのため、try...catchで囲むだけでは不十分です。response.okまたはresponse.statusを確認してから、本文を解析する必要があります。
まずStatus・Content-Type・Responseを確認する
原因を探すときは、コードを片っ端から書き換えるより、Chrome DevToolsで次の3項目を確認します。
- Status:200、404、500などのHTTPステータスコード
- Content-Type:
application/jsonかtext/htmlか - Response:実際に返ってきた本文
| 確認結果 | 考えられる原因 | 主に直す場所 |
|---|---|---|
| 404+HTML | URLやルーティングの誤り | 呼び出し先・APIルート |
| 500+HTML | サーバー内の例外 | バックエンド・サーバーログ |
| 200+HTMLページ | APIではないURLを呼んでいる | URL・プロキシ設定 |
| 200+ログイン画面 | 認証切れやリダイレクト | 認証処理・Cookie |
| 403+ブロック画面 | WAFやCDNによる遮断 | WAF・サーバー設定 |
| JSONの前にWarning | PHPなどの余計な出力 | バックエンド・ログ設定 |
| application/jsonでも解析失敗 | 本文が不正なJSON | JSONの生成処理 |
Statusが200でも正常とは限りません。HTMLページやログイン画面が200で返っている場合は、response.okがtrueでもJSON解析に失敗します。
Chrome DevToolsで原因を切り分ける手順
Networkタブでは、対象リクエストを選び、Status、Content-Type、Responseの順に確認します。Responseの内容が分かれば、次に調べる場所を絞り込めます。

404ページならURLやルーティング、ログイン画面なら認証、500ページならサーバーログを確認します。ここからは、各項目を実際の画面で確認していきます。
1.再現ページを開いてNetworkを選ぶ
まず、この記事で後述するサンプルコードを使って用意した再現ページをブラウザで開きます。実在するWebサイトからエラーが起きているページを探す必要はありません。
WindowsではF12キー、またはページ上で右クリックして「検証」を選び、DevToolsを開きます。「Network」を選択した状態で再現ページを再読み込みすると、ページ内で発生した通信を確認できます。

2.Fetch/XHRで対象リクエストを絞り込む
ページを再読み込みするか、エラーが出る操作をもう一度行います。通信が多い場合は「Fetch/XHR」を選び、API通信に絞り込みます。

3.HeadersでURLとStatusを確認する
対象リクエストを選び、「Headers」のGeneralにあるRequest URLとStatus Codeを確認します。相対URLを使っている場合は、想定とは別のホストやポートへ送信されていないかも確認します。

4.Responseで返却内容を確認する
「Response」を選びます。先頭が<!DOCTYPE html>や<html>なら、JSONではなくHTMLが返っています。
HTML内のタイトルや本文も手掛かりになります。「404 Not Found」ならURL、「Sign in」やログインフォームなら認証、「Internal Server Error」ならサーバー側を調べます。

5.Content-Typeと最終URLを確認する
Response HeadersのContent-Typeも確認します。text/htmlなら、サーバー自身がHTMLとして返していることが分かります。

ただし、Content-Typeがapplication/jsonでも本文が必ず正しいJSONとは限りません。ヘッダーとResponseの両方を確認します。
認証が絡む場合は、リクエストしたURLだけでなく、最終的に表示されたURLやリダイレクト履歴も確認します。ログイン画面へ移動したあとに200が返ると、Statusだけでは異常に気づきにくいためです。
再現用のローカル環境を準備する
ボンボンここから先は、実際にエラーを再現して確かめたい人向けだよ。原因と対処法だけ知りたい場合は、読み飛ばしても大丈夫!興味があれば、ローカル環境で試してみてね。
5つのケースを同じ条件で比較できるよう、Node.jsの標準機能だけで動く再現用サーバーを用意します。追加パッケージのインストールは不要です。
このコードは学習・検証用です。外部公開せず、自分のパソコンのローカル環境で実行してください。
1.Node.jsを準備する
Node.jsがインストールされていることを確認します。ターミナルまたはPowerShellで次のコマンドを実行し、バージョン番号が表示されれば準備できています。
node --version2.server.jsを作成する
作業用の新しいフォルダーを作り、その中へserver.jsという名前で次のコードを保存します。
const http = require("http");
const port = 3000;
const page = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Unexpected token 再現</title>
</head>
<body>
<h1>Unexpected token "<" 再現</h1>
<button data-url="/api/ok">正常なJSON</button>
<button data-url="/html-200">200 + HTML</button>
<button data-url="/not-found">404 + HTML</button>
<button data-url="/error">500 + HTML</button>
<button data-url="/private">302 → ログイン画面</button>
<button data-url="/mixed">警告 + JSON</button>
<pre id="result">ボタンを押してください</pre>
<script>
const result = document.querySelector("#result");
document.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", async () => {
const url = button.dataset.url;
result.textContent = url + " を呼び出しています";
try {
const response = await fetch(url);
const data = await response.json();
result.textContent =
"status: " + response.status + "\\n" +
JSON.stringify(data, null, 2);
} catch (error) {
result.textContent = String(error);
console.error(error);
}
});
});
</script>
</body>
</html>`;
const server = http.createServer((request, response) => {
const url = new URL(request.url, `http://${request.headers.host}`);
if (url.pathname === "/") {
response.writeHead(200, {
"Content-Type": "text/html; charset=utf-8"
});
response.end(page);
return;
}
if (url.pathname === "/api/ok") {
response.writeHead(200, {
"Content-Type": "application/json; charset=utf-8"
});
response.end(JSON.stringify({
status: "ok",
message: "正常なJSONです"
}));
return;
}
if (url.pathname === "/html-200") {
response.writeHead(200, {
"Content-Type": "text/html; charset=utf-8"
});
response.end(
"<!DOCTYPE html><html><body><h1>通常のHTMLページ</h1></body></html>"
);
return;
}
if (url.pathname === "/not-found") {
response.writeHead(404, {
"Content-Type": "text/html; charset=utf-8"
});
response.end(
"<!DOCTYPE html><html><body><h1>404 Not Found</h1></body></html>"
);
return;
}
if (url.pathname === "/error") {
response.writeHead(500, {
"Content-Type": "text/html; charset=utf-8"
});
response.end(
"<!DOCTYPE html><html><body><h1>500 Internal Server Error</h1></body></html>"
);
return;
}
if (url.pathname === "/private") {
response.writeHead(302, {
Location: "/login"
});
response.end();
return;
}
if (url.pathname === "/login") {
response.writeHead(200, {
"Content-Type": "text/html; charset=utf-8"
});
response.end(
"<!DOCTYPE html><html><body><h1>ログイン</h1><form><input name='user'></form></body></html>"
);
return;
}
if (url.pathname === "/mixed") {
response.writeHead(200, {
"Content-Type": "text/html; charset=utf-8"
});
response.end(
"<br><b>Warning</b>: Undefined variable sample\n" +
JSON.stringify({ status: "ok" })
);
return;
}
response.writeHead(404, {
"Content-Type": "text/html; charset=utf-8"
});
response.end("<h1>404 Not Found</h1>");
});
server.listen(port, () => {
console.log(`http://localhost:${port} を開いてください`);
});1つのファイルに、確認用ページと6つのエンドポイントをまとめています。
| URL | 再現内容 | 想定Status |
|---|---|---|
/api/ok | 正常なJSON | 200 |
/html-200 | HTMLを正常レスポンスとして返す | 200 |
/not-found | HTMLの404ページ | 404 |
/error | HTMLの500ページ | 500 |
/private | ログイン画面へリダイレクト | 302→200 |
/mixed | 警告文の後ろへJSONを出力 | 200 |
3.再現用サーバーを起動する
server.jsを保存したフォルダーで、次のコマンドを実行します。
node server.js「http://localhost:3000を開いてください」と表示されたら、ブラウザーでそのURLを開きます。各ボタンを押すと、正常なJSONまたは目的のエラーを再現できます。


4.サーバーを停止する
検証が終わったら、サーバーを実行しているターミナルでCtrl+Cを押して停止します。
実際のPHP Warningを再現する場合
上の/mixedは、Warningに似たHTMLがJSONの前へ混ざる状態をNode.jsで再現しています。PHPそのもののWarningではありません。
PHPが使えるローカル環境では、次のwarning.phpでも確認できます。PHPのバージョンや設定により、警告の表示形式は異なります。
<?php
ini_set('display_errors', '1');
error_reporting(E_ALL);
echo $undefined_variable;
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['status' => 'ok']);これは意図的に未定義変数を参照する検証コードです。本番サイトへ置かず、ローカル環境だけで使用します。
Unexpected token ‘<‘ をケース別に再現
今回再現する5ケースは、同じエラーが表示されても、返ってきた内容と直す場所が異なります。


まずステータスコードとResponseを確認し、URL、認証、バックエンドなど、調査対象を絞り込みます。ここから、それぞれの挙動を順番に見ていきます。検証時には、OS、ブラウザーバージョン、ローカル環境、検証日も記録します。
ケース1:200なのにHTMLが返る
APIではなく、通常のHTMLページをfetch()し、response.json()を実行するケースです。
async function loadData() {
const response = await fetch("/html-200");
const data = await response.json();
console.log(data);
}
loadData();このケースで確認したいのは、HTTP通信が成功してresponse.okがtrueでも、本文がHTMLならJSON解析に失敗する点です。






実測値:
Status[200]
Content-Type[text/html; charset=utf-8]
Responseの冒頭[<!DOCTYPE html><html><body><h1>通常のHTMLページ]
ケース2:URLを間違えて404ページが返る
存在しないAPIルートを指定すると、Webサーバーやフレームワークの404ページがHTMLで返ることがあります。
const response = await fetch("/not-found");
const data = await response.json();URLのスペルだけでなく、相対URL、末尾のスラッシュ、APIのベースURL、開発サーバーのポートも確認します。




実測値:
Status[404]
Content-Type[text/html; charset=utf-8]
Responseの冒頭[<!DOCTYPE html><html><body><h1>404 Not Found]
ケース3:サーバーエラーで500ページが返る
再現ページの「500+HTML」ボタンは、/errorへアクセスします。API内部で例外が起き、Webサーバーやフレームワークのエラーページが返るケースです。この場合、フロント側でJSONの解析方法を変えても根本解決にはなりません。
Responseで500エラーページを確認したら、バックエンドの例外処理とサーバーログを確認します。本番環境では、エラーの詳細をレスポンスへそのまま表示しない設定も必要です。




実測値:
Status[500]
Content-Type[text/html; charset=utf-8]
Responseの冒頭[<!DOCTYPE html><html><body><h1>500 Internal Server Error]
ケース4:認証切れでログイン画面が返る
再現ページの「302→ログイン画面」ボタンは、/privateから/loginへリダイレクトします。実際のシステムでも、認証が必要なAPIへ未ログイン状態でアクセスしたとき、JSON形式の401エラーではなく、ログイン画面へ移動する場合があります。
ブラウザーがリダイレクトをたどった結果、最終的には200のログイン画面が返る場合があります。このときは、Request URL、最終URL、Responseのログインフォームをセットで確認します。






実測値:
Status[302 → 200]
Content-Type[text/html; charset=utf-8]
Responseの冒頭[<!DOCTYPE html><html><body><h1>ログイン]
ケース5:警告表示がJSONの前に混ざる
再現ページの「警告+JSON」ボタンは、/mixedから警告風のHTMLとJSONを続けて返します。実際のPHPでも、WarningやNoticeが画面出力される設定では、正しいJSONの前へHTML形式の警告が混ざることがあります。
<br>
<b>Warning</b>: Undefined variable ...
{"status":"ok"}JSONらしい部分が含まれていても、レスポンス全体としては正しいJSONではありません。警告を隠すだけで終わらせず、原因を修正したうえで、運用環境では画面ではなくログへ記録します。




実測値:
Status[200]
Content-Type[text/html; charset=utf-8]
Responseの冒頭[<br><b>Warning</b>: Undefined variable sample]
原因ごとの対処法
404ならURLとルーティングを確認する
- Request URLが想定どおりか
- APIのホスト名とポート番号が正しいか
- 相対URLの基準となるページが想定どおりか
- ルート名、パスパラメーター、末尾のスラッシュが正しいか
- 開発用プロキシがAPIサーバーへ転送しているか
500ならサーバーログを確認する
500はサーバー内部で処理に失敗したことを示します。Responseに詳しい理由が出ていない場合もあるため、アプリケーションログ、Webサーバーのログ、直前に変更した処理を確認します。
ログイン画面なら認証情報とリダイレクトを確認する
- セッションやアクセストークンの有効期限
- CookieがAPIリクエストへ送信されているか
- Authorizationヘッダーが必要か
- API向けの401/403ではなく画面向けログイン処理へ流れていないか
WAFやCDNの画面ならブロック条件を確認する
403とともにCloudflareなどのブロック画面が返る場合は、JavaScript側ではなくWAFやCDNのルールを確認します。IP制限、Bot判定、リクエストメソッド、送信内容などが条件になることがあります。
Content-Typeだけをapplication/jsonに変えても直らない
レスポンスヘッダーをapplication/jsonに変えても、本文がHTMLのままならJSON解析は失敗します。ヘッダーだけでなく、すべての処理経路で本文を正しいJSONとして返す必要があります。
fetchで安全にJSONを処理する実装例
response.okとContent-Typeを確認してからJSONを読み込むと、元のSyntaxErrorより原因が分かりやすいエラーを出せます。
async function fetchJson(url, options) {
const response = await fetch(url, options);
const contentType =
response.headers.get("content-type") ?? "";
if (!response.ok) {
const body = await response.text();
throw new Error(
`HTTP ${response.status}: ${body.slice(0, 200)}`
);
}
if (!contentType.includes("application/json")) {
const body = await response.text();
throw new Error(
`JSONではないレスポンスです: ${contentType}\n` +
body.slice(0, 200)
);
}
return response.json();
}ただし、response.okだけでは200+HTMLを防げません。Content-Typeが正しくても本文が壊れている可能性もあります。最終的にはサーバー側が、成功時と失敗時の両方で一貫したJSONを返す設計が必要です。
調査中はresponse.text()で生の本文を確認する
原因調査中は、一時的にresponse.json()をresponse.text()へ変えると、返ってきた本文をそのまま確認できます。
const response = await fetch("/api/users");
console.log("status:", response.status);
console.log(
"content-type:",
response.headers.get("content-type")
);
const text = await response.text();
console.log(text.slice(0, 200));レスポンス本文は通常、一度読み込むと再度読み込めません。response.text()を実行したあと、同じResponseへresponse.json()を続けて実行しないようにします。テキストとして取得した内容を解析する場合は、確認後にJSON.parse(text)を使います。
WordPressで発生した場合の確認ポイント
WordPressのREST APIや、管理画面からAPI通信を行うプラグインでも、HTMLが混ざると同じエラーが発生します。
- REST APIのURLを直接開き、JSONが返るか確認する
- プラグインやテーマがレスポンスの前へHTMLを出力していないか確認する
- PHPエラーを画面へ表示せず、
debug.logで確認する - WAFやセキュリティープラグインがHTMLのブロック画面を返していないか確認する
- NetworkのResponseで、ログイン画面やサーバーのエラーページへ流れていないか確認する
なお、WordPress REST APIの認証エラーはJSON形式で返る場合もあります。認証エラーであることだけを理由に、このエラーが発生するとは限りません。実際のResponseがHTMLかどうかを確認します。
よく似ているが別のエラー
Unexpected end of JSON input
空のレスポンスや、途中で切れたJSONを解析したときに発生します。204 No Contentのレスポンスへresponse.json()を実行していないかも確認します。
Unexpected token ‘u’
undefinedをJSONとして解析している可能性があります。今回の「HTMLを受け取ったケース」とは確認箇所が異なります。
CORSエラー
CORSは、異なるOrigin間のレスポンスをブラウザーが読み取れるかどうかに関する仕組みです。ConsoleやNetworkにCORSエラーが表示されている場合、HTMLをJSONとして解析した問題とは分けて調べます。
よくある質問
Unexpected token ‘<‘ at position 0との違いは?
基本的な原因は同じです。「at position 0」は、JSONパーサーが本文の先頭で予期しない「<」を見つけたことを示します。ブラウザーや実行環境によってエラーメッセージの表現が異なります。
axiosでも発生しますか?
発生する可能性があります。ただし、axiosのバージョンや設定、レスポンス変換処理によって表示されるエラーは異なります。まずerror.response.status、レスポンスヘッダー、error.response.dataを確認します。
Postmanでは成功するのにブラウザーでは失敗するのはなぜ?
ブラウザーとPostmanでは、Cookie、Authorizationヘッダー、Origin、プロキシ、リダイレクトの扱いが同じとは限りません。両方のRequest URL、ヘッダー、Responseを比較します。
try…catchを追加すれば解決しますか?
解決はしません。例外を捕捉して画面が停止するのを防ぐことはできますが、HTMLが返った原因は残ります。Status、Content-Type、Responseを確認し、URL、認証、サーバー処理などの根本原因を修正します。
まとめ
Unexpected token '<'が表示されたら、JSONの記述だけを見るのではなく、サーバーから何が返ったかを確認します。
- NetworkでStatus、Content-Type、Responseを確認する
- 200でもHTMLやログイン画面が返るケースに注意する
- 404はURL、500はサーバーログ、ログイン画面は認証を確認する
response.okとContent-Typeを確認してからJSONを解析する
最初にResponseを見る習慣をつけると、「JSONのエラー」に見えていたものが、URL、認証、サーバーのどの問題なのかを早く切り分けられます。キャプチャ撮影後は、各ケースの実測値を比較表へ反映して仕上げます。


コメント