「作った」と「届いた」は違う
ここまでの作業記録を記事にまとめ、HTMLファイルとして書き出しました。CSSも当て、ブラウザで開けばきちんと読める状態になっていました。私はそこで「記事の作成は完了した」と考えていました。
しかし当然ながら、ファイルがローカルにあるだけでは誰も読めません。記事はブログに載って初めて記事です。成果物の形が合っていても、あるべき場所に届いていなければ完了ではない——今回いちばん効いた教訓はこれでした。
そこで「HTMLファイル4本を、WordPressの投稿として登録する」という作業が発生します。手作業でコピー&ペーストすれば済む話に見えますが、1本あたり1万文字を超えるHTMLです。しかも今回の環境では、その素朴な方法が使えませんでした。この記事は、その受け渡し経路を作るまでの記録です。
ブラウザの同一オリジンポリシー(CORS)、WordPressのREST APIとnonce、アップロード可能なファイル形式の判定、React(Next.js)で外部HTMLを表示するときのCSS設計、そして公開後の検証方法。どれも「WordPressとNext.jsをつなぐ」構成では避けて通れない話です。
通せる経路を探す
状況を整理します。記事のHTMLは手元の作業環境にあり、WordPressの管理画面はブラウザで開いています。この2つの間には直通の道がありません。
考えられる経路を順に試しました。
| 案 | やり方 | 結果 |
|---|---|---|
| 1 | GitHubのファイルURLをブラウザから直接読み込む | 失敗。リポジトリが非公開なうえ、別ドメインなのでCORSで弾かれる |
| 2 | 作業環境から直接WordPressのAPIを叩く | 失敗。作業環境からの外向き通信が制限されている |
| 3 | 記事本文をそのままスクリプトに書き込んで実行する | 理屈上は可能。ただし4本で約5万文字。引用符やバックスラッシュのエスケープを1文字でも間違えると壊れる |
| 4 | メディアライブラリにファイルをアップロードし、そのURLから読む | 最初は失敗。原因の切り分けに時間を使った(後述) |
最終的に採用したのは、この4案のどれでもない5番目の方法でした。ただしそこにたどり着くには、まず1番の失敗が何を意味しているかを理解する必要があります。
なぜブラウザは他所のデータを読ませないのか
案1で出たエラーは Failed to fetch でした。ファイルが無いのでも、通信ができないのでもありません。ブラウザが意図的に読み取りを止めています。
ブラウザには同一オリジンポリシーという原則があります。オリジンとは「プロトコル+ドメイン+ポート」の組のことで、https://cms.kisaku.site と https://github.com は別のオリジンです。原則として、あるオリジンのページで動くJavaScriptは、別のオリジンのデータを読めません。
なぜそんな制限があるのか。もし制限が無ければ、こういうことが起きます。
// 訪問者がネットバンクにログイン済みなら、
// Cookieが一緒に送られて「本人のリクエスト」として通ってしまう
const res = await fetch('https://bank.example.com/api/balance', {
credentials: 'include'
});
const data = await res.json(); // 残高が読めてしまう
send_to_attacker(data);
つまり同一オリジンポリシーは、「ログイン状態を勝手に借用して他所のデータを盗む」ことを防ぐための仕組みです。CORS(Cross-Origin Resource Sharing)は、その原則に対して「このオリジンからなら読ませてよい」とデータの提供側が明示的に許可を出すための仕組みです。許可を出すのは受け取る側ではなく渡す側だという点が重要で、だから今回のように相手がGitHubであれば、こちらの都合で許可を足すことはできません。
「CORSエラーが出た=サーバーが落ちている/URLが間違っている」と考えてしまいがちですが、違います。リクエスト自体はサーバーに届いていて、レスポンスも返ってきています。ブラウザがそれをJavaScriptに渡さないだけです。curl で同じURLを叩くと普通に取得できるのは、curlにはこの制限が無いからです。
ファイル選択欄を通り道にする
ここで発想を変えます。ブラウザが「別のオリジンから取ってくる」のを止めるなら、取ってこなければいい。利用者が自分の意思でファイルを選んで渡す経路なら、CORSは関係ありません。
ファイル選択欄(<input type="file">)がまさにそれです。ここには、セキュリティ上の重要な非対称性があります。
| 操作 | 可否 | 理由 |
|---|---|---|
| JavaScriptからファイルパスを設定する | できない | できてしまうと、サイト側が勝手に利用者のファイルを盗み出せる |
| 選ばれたファイルの中身をJavaScriptから読む | できる | 利用者が明示的に選んだファイルなので、同意があるとみなせる |
この「選ばれた後なら読める」を使います。ポイントは、ファイルをアップロードはしないことです。選択欄に入れるところまでで止め、中身だけをJavaScriptで読み出します。
// 「メディアを追加」ページのファイル選択欄
const input = document.getElementById('async-upload');
const file = input.files[0];
// アップロードはしない。中身だけ読む。
// File.text() はテキストとして読み出すメソッド(Promiseを返す)
const text = await file.text();
const posts = JSON.parse(text);
console.log(posts.length); // 4
console.log(posts[0].content.length); // 11344
記事4本をJSONの配列にまとめた1つのファイルを用意し、それを選択欄に入れて読み込みました。これで、5万文字のHTMLがブラウザのメモリ上に乗ります。CORSも通信も介在しません。
メディアライブラリに中継用ファイルが残りません。「サーバーに置いてから読む」方式だと、用が済んだあとのファイルが公開状態で残り続けます。今回のように一度きりの受け渡しでは、置かずに済ませるほうが後始末が要りません。
「権限がありません」の正体
この方法にたどり着く前に、案4(メディアにアップロードしてから読む)を試していました。そこで出たのがこのエラーです。
このファイルタイプをアップロードする権限がありません。
管理者としてログインしているのに「権限がありません」と言われる。素直に読むと「WordPressが .txt を受け付けない設定になっている」と解釈したくなります。しかし調べると、WordPressは標準で text/plain を許可しています。話が合いません。
そこで、同じことをREST API経由で試しました。小さなファイルを作って投げてみます。
const form = new FormData();
form.append('file', new Blob(['hello'], {type: 'text/plain'}), 'test.txt');
const res = await fetch('/wp-json/wp/v2/media', {
method: 'POST',
credentials: 'same-origin',
headers: {'X-WP-Nonce': nonce},
body: form
});
console.log(res.status); // 201 → 作成成功
201が返りました。.txt は問題なくアップロードできる。つまりエラーの原因はファイル形式ではありませんでした。管理画面のフォームを操作する過程で、フォームに埋め込まれていたnonceが本来の組み合わせから外れてしまい、WordPressが「この操作は正当な経路から来ていない」と判断していたのです。
nonceとは何か
nonceは “number used once” の略で、その操作が正規の画面から、本人の意思で行われたことを示す使い捨ての合言葉です。Cookieだけで認証していると、こういう攻撃が成立してしまいます。
<!-- 攻撃者のページ。訪問者がWordPressにログイン済みだと…… -->
<form action="https://example.com/wp/wp-admin/user-new.php" method="post">
<input name="user_login" value="attacker">
<input name="role" value="administrator">
</form>
<script>document.forms[0].submit();</script>
Cookieはドメインに対して自動で送られるので、リクエストは「ログイン中の管理者からの正当な要求」に見えてしまいます。nonceがあると、攻撃者はその値を知り得ないため、この形の攻撃は成立しません。
ややこしいのは、WordPressのnonceは用途ごとに別物だという点です。管理画面のフォームに埋まっているnonceと、REST APIが要求するnonceは別で、片方をもう片方に使うと弾かれます。
| 用途 | 取得方法 | 渡し方 |
|---|---|---|
| 管理画面のフォーム送信 | フォーム内の hidden 項目(_wpnonce) |
フォームの一部として送信 |
| REST API | wpApiSettings.nonce、または admin-ajax.php?action=rest-nonce |
X-WP-Nonce ヘッダ |
最初、私はページのHTMLから正規表現で10桁の16進数を拾ってnonceにしていました。当然これは別用途のnonceを掴むことがあり、rest_cookie_invalid_nonce という403が返ります。確実な取り方は次のとおりです。
// WordPressが専用に用意しているエンドポイント。
// 管理画面にログインしていれば、有効なnonceを文字列で返す。
const nonce = (await fetch('/wp/wp-admin/admin-ajax.php?action=rest-nonce', {
credentials: 'same-origin'
}).then(r => r.text())).trim();
エラーメッセージは「何が起きたか」を教えてくれますが、「なぜ起きたか」は教えてくれません。「権限がありません」を額面どおり受け取っていたら、許可形式を増やす設定変更に進んでいたはずです。実際の原因は認証の受け渡しでした。別の経路で同じことを試して結果が変わるなら、原因は自分が疑っていた場所には無い——これは今回いちばん役に立った切り分け方でした。
REST APIで下書きを作る
本文がブラウザ上に乗り、有効なnonceも手に入りました。あとは投稿を作るだけです。
const post = posts[0];
const res = await fetch('/wp-json/wp/v2/posts', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce
},
body: JSON.stringify({
title: post.title,
content: post.content,
status: 'draft', // いきなり公開しない
categories: [11] // 「技術系」カテゴリのID
})
});
const created = await res.json();
console.log(res.status, created.id); // 201 66
status を draft にしているのは意図的です。いきなり publish にすると、表示が崩れていた場合に崩れた状態が世に出ます。下書きなら、確認してから公開できます。取り返しのつく順番で進めるというだけの話ですが、効果は大きいです。
保存されたものを疑う
201が返ったから成功、とはしませんでした。WordPressには wp_kses_post() という仕組みがあり、投稿本文から危険なタグや属性を削り落とすことがあります。今回の記事は class 属性に強く依存しているので、これが削られると装飾が全部消えます。
削られるかどうかは、投稿するユーザーが unfiltered_html という権限を持つかで決まります。単一サイトの管理者は持っていますが、それは「持っているはず」であって「確認した」ではありません。そこで、保存されたものを読み戻して元と突き合わせました。
// context=edit を付けると、加工前の生データ(content.raw)が返る
const res = await fetch('/wp-json/wp/v2/posts/66?context=edit', {
credentials: 'same-origin',
headers: {'X-WP-Nonce': nonce}
});
const saved = await res.json();
console.log(saved.content.raw === post.content); // true
console.log((saved.content.raw.match(/<pre/g) || []).length); // 12
console.log(saved.status, saved.categories); // draft [11]
context=edit を付けるのがポイントです。これを付けないと表示用に整形された content.rendered しか返らず、元と比べても差が出てしまいます。生データ同士で比較して初めて「1文字も失われていない」と言えます。
スラッグを先に整える
公開する前に、URLの末尾になるスラッグを英語に変えました。日本語タイトルのまま公開すると、スラッグも日本語になり、URLがパーセントエンコードされて非常に長くなります。
// 日本語スラッグの実際の姿
/blog/%E3%81%AA%E3%81%9C%E3%81%93%E3%81%AE%E6%A7%8B%E6%88%90...
// 変更後
/blog/headless-wordpress-nextjs-architecture
これを公開前にやるのには理由があります。公開後にスラッグを変えると、それまでのURLが404になります。SNSで共有されていれば、そのリンクは死にます。URLは公開した瞬間に「他人が持つ資産」になるので、後から変えられる前提で設計してはいけません。
CSSが当たらない
記事を1本公開して、フロント(Next.js側)で見てみると、文章は出るものの装飾が一切効いていませんでした。コードブロックは白背景のただの文字、補足ボックスも普通の段落です。
原因は2つ重なっていました。
1. className は流し込んだHTMLの中には届かない
WordPressの本文は、React側では次のように表示しています。
<div
className="wp-content mt-8 rounded-2xl bg-white p-6"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
dangerouslySetInnerHTML は「このHTMLを解釈せずそのまま入れる」という指定です。中身はReactのコンポーネントではないので、Reactが管理する仕組み(className を渡す、コンポーネントを差し込む)は一切届きません。外側の div にどれだけクラスを足しても、内側の h2 や pre には影響しません。
したがって、CSS側で「この箱の中の要素」として受けるしかありません。
2. Tailwindが標準のスタイルを消している
Tailwind CSSにはpreflightという機能があり、読み込むと h2 の文字サイズや p の余白といったブラウザ標準のスタイルを全部リセットします。ブラウザ差をなくすための仕組みですが、外部から流し込んだHTMLには致命的で、見出しと本文が同じ見た目になります。
この2つをまとめて解決する形が、こうなります。
.wp-content {
/* 色は変数にまとめる。調整を1か所で済ませるため */
--code-bg: #1c2128;
--line: #e3e6ea;
line-height: 2; /* 日本語は行間を広く取ると格段に読みやすい */
font-size: 17px;
}
/* 装飾ではなく「上の余白」で階層を出す。
線や色を足すより、空白のほうが読み手には強く効く */
.wp-content h2 {
font-size: 1.5rem;
font-weight: 700;
margin: 4.5rem 0 1.5rem;
scroll-margin-top: 1.25rem; /* 目次から飛んだとき上端に貼り付かない */
}
/* コードは濃色にして「ここは読み物ではない」と視覚的に伝える */
.wp-content pre {
background: var(--code-bg);
color: #dfe3e8;
border-radius: 8px;
padding: 1.1rem 1.25rem;
overflow-x: auto; /* 折り返さない。折り返すとコードの構造が壊れる */
}
/* スマホで表がはみ出すのを防ぐ。
表そのものではなくラッパーをスクロールさせるのがポイント */
.wp-content .table-wrap { overflow-x: auto; }
Tailwindには @tailwindcss/typography という、まさにこの用途のプラグインがあります。導入すれば見出しや段落は一発で整います。今回それを使わなかったのは、この記事群が .note や .code-block といった独自のclassを使っており、そこは結局自分で書く必要があるためです。半分プラグイン・半分自前だと、どちらに何が書いてあるか分からなくなります。既製品を入れるかどうかは「どこまで賄えるか」で決めるのが良さそうです。
公開後の確認項目
公開したあと、次の項目を確認しました。「見た目が合っていそう」で終わらせないための一覧です。
| 確認すること | 方法 | なぜ見るのか |
|---|---|---|
| 一覧ページに出ているか | ブログ一覧のリンクにスラッグが含まれるか | 個別ページが正しくても、一覧に出なければ誰にも見つからない |
| 個別ページが200を返すか | fetch(url) のステータス |
404や500でないこと。スラッグの設定ミスはここに出る |
| 本文が入っているか | HTML内に wp-content の箱があるか |
取得に失敗しても、ページ自体は200で返ることがある |
| HTMLがエスケープされていないか | <h2> のような文字列が本文に出ていないか |
どこかでエスケープが挟まると、記事全体がタグの羅列になる |
| CSSが実際に効いているか | getComputedStyle() で計算後の値を見る |
目視だと「効いているように見える」で通してしまう |
const box = document.querySelector('.wp-content');
const pre = box.querySelector('pre');
console.log(getComputedStyle(pre).backgroundColor);
// "rgb(28, 33, 40)" ← 指定した --code-bg と一致
console.log(getComputedStyle(box).lineHeight);
// "34px" ← 17px × 2.0。行間の指定も効いている
getComputedStyle() は、CSSの継承や優先順位をすべて解決したあとの最終的な値を返します。「スタイルシートには書いたが、より強い指定に負けていた」というよくある失敗が、これを見れば一発で分かります。目で見て確認すると、うっすら効いている状態を「効いている」と判断してしまいがちです。
おまけ:rebaseが止まるとき
CSSをコミットしようとしたとき、以前と同じところで詰まりました。
error: cannot pull with rebase: You have unstaged changes.
rebaseは「自分のコミットを、取り込んだ最新の状態の上に載せ直す」操作です。作業中の未コミットの変更が残っていると、載せ直す途中でそれが失われる危険があるため、Gitは先に止まります。
毎回 git stash と git stash pop を手で打っていたのですが、これを自動でやるオプションがあります。
git pull --rebase --autostash
# 内部でやっていること:
# 1. 未コミットの変更を一時退避(stash)
# 2. rebase を実行
# 3. 退避したものを戻す(stash pop)
今回のように「関係ない別ファイルの変更が手元に残っている」場面では、これで止まらずに済みます。
今回の学び
技術的な内容より先に、いちばん大きかったのは冒頭の話です。ファイルを作ったことと、それが読める場所にあることは別。今回は「記事を書く」という依頼に対して、HTMLファイルを渡した時点で完了だと考えてしまっていました。依頼した側から見れば、ブログに載っていなければ何も起きていないのと同じです。
技術面では、次の3つが残りました。
ひとつ目は、制約は迂回するのではなく、その意味を理解してから別の道を探すということ。CORSに阻まれたとき、抜け道を探すのではなく「なぜこの制限があるのか」を理解したことで、ファイル選択欄という筋の通った経路にたどり着けました。制限の理由が分かれば、その理由に反しない方法も見えてきます。
ふたつ目は、エラーメッセージは症状であって原因ではないということ。「権限がありません」を額面どおり受け取っていたら、許可設定をいじる方向に進んでいました。別の経路で同じ操作を試し、結果が変わったことで、原因が別の場所にあると分かりました。
みっつ目は、成功したという返事を信じないということ。201が返っても、中身が削られていないかは別問題です。保存されたものを読み戻して元と比較する。CSSは目で見ずに計算値で確認する。ひと手間ですが、この確認があるから「できました」と言い切れます。

