Unexpected token ‘<' の原因と対処法|HTMLが返る問題をNetworkタブで切り分ける

JavaScriptでJSONを期待した処理にHTMLが返り、Unexpected tokenエラーになる様子

JavaScriptでAPIを呼び出したとき、次のようなエラーが表示されることがあります。

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

JSONの書き方を見直しても直らない場合、問題は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が返り、エラーになるまでの流れを図にすると次のようになります。

JSONを期待したJavaScriptにHTMLが返り、response.json()でUnexpected tokenエラーになる流れ

エラーが表示されているのは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-Typeapplication/jsontext/html
  • Response:実際に返ってきた本文
確認結果考えられる原因主に直す場所
404+HTMLURLやルーティングの誤り呼び出し先・APIルート
500+HTMLサーバー内の例外バックエンド・サーバーログ
200+HTMLページAPIではないURLを呼んでいるURL・プロキシ設定
200+ログイン画面認証切れやリダイレクト認証処理・Cookie
403+ブロック画面WAFやCDNによる遮断WAF・サーバー設定
JSONの前にWarningPHPなどの余計な出力バックエンド・ログ設定
application/jsonでも解析失敗本文が不正なJSONJSONの生成処理

Statusが200でも正常とは限りません。HTMLページやログイン画面が200で返っている場合は、response.oktrueでもJSON解析に失敗します。

Chrome DevToolsで原因を切り分ける手順

Networkタブでは、対象リクエストを選び、Status、Content-Type、Responseの順に確認します。Responseの内容が分かれば、次に調べる場所を絞り込めます。

NetworkタブでStatus、Content-Type、Responseを確認し、返却内容から原因を切り分ける流れ

404ページならURLやルーティング、ログイン画面なら認証、500ページならサーバーログを確認します。ここからは、各項目を実際の画面で確認していきます。

1.再現ページを開いてNetworkを選ぶ

まず、この記事で後述するサンプルコードを使って用意した再現ページをブラウザで開きます。実在するWebサイトからエラーが起きているページを探す必要はありません。

WindowsではF12キー、またはページ上で右クリックして「検証」を選び、DevToolsを開きます。「Network」を選択した状態で再現ページを再読み込みすると、ページ内で発生した通信を確認できます。

Chrome DevToolsでNetworkパネルを開いた画面

2.Fetch/XHRで対象リクエストを絞り込む

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

NetworkパネルをFetch/XHRに絞り込み、APIリクエストを表示した画面

3.HeadersでURLとStatusを確認する

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

Request URLとStatus Codeを確認する画面

4.Responseで返却内容を確認する

「Response」を選びます。先頭が<!DOCTYPE html><html>なら、JSONではなくHTMLが返っています。

HTML内のタイトルや本文も手掛かりになります。「404 Not Found」ならURL、「Sign in」やログインフォームなら認証、「Internal Server Error」ならサーバー側を調べます。

APIリクエストのHTMLレスポンスを確認する画面

5.Content-Typeと最終URLを確認する

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

Content-Typeの確認画面

ただし、Content-Typeがapplication/jsonでも本文が必ず正しいJSONとは限りません。ヘッダーとResponseの両方を確認します。

認証が絡む場合は、リクエストしたURLだけでなく、最終的に表示されたURLやリダイレクト履歴も確認します。ログイン画面へ移動したあとに200が返ると、Statusだけでは異常に気づきにくいためです。

再現用のローカル環境を準備する

ボンボン

ここから先は、実際にエラーを再現して確かめたい人向けだよ。原因と対処法だけ知りたい場合は、読み飛ばしても大丈夫!興味があれば、ローカル環境で試してみてね。

5つのケースを同じ条件で比較できるよう、Node.jsの標準機能だけで動く再現用サーバーを用意します。追加パッケージのインストールは不要です。

このコードは学習・検証用です。外部公開せず、自分のパソコンのローカル環境で実行してください。

1.Node.jsを準備する

Node.jsがインストールされていることを確認します。ターミナルまたはPowerShellで次のコマンドを実行し、バージョン番号が表示されれば準備できています。

node --version

2.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正常なJSON200
/html-200HTMLを正常レスポンスとして返す200
/not-foundHTMLの404ページ404
/errorHTMLの500ページ500
/privateログイン画面へリダイレクト302→200
/mixed警告文の後ろへJSONを出力200

3.再現用サーバーを起動する

server.jsを保存したフォルダーで、次のコマンドを実行します。

node server.js

http://localhost:3000を開いてください」と表示されたら、ブラウザーでそのURLを開きます。各ボタンを押すと、正常なJSONまたは目的のエラーを再現できます。

Unexpected tokenエラーをケース別に再現するローカルページ

4.サーバーを停止する

検証が終わったら、サーバーを実行しているターミナルでCtrlCを押して停止します。

実際の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ケースは、同じエラーが表示されても、返ってきた内容と直す場所が異なります。

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.oktrueでも、本文がHTMLならJSON解析に失敗する点です。

ステータス200のHTMLをJSONとして解析してエラーになった結果(Console)
ステータス200のHTMLをJSONとして解析してエラーになった結果(Headers)
ステータス200のHTMLをJSONとして解析してエラーになった結果(Response)

実測値:
 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、開発サーバーのポートも確認します。

存在しないAPIのURLへアクセスして404のHTMLが返った結果(Headers)
存在しないAPIのURLへアクセスして404のHTMLが返った結果(Response)

実測値:
 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エラーページを確認したら、バックエンドの例外処理とサーバーログを確認します。本番環境では、エラーの詳細をレスポンスへそのまま表示しない設定も必要です。

API内部のエラーによって500のHTMLが返った結果(Headers)
API内部のエラーによって500のHTMLが返った結果(Response)

実測値:
 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のログインフォームをセットで確認します。

認証切れによってAPIからログイン画面へ移動した結果(private-Headers)
認証切れによってAPIからログイン画面へ移動した結果(login-Headers)
認証切れによってAPIからログイン画面へ移動した結果(login-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ではありません。警告を隠すだけで終わらせず、原因を修正したうえで、運用環境では画面ではなくログへ記録します。

警告風のHTMLがJSONの前に出力された状態(Headers)
警告風のHTMLがJSONの前に出力された状態(Response)

実測値:
 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、認証、サーバーのどの問題なのかを早く切り分けられます。キャプチャ撮影後は、各ケースの実測値を比較表へ反映して仕上げます。

参考リンク

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

ちゃあむのアバター ちゃあむ エンジニア

Web開発やSaaS(ServiceNow、Salesforce)、業務システムに携わる、猫と食べることが大好きなインドア系ITエンジニアです。

コメント

コメントする


目次