タグ: Vercel

  • 設定を足しただけでは、変わらなかった

    設定を足しただけでは、変わらなかった

    問い合わせフォームの送信先を設定して、保存しました。画面を開き直しても、フォームは「準備中」のままでした。

    設定は間違っていません。読まれるタイミングが違っただけでした。

    この記事で扱うこと

    問い合わせの受け取りを外部サービスに任せるまでの判断と、その設定でつまずいた3点です。環境変数がいつ読まれるか、保存したら見られなくなる値のこと、そして「正しく弾けているか」の確かめ方を書きます。

    なぜ自分でメールを送らないのか

    このサイトには、メールを送る手段がありません。足すこと自体はできますが、そこから先が長い。

    迷惑メール扱いされないためには、そのドメインから送ってよいという宣言をDNSに置く必要があります。SPFとDKIMと呼ばれるものです。

    SPFは1ドメインに1本しか置けない

    すでに何かの設定が入っている状態で足すと、書き方を誤った瞬間にメールが届かなくなります。

    しかも壊れたことは、こちらからは分かりません。誰かに「返信が来ない」と言われるまで、正常に見え続けます。

    問い合わせを1本受け取るために、この重さを引き受けるのは割に合わないと判断しました。受け取りだけ外に出すことにします。

    求めているのは、転送先1つだけ

    ボット対策も、エラーの出し分けも、入力の確認も、すべてサイト側に置いてあります。外のサービスに任せているのは「受け取って、メールで転送する」という一点です。

    この形にしておくと、乗り換えの費用が小さくなります。別のサービスに移すときに変えるのは送信先のURL1つで、コードには触りません。

    最初から小さく繋いでおく

    外部サービスの機能を使い込むほど、そのサービスの形に合わせた作りになります。便利ですが、離れにくくもなります。

    「何を任せないか」を先に決めておくほうが、あとから選び直せます。

    つまずき1:環境変数は、ビルドのときに読まれる

    送信先のURLは、コードに書かずに環境変数として置いています。保存して、サイトを開き直しました。変わりません。

    思っていたこと 実際
    設定を保存すれば、次のアクセスから反映される 作り直すまで、前の状態のまま

    このサイトはページを先に作ってしまう構成なので、環境変数を読むのはページを作るときです。あとから設定を足しても、すでに作られたページは古い状態を抱えたままになります。

    配信サービスの画面にも「変更を反映するには新しいデプロイが必要です」と出ていました。読んでいませんでした。保存した時点で終わったつもりになっていたからです。

    つまずき2:保存すると、見られなくなる値がある

    環境変数を保存するとき、種別を2つから選べました。

    種別 あとから値を見られるか
    設定値 見られる
    秘密 見られない

    今回は「秘密」を選びました。訪問者に配るファイルへ混ざってほしくない値だからです。

    ただしこれは、自分でも二度と確認できないという意味でもあります。今回は発行元の画面でいつでも見られるURLなので困りませんが、そうでない値なら、保存する前に控える必要がありました。

    つまずき3:相手側の防御が、こちらの正常な送信を弾く

    受け取り側のサービスにも、独自のボット対策が用意されています。画像の文字を読ませるあれです。

    ブラウザ前提の仕組みは、サーバー間では通らない

    この対策は「人がブラウザで操作している」ことを前提にしています。

    今回はサイトのサーバーから相手のサーバーへ送る形なので、有効になっているとこちらの正常な送信まで弾かれます。

    今回は既定で無効になっていたので何もせずに済みましたが、有効な状態で繋いでいたら「設定は合っているのに届かない」で長く悩むところでした。二重に掛けた対策が、互いを打ち消すことがあります。

    正しく弾けているかは、画面では分からない

    繋いだあと、4つ試しました。

    送ったもの 期待 結果
    普通に書いて送る 通る 通った
    開いた直後に送る 断る 断られた
    アドレスの形が違う 断る 断られた
    見えない欄を埋めて送る 捨てる 「送信しました」と出た

    最後のものは、わざとそうしています。機械に「その欄を避ければ通る」と分からせないためです。

    そのぶん、画面を見ているだけでは、弾けたのか通ったのかが区別できません。確認するには受信箱を見て、届いていないことを確かめる必要があります。実際に届いていたのは4件のうち1件だけでした。

    「何も起きないこと」を確かめる手順を用意しておく

    正しく動いた証拠が「画面に出ない」「メールが来ない」という形をしている場合、確認の手順を別に決めておかないと、確認そのものを忘れます。

    今回の学び

    まず、設定を保存した時点では、まだ何も変わっていないことがあるということ。読まれるのがページを作るときなら、作り直すまでは古いままです。保存できたことと、効いていることは別でした。

    次に、隠すと、自分からも見えなくなること。訪問者に見せたくない値を隠すのは正しい判断ですが、その値を次に必要とするのは自分かもしれません。保存する前に控えるかどうかを決めておく必要がありました。

    そして、対策は二重に掛けると打ち消し合うことがあること。自分の側で弾いているのに相手の側でも弾いていると、通したいものが通りません。どこで何を見ているかを1回並べて書き出すのが、結局早い道でした。

  • 再試行は入れていた。それでもデプロイが落ちた

    再試行は入れていた。それでもデプロイが落ちた

    再試行の処理は、半年前から入れていました。CMSが500を返したら、少し待ってもう一度投げる。3回まで。

    それでも、デプロイが落ちました。

    この記事で扱うこと

    再試行を入れてあったのに効かなかった話です。何が返ってきていたのか、なぜ3回では足りなかったのか、そして回数ではなく間隔を増やした理由を書きます。

    ページを先に作る構成では、ビルド中に問い合わせが集中する

    このサイトは、訪問者が来てからCMSに聞くのではなく、デプロイのときに全ページを作ってしまう構成です。表示は速くなりますが、そのぶんビルドの最中にCMSへ何十回も問い合わせが飛びます。

    記事が増えたいまは、1回のビルドで82ページぶんの問い合わせが、1分足らずのあいだに走ります。

    失敗の意味が変わっていた

    動的に表示していたころは、1回の問い合わせが失敗してもその1回の表示が失敗するだけでした。次に開けば直ります。

    事前生成に変えたあとは、同じ1回の失敗がデプロイ全体を止めます。新しい記事も、直したところも、まとめて出なくなります。

    速くするために取った選択が、別の場所の壊れやすさを生んでいたことになります。

    返ってきていたのは、WordPressのエラーではなかった

    ビルドのログを見ると、82ページ中40ページを作ったあたりで止まっていました。

    Error: GraphQL Error (Code: 500): {"response":{"error":"<!DOCTYPE HTML PUBLIC ...
    

    500という数字だけ見ると「WordPressが処理に失敗した」と読みたくなりますが、中身はHTMLでした。しかもWordPressのエラー画面ではなく、Apacheが出す素のエラーページです。

    返ってきたもの そこから分かること
    WordPressのエラー(JSON) WordPressまでは届いている。中で失敗した
    Apacheの素のエラーページ WordPressに届く前に断られている

    つまりPHPが動く手前の段階で弾かれていました。短時間に問い合わせが集中したことで、共用サーバー側がまとめて断る状態に入っていたのだと思います。

    3回では足りなかった理由

    入れてあった再試行は、こういう設定でした。

    回 待ち時間 累計
    1回目 — 0秒
    2回目 1秒 1秒
    3回目 3秒 4秒

    最初から最後まで、4秒で終わります。

    相手が「一瞬もたついた」だけなら、これで通ります。実際これまでは通っていました。今回そうならなかったのは、断られている状態が数秒では明けなかったからです。

    同じ壁に3回ぶつかっていた

    再試行は動いていました。ログにも3回ぶんの失敗が残ります。

    ただ、4秒のあいだに3回投げても、3回とも同じ状態の相手に当たります。回数を増やしても、待っている時間が短ければ結果は変わりません。

    回数ではなく、間隔を増やした

    直したのは、待ち時間の表です。

    const MAX_ATTEMPTS = 5;
    const RETRY_DELAY_MS = [1000, 3000, 8000, 15000];
    

    最後まで粘ると、1件あたり最長27秒かかります。ビルドは確実に遅くなります。

    それでもこちらを選んだのは、待って通るほうが、デプロイごと落ちるより安いからです。27秒は待てば終わりますが、落ちたビルドは自分では直りません。気づいて、原因を調べて、押し直すまで止まったままになります。

    なぜ回数を増やすだけでは足りないか

    回数だけ10回に増やしても、間隔が1秒なら全体で10秒です。混んでいる相手にとってはさらに叩きに来ただけになります。

    相手が回復するのを待つのが目的なので、増やすべきは回数ではなく間隔のほうでした。

    気づくのが遅れた理由

    この失敗に気づいたのは、デプロイした数時間後でした。新しく足したページを開いたら404だったからです。

    ビルドが失敗しても、サイトは前の版のまま動き続けます。これ自体は正しい挙動で、壊れた版が公開されるよりずっと良い。ただし見ている側には何も起きません。止まったことは、止まった画面を見ても分からないということです。

    いまは通知を入れていませんが、次に同じことが起きたときに数時間気づかない状態は残っています。ここは別に手を打つ必要があると考えています。

    今回の学び

    まず、速くする変更が、壊れやすさを持ち込むことがあるということ。事前生成そのものは良い選択でしたが、「1回の失敗が何を止めるか」が変わっていました。変えたときに、失敗の重さも一緒に変わります。

    次に、500の中身を見ないと、誰が断ったか分からないこと。同じ500でも、アプリが失敗したのか、その手前で断られたのかで打つ手が違います。今回は中身がHTMLだったことが手がかりになりました。

    そして、再試行は「回数」ではなく「どれだけ待つか」だということ。入れてあること自体は安心材料になりません。相手が何秒で回復するのかを考えないまま回数だけ決めていた、というのが本当の原因でした。

  • 上げる先が、ひとつしかなかった

    上げる先が、ひとつしかなかった

    脆弱性の警告が28件。そのうち23件が next に集約されることまでは分かっていました。あとは上げるだけ——のはずが、まず「どこまで上げるか」で手が止まりました。

    この記事で扱うこと

    更新先のバージョンをどう選んだか、自動化ツールがやってくれたこととやってくれなかったこと、そして「入れる必要のない設定」を消した判断。メジャーバージョンを2つ跨ぐ更新を、確認しながら進めた記録です。

    まず、選択肢を数える

    手元は 14.2.35 でした。自動で提案されていたのは 16.3.1。2世代ぶんの差があります。

    最初に考えたのは「まず14系の最新まで上げて、それで消える分を消そう」でした。メジャーを跨がなければ壊れにくいからです。これは実行しませんでした。調べたら、前提が違っていたからです。

    サポート状況
    Next.js 14  サポート終了(2025-10-26)
                最終パッチ 14.2.35 ← 手元にあるのがこれ
    
    Next.js 15  Maintenance LTS  2026年10月まで
    Next.js 16  Active LTS       2027年10月まで

    14系の最新は、すでに手元にありました。「上げる」という操作に余地が無く、18件の警告は14に留まる限り1件も消えません。

    やっても何も起きないコマンドを、実行しかけた

    調べる前は「14系の最新に上げましょう」と判断していました。実行すれば何かが変わるはず、という思い込みです。手を動かす前にサポート期限を1つ調べただけで、この無駄が消えました。作業の前に前提を確認するのは、遠回りに見えて速いです。

    15を選ばなかった理由

    残る選択肢は15と16です。15の方が変更が小さいので、一見こちらが安全に見えます。

    しかし15のサポートは2か月後に切れます。そこで上げても、すぐ同じ作業をやり直すことになります。「変更が小さい」ことの価値は、その変更が長持ちするかどうかで決まります。2か月で無効になるなら、小さいことに意味がありません。

    実質的に、上げる先は16しかありませんでした。

    何が壊れるかを、先に調べる

    移行ガイドを読んで、このサイトに該当するものだけを抜き出しました。全部を読むのではなく、自分のコードに関係するものを探すという読み方です。

    変更点 このサイトへの影響
    params が非同期になる 該当。記事ページで同期的に使っている
    React 19.2 が必要 該当。18.3.1 から上げる必要がある
    next lint の廃止 該当。package.json に書いてある
    画像設定の既定値変更 明示指定していないので影響なし
    Turbopackが既定に webpack設定を持っていないので影響なし
    middleware の改名 使っていない

    該当したのは3つだけでした。2世代ぶんの変更に見えて、実際に手を入れる場所は限られていると分かった時点で、作業の見通しが立ちます。

    自動化ツールは、半分やってくれる

    Next.jsには移行用のコードモッド(コードを機械的に書き換えるツール)が用意されています。2つ実行しました。

    実行したもの
    # パッケージ更新・設定ファイルの書き換え・lintの移行
    npx @next/codemod@canary upgrade latest
    
    # params / searchParams の非同期化
    npx @next/codemod@canary next-async-request-api .

    変換結果は正確でした。たとえば記事ページはこうなります。

    自動変換された箇所
    - type Props = { params: { slug: string } };
    + type Props = { params: Promise<{ slug: string }> };
    
    - export default async function BlogPostPage({ params }: Props) {
    + export default async function BlogPostPage(props: Props) {
    +   const params = await props.params;

    49ファイルを走査して2ファイルを変更、エラー0。手で直していたら見落としが出る種類の作業です。

    変換されなかった場所も、確認する

    「0 errors」は「全部やった」ではありません。ツールが認識できなかった書き方は、静かに素通りします。変更されたファイル数と、自分が想定していた箇所の数が合っているかは、自分で確かめる必要があります。今回は2ファイルで一致していました。

    入れる必要のない設定を、消した

    ここが今回いちばん判断が要った箇所です。

    コードモッドは、7つのファイルにこれを追加していました。

    追加されていたもの
    // TODO: Cache Components adoption. Refactor this route so this
    // opt-out can be removed.
    export const instant = false;

    調べると、これは16.3の新機能に対するオプトアウトで、別の設定を有効にしているときにだけ意味を持ちます。このサイトはその設定を使っていないので、7ファイルすべてで何もしていません。

    将来のために先回りして入れてくれたものです。しかし残すとどうなるかを考えました。

    残す場合 消す場合
    将来その機能を有効にしたとき、警告が出ない そのとき改めて入れる必要がある
    「あとで対応する」というTODOが7か所に残る コードに無効な設定が残らない

    消しました。誰も対応しないTODOは、そのうち景色になります。読むたびに「これは何だっけ」と確認するコストが7か所ぶん発生し、いずれ誰も読まなくなります。必要になったときに、必要な場所へ入れるほうが健全だと判断しました。

    自動生成されたものを、そのまま受け取らない

    コードモッドが入れたものは正しい提案です。しかし「このプロジェクトにとって必要か」までは判断していません。ツールは一般的に安全な側へ倒します。プロジェクトの事情を知っているのは書き手のほうです。

    本番に入れる前に、本番と同じもので確かめる

    今回はmainに直接入れず、ブランチを切りました。

    進め方
    git checkout -b feat/next-16
    (コードモッド実行・手直し)
    npm run build      ← 手元で通ることを確認
    git push -u origin feat/next-16

    ブランチをpushすると、デプロイ先がプレビュー用のURLを作ってくれます。本番と同じビルド手順で、本番と同じ環境に載った状態を、公開せずに触れます。

    そこで確認したのは6点です。

    確認項目
    1. 記事一覧が20本、サムネイルが出る
    2. 記事ページの本文・目次・読了バー
    3. sitemap.xml が25件(数を数える)
    4. robots.txt が4行
    5. 構造化データの日時に Z が付いている
    6. ビルド時のエラー・警告
    3番は、数えないと分からない

    sitemapは、失敗しても固定ページだけを返して200で応答する作りにしてあります。壊れていても画面は正常に見えます。「表示された」ではなく「25件あった」まで確認しないと、記事がsitemapから消えていても気づけません。

    全部通ったのを見てから、mainへマージしました。プレビューで通ったものが本番でも通る——確認の順番として、これが一番安く済みます。

    結果

    脆弱性の件数
    next     18件 → 0
    postcss   4件 → 0
    nanoid    1件 → 0
    glob      3件 → 0
    lodash    2件 → 2   (開発用のみ)
    
    合計     28件 → 2件

    glob が消えたのは想定外でした。ESLintも一緒に上がり、その依存として新しい glob が入ったためです。本番で動くもののうち、既知の脆弱性は無くなりました。

    残った2件は開発用の依存で、配信される成果物には入りません。

    今回の学び

    まず、「上げる」の前に、上げ先を数えること。14系の最新はすでに手元にあり、実行しても何も起きないコマンドを打ちかけていました。サポート期限を1つ調べるだけで防げます。

    次に、変更の小ささより、その変更が持つ期間で選ぶこと。15は変更が小さいですが2か月で切れます。安全に見える選択肢が、実は先送りなだけということがあります。

    そして、自動化ツールの出力も、読んでから受け取ること。コードモッドは正確でしたが、このプロジェクトに不要なものも入れていました。ツールは一般的に安全な側へ倒し、個別の事情までは知りません。

    最後に、本番と同じもので確かめてから本番に入れること。プレビュー環境を1回挟むだけで、「マージしてから気づく」が「マージ前に気づく」に変わります。確認の順番を変えただけで、リスクの大きさが変わります。

  • wwwのあるなしで、別のサイトになる

    wwwのあるなしで、別のサイトになる

    kisaku.site と www.kisaku.site。どちらを開いても同じサイトが出ます。

    しかし機械から見ると、この2つは別のサイトです。今回、計測まわりの設定を3か所やって、そのすべてで「どちらを書くか」を選ばされました。選び方を間違えると、設定したのに数字がいつまでも0のままになります。

    この記事で扱うこと

    wwwありとwwwなしが別物として扱われる理由、apexドメインにCNAMEを置けないというDNSの制約、そして計測系の3つ(Search Console・GA4・OGP)で同じホスト名に揃える必要があること。

    まず用語を整理する

    呼び方 例 意味
    apexドメイン
    (ゾーンapex、ネイキッドドメイン)
    kisaku.site いちばん短い形。前に何も付かない
    サブドメイン www.kisaku.site
    cms.kisaku.site/wp/
    apexの前に名前を足したもの

    www は特別なものではありません。cms や blog と同じ、ただのサブドメイン名です。慣習として広まっているだけで、技術的な地位は他と変わりません。

    ここが出発点です。www が特別だと思っていると、なぜ別扱いされるのかが理解できません。

    なぜ「どちらか」に決める必要があるのか

    両方で同じ内容が見られる状態は、放っておくと3つの問題を生みます。

    困ること 何が起きるか
    検索エンジンから見て重複 同じ内容が2つのURLに存在する扱いになり、評価が分散する
    計測が2つに割れる 同じ人が両方を訪れると別の訪問者として数えられる
    Cookieが共有されない 片方でログインしても、もう片方では未ログイン扱いになりうる

    だから正規のアドレスを1つ決めて、もう片方はそこへ転送する。これが標準的なやり方です。このサイトは www.kisaku.site を正規にして、kisaku.site から転送しています。

    なぜ www 側を正規にしたのか

    好みではなく、DNSの制約が理由です。

    ドメインとサーバーを結びつけるとき、レコードの種類を選びます。

    種類 指す先 apexに置けるか
    A / AAAA IPアドレス 置ける
    CNAME 別のドメイン名 置けない

    apexドメインにはCNAMEを置けません。DNSの仕様上、CNAMEは「この名前は別名です」という宣言で、同じ名前に他のレコードを共存させられないためです。apexには NS や MX といったレコードが必ず存在するので、CNAMEと同居できません。

    これが効いてくるのは、VercelやNetlifyのようなサービスを使うときです。こうしたサービスは配信先のIPアドレスを固定していません。負荷や障害に応じて変わります。だからIPではなく名前で指してほしい——つまりCNAMEを求めます。

    ALIAS / ANAME という回避策

    「apexでもCNAMEのように振る舞う」独自レコードを用意しているDNSサービスもあります(ALIAS、ANAME、CNAMEフラットニングなど呼び方はさまざま)。ただしこれはDNSの標準ではなく、各社の独自拡張です。使えるかどうかは契約しているDNSサービス次第で、乗り換えの自由が下がります。

    まとめると、www 側はCNAMEを素直に置けるのに対し、apex側は置けません。制約の少ないほうを正規にして、制約のあるほうを転送元にする。これが www を正規にした理由です。

    逆に、自分でサーバーを持っていてIPアドレスが固定なら、apexを正規にする選択も普通にあります。どちらが優れているという話ではなく、構成によって答えが変わります。

    転送しても、統合はされない

    ここからが今回の本題です。

    転送を設定すると、人が kisaku.site を開いても www.kisaku.site に飛びます。見た目には1つのサイトです。

    しかし計測やSEOのツールは、転送を設定したからといって自動で統合してくれません。登録するときにどちらを書いたかで、拾えるものが変わります。

    設定した場所 登録した値 間違えるとどうなるか
    Search Console
    (URLプレフィックス)
    https://www.kisaku.site/ データが増えないまま。不具合との区別がつかない
    GA4 データストリーム https://www.kisaku.site 設定上の表示がずれる
    Next.js の metadataBase https://www.kisaku.site SNS共有時にOGP画像が出ない

    いちばん厄介なのは1行目です。間違っていても、エラーは出ません。ただ数字が0のままになります。「まだ検索に載っていないのだろう」と思って何週間も待ってしまう、という失敗が起こりえます。

    登録するのは「転送先」

    迷ったら、ブラウザのアドレスバーに最終的に表示されているほうを登録します。転送元ではありません。このサイトなら www 付きです。

    ドメインプロパティなら分かれない

    Search Consoleには、URLプレフィックスとは別にドメインプロパティという種類があります。こちらは www の有無も http / https の違いも区別せず、すべてまとめて扱います。

    URL プレフィックス ドメイン
    対象 入力したURLの配下だけ すべてのサブドメイン・両プロトコル
    確認方法 5種類から選べる DNSのTXTレコードのみ
    www の有無 別プロパティになる 区別しない

    「それなら最初からドメインで登録すればいい」と思えますが、そう単純でもありません。

    ドメインプロパティはすべてのサブドメインを含みます。このサイトには cms.kisaku.site/wp/ というWordPress用のサブドメインがあり、そちらは検索結果に出したくありません。ドメインプロパティにすると、見せたくない側の数字も一緒に入ってきます。

    また、確認方法がDNSのTXTレコード追加に限られます。DNSは設定を間違えるとサイト全体が見えなくなる場所です。手軽さでいえばHTMLタグを1行足すほうが安全です。

    両方持つという選択

    実際には、両方登録して並行して見ることもできます。URLプレフィックスで www 側を細かく見つつ、ドメインプロパティで全体を把握する。どちらか一方を選ばなければならない、という制約はありません。

    フロント側での指定

    Next.jsでは metadataBase に正規のURLを1か所書きます。

    layout.tsx(要点)
    const SITE_URL = "https://www.kisaku.site";
    
    export const metadata: Metadata = {
      metadataBase: new URL(SITE_URL),
      openGraph: {
        type: "website",
        siteName: "KiSAKU Portfolio",
        locale: "ja_JP",
        url: SITE_URL,
      },
    };

    これが必要なのは、SNSは相対パスの画像を読めないからです。OGP画像を /ogp.png のように書いても、共有先のサービスはそれをどのドメインの話か判断できません。metadataBase があると、Next.jsが絶対URLに変換して出力してくれます。

    ここで www なしを書くと、共有時の画像URLが転送を挟む形になります。転送に対応していないサービスでは画像が表示されません。

    確認の仕方

    設定が効いているかは、実際にアクセスして確かめます。

    転送されているかを見る
    curl -sI https://kisaku.site/ | head -5
    
    HTTP/2 308
    location: https://www.kisaku.site/

    308 は「恒久的な転送」です。検索エンジンに対して「こちらが正規です」と伝える意味を持ちます。302(一時的)だと、その意味が伝わりません。

    ブラウザで確認するときの落とし穴

    恒久的な転送はブラウザに強くキャッシュされます。設定を直したのに古い転送先へ飛び続ける、ということが起きます。以前、この勘違いで「本番が更新されていない」と思い込んだことがありました。実際にはURLの末尾に適当なクエリを付けるだけで、正しく転送されていることが確認できました。

    今回の学び

    まず、www は特別な存在ではないということ。cms と同じただのサブドメインです。ここを理解していないと、なぜ別サイト扱いされるのかが腑に落ちません。

    次に、どちらを正規にするかは好みではなく制約で決まること。apexにCNAMEを置けないというDNSの仕様が、そのまま結論を導きました。構成が変われば答えも変わります。

    そして、転送は人には効くが、ツールには効かないこと。転送を設定しても、Search ConsoleもGA4もOGPも、登録した値のとおりに動きます。3か所すべてで同じホスト名に揃える必要がありました。

    最後に、エラーが出ない間違いがいちばん高くつくこと。ホスト名を間違えても、どこにも警告は出ません。ただ数字が増えないだけです。壊れているのか、まだ何も起きていないのか、区別がつきません。沈黙する設定ほど、入れた直後に確かめる価値があります。

  • 「来なかった人」を見るために

    「来なかった人」を見るために

    アクセス解析は「来た人」しか教えてくれません。

    検索結果に出たのに押されなかった、そもそも検索結果に出ていない——こうした「来なかった人」の情報は、GA4には一切残りません。それを見るための道具がGoogle Search Consoleです。今回はその導入と、所有権の確認方法を選ぶまでの判断を記録します。

    この記事で扱うこと

    Search ConsoleとGA4の役割の違い、ドメインプロパティとURLプレフィックスの選び分け、5つある所有権確認方法のうちどれを選んだか。そして、確認コードをコードに直接書かなかった理由。

    GA4では見えないもの

    まず、2つの道具が何を見ているのかを整理します。境界を勘違いすると、片方に無いものを延々と探すことになります。

    Googleアナリティクス Search Console
    見る対象 サイトに来た後の行動 サイトに来る前の状況
    データの出どころ サイトに埋めたタグ Googleの検索システム
    分かること 閲覧ページ、滞在、遷移、流入元 検索語、表示回数、順位、クリック率
    分からないこと どんな言葉で探されたか サイト内で何をしたか

    この違いがはっきり出るのが検索語です。GA4は「Google検索から来た」ことは分かりますが、何と入力して来たのかは教えてくれません。プライバシー保護のため、検索エンジン側が渡していないからです。

    Search Consoleは検索エンジン側の記録なので、そこが見えます。さらにクリックされなかった表示も数えられています。これがGA4には原理的に存在しない情報です。

    表示されているのに押されていない、が分かる

    ある記事が検索結果に100回出て、クリックが2回だとします。GA4に残るのは「2」だけです。Search Consoleを見ると「100回出ている」と分かるので、内容ではなくタイトルの問題だと切り分けられます。逆に表示が0なら、書き方以前に見つけられていません。直す場所がまったく変わります。

    プロパティの種類を選ぶ

    登録の最初に、2種類のどちらかを選ばされます。

    ドメイン URL プレフィックス
    対象 すべてのサブドメイン、http/https両方 入力したアドレスの配下だけ
    確認方法 DNSの設定のみ 5種類から選べる
    向いている場面 サブドメインをまとめて見たい 特定の1サイトだけ見たい

    今回はURLプレフィックスを選び、https://www.kisaku.site/ を登録しました。

    理由は2つあります。ひとつは、このサイトには cms.kisaku.site/wp/ というWordPress用のサブドメインがあり、そこは検索結果に出したくないからです。ドメインプロパティにすると、見せたくない側の数字まで一緒に入ってきます。

    もうひとつは、確認方法の選択肢です。ドメインプロパティはDNSレコードの追加が必須で、ドメイン管理会社の画面での作業になります。設定を間違えるとサイト全体が見えなくなる場所なので、必要のない作業は入れたくありませんでした。

    wwwの有無は別物として扱われる

    URLプレフィックスでは、https://kisaku.site/ と https://www.kisaku.site/ は別のプロパティです。転送を設定していても自動では統合されません。登録するのは転送先——つまり実際に表示されるほうです。ここを間違えると、いつまでもデータが0のままになります。

    所有権の確認方法

    URLを入力すると、そのサイトが自分のものだと証明するよう求められます。方法は5つ提示されました。

    方法 やること 今回の判断
    HTMLファイル 指定のファイルをサイト直下に置く ×
    HTMLタグ metaタグを head に入れる 採用
    Googleアナリティクス GA4のタグが入っていれば通る ×
    Googleタグマネージャー GTMのタグが入っていれば通る ×
    DNSレコード ドメイン設定にTXTを追加 ×

    HTMLファイル方式を外したのは、Next.jsで public に置くこと自体はできるものの、この1ファイルが何のためにあるのか、後から見て分からなくなるからです。ファイル名は意味を持たない英数字の羅列で、消しても当面は気づきません。そして消すと所有権が外れます。

    GA4やGTMを使う方式も外しました。一見いちばん楽ですが、計測を止めた瞬間に所有権の確認も外れます。将来「GTMをやめて直接タグを貼ろう」と判断したとき、Search Consoleまで巻き込まれます。関係のないもの同士を依存させない、という判断です。

    DNSは前述のとおり、影響範囲が大きすぎるので今回は使いませんでした。

    残ったHTMLタグ方式を選んでいます。head に1行入るだけで、これはNext.jsが正式に対応している範囲です。

    確認コードを直接書かなかった理由

    提示されるのはこういう1行です。

    Search Consoleが提示するタグ
    <meta name="google-site-verification" content="(英数字の文字列)" />

    これを layout.tsx にそのまま書いても動きます。しかし、環境変数から読む形にしました。

    layout.tsx(要点)
    export const metadata: Metadata = {
      metadataBase: new URL("https://www.kisaku.site"),
      // ...
      verification: {
        google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION,
      },
    };

    Next.jsには verification という専用の項目が用意されていて、値を渡すと head にmetaタグを出力してくれます。手書きでタグを差し込む必要はありません。

    環境変数にした理由は、値が未設定なら何も出力されないからです。コードを先にデプロイしておいて、確認コードを取得してから設定を足せます。実際、今回はコードのほうが先にできていました。

    もうひとつ、この値がサイトの機能に関係ないものだという点も理由です。動作には一切影響しないので、コードの中に混ざっていると「何のための文字列か」を毎回思い出すことになります。設定として外に置けば、設定であることが見た目で分かります。

    この値は秘密ではない

    NEXT_PUBLIC_ が付いた環境変数は、ビルド時にコードへ埋め込まれ、誰でも見られる状態になります。この確認コードはもともとHTMLに出力するためのものなので、それで問題ありません。逆に言えば、秘密にすべき値をこの接頭辞で扱ってはいけません。接頭辞が「公開してよい」という宣言になっています。

    登録しただけでは確認されない

    metaタグがまだ本番に出ていないので、この時点でのプロパティは未確認の状態です。

    手順としてはこうなります。

    確認が通るまでの順番
    1. Search Consoleでプロパティを追加し、確認コードを控える
    2. Vercelの環境変数に確認コードを登録する
    3. 再デプロイする(環境変数の追加だけでは反映されない)
    4. Search Consoleに戻って「確認」を押す
    環境変数は追加しただけでは効かない

    NEXT_PUBLIC_ の値はビルド時にコードへ埋め込まれます。管理画面で追加しても、その後にビルドし直さなければ、出力されるHTMLは前のままです。「設定したのに反映されない」の大半はこれです。

    確認が通ったあとは、サイトマップの送信が次の作業になります。sitemap.xml をまだ用意していないので、これは別のタスクとして残しました。

    今回の学び

    まず、2つの道具は「来た後」と「来る前」で分かれているということ。GA4だけを見ていると、記事が読まれない理由を内容の問題だと考えがちです。実際には検索結果に出ていないだけかもしれません。直す場所を間違えないために、両方が要ります。

    次に、楽な確認方法ほど、他のものに依存していること。GA4やGTMを使う方式は一瞬で終わりますが、計測をやめた瞬間に所有権も外れます。関係のないもの同士を結びつけない——遠回りに見えても、独立させておくほうが後で自由が利きます。

    そして、設定はコードの外に置くと、設定に見えること。確認コードを直接書いても動きますが、コードの中に混ざると意味を思い出す手間が毎回かかります。環境変数にしておけば、それが「あとで変わりうる値」だと見た目で分かります。

  • 速くしたら、壊れた — 表示1.7秒を0.03秒にするまでと、その代償

    速くしたら、壊れた — 表示1.7秒を0.03秒にするまでと、その代償

    サイトの表示が遅い、という話から始まった作業です。結果として最初の1バイトが返るまでの時間は約1.7秒から0.03秒になりました。50倍以上です。

    ただしこの記事の本題は、その数字ではありません。速くしたことで、別の場所が壊れたという話です。デプロイが失敗し、画像がぼやけ、一覧が読めなくなりました。どれも高速化そのものが引き起こしたものです。

    この記事で扱うこと

    計測から原因を特定する手順、Next.jsのキャッシュがどこに効いてどこに効かないか、事前生成とISR、そして「速くするための選択が壊れやすさを生む」という構造。最後に、自分の計測を自分で壊した失敗も書きます。

    まず測る

    「遅い気がする」から始まる改善は、たいてい見当違いの場所を触って終わります。数字が無いと、直したかどうかも判断できません。

    使ったのは curl だけです。

    最初の1バイトが返るまでの時間を測る
    curl -s -o /dev/null \
      -w "合計:%{time_total}s  最初の1バイト:%{time_starttransfer}s\n" \
      https://www.kisaku.site/

    見るべきは time_starttransfer、最初の1バイトが返るまでの時間です。体感の遅さはほぼここで決まります。この値が大きいと、画面が真っ白なまま待たされます。逆にここが小さければ、多少ファイルが大きくても「表示され始めた」感覚は得られます。

    フロントとCMSの両方を測りました。

    対象 最初の1バイトまで
    フロント(1回目) 3.25秒
    フロント(2回目) 1.85秒
    フロント(3回目) 1.72秒
    CMSのGraphQL 2.29秒

    3回繰り返しているのには理由があります。1回目は暗号化通信の確立が含まれるので必ず遅くなります。1回だけ測って「3.2秒だ」と判断すると、実力値を1.5秒以上見誤ります。

    この表から読み取れることは明確でした。フロントの実力値が約1.7秒、CMSが約2.3秒。つまりフロントの待ち時間は、ほぼそのままCMSの応答時間です。Vercel自体の処理はごくわずかで、大半は共用サーバーのWordPressがPHPとMySQLを動かし終わるのを待っている時間でした。

    犯人は自分が書いた3文字だった

    原因はGraphQLクライアントのこの指定でした。

    src/lib/graphql-client.ts(修正前)
    export const graphqlClient = new GraphQLClient(endpoint, {
      fetch: (input, init) =>
        fetch(input, {
          ...init,
          cache: "no-store",   // ← これ
        }),
    });

    cache: "no-store" は「結果を一切キャッシュしない」という指定です。Next.jsはこれを見つけると、そのページを事前に生成できないと判断します。訪問者が来るたびに、その場でCMSへ問い合わせて組み立てる方式になります。

    ビルド結果にも、はっきり出ていました。

    修正前のビルド結果
    Route (app)
    ┌ ƒ /
    ├ ƒ /blog
    ├ ƒ /blog/[slug]
    ├ ƒ /profile
    ├ ƒ /projects
    └ ƒ /skills
    
    ƒ  (Dynamic)  server-rendered on demand

    全ページに ƒ の印。すべてが「訪問時に組み立てる」状態でした。この情報は毎回のビルドで表示されていたのに、意味を確認していませんでした。

    この指定を入れたのは「記事を公開したらすぐ反映されるように」という意図でした。狙い自体は正しかったのですが、その代償として全ページが毎回CMS待ちになることを見落としていました。

    キャッシュは、思ったところに効かない

    直し方は単純に見えました。no-store を外して、代わりに「5分間キャッシュする」と書けばいい。

    ところが、ここで一段深い問題に当たりました。

    Next.jsのfetchキャッシュはGETにしか効かない

    Next.jsには fetch の結果を自動でキャッシュする仕組みがありますが、対象はGETリクエストだけです。GraphQLは通常POSTでクエリを送ります。つまり fetch に revalidate を指定しても、素通りして何も起きません。

    気づかずに進めていたら、「設定したのに速くならない」という状態で長く悩んだはずです。

    解決には unstable_cache を使いました。これは fetch に限らず、関数の戻り値そのものをキャッシュできる仕組みです。

    src/lib/data.ts
    import { unstable_cache } from "next/cache";
    
    export const getPosts = unstable_cache(
      async () => {
        const data = await graphqlClient.request(GET_POSTS);
        return data.posts?.nodes ?? [];
      },
      ["posts"],                                    // キャッシュキーの一部
      { revalidate: 300, tags: ["wordpress"] },     // 300秒保持
    );

    引数も自動的にキャッシュキーに含まれるので、getPostBySlug("a") と getPostBySlug("b") は別物として扱われます。

    名前に unstable_ と付いていますが、これは「将来APIが変わるかもしれない」という意味で、動作が不安定という意味ではありません。

    tags を付けているのは将来のためです。WordPressで記事を公開したときにVercelへ通知を送る仕組みを足せば、5分待たずに反映できます。今は使っていませんが、後から足せるように印だけ置いています。

    ページ自体も事前に作る

    あわせて、各ページに1行足しました。

    各 page.tsx の先頭
    export const revalidate = 300;

    これでNext.jsはページを事前に生成し、300秒ごとに裏側で作り直します。訪問者はCMSの応答を待ちません。

    ただし記事ページは、これだけでは足りませんでした。/blog/[slug] は「どんなスラッグが存在するか」をNext.jsが知らないため、事前に作りようがないからです。そこで一覧を教えます。

    src/app/blog/[slug]/page.tsx
    export async function generateStaticParams() {
      const posts = await getPosts();
    
      return posts
        .map((post) => post.slug)
        .filter((slug): slug is string => Boolean(slug))
        .map((slug) => ({ slug }));
    }

    結果

    ページ 改善前 改善後 状態
    トップ 約1,720ms 25〜35ms キャッシュ命中
    記事ページ 約1,720ms 26〜29ms キャッシュ命中
    実績 約1,720ms 23ms キャッシュ命中
    ブログ一覧 約1,720ms 242ms 毎回生成

    ブログ一覧だけが動的なままです。?category= で内容が変わるページは事前に作れません。それでも1.7秒から242msになりました。CMSへの問い合わせが消え、残っているのは画面を組み立てる時間だけだからです。

    自分の計測を、自分で壊した

    ここで恥ずかしい失敗をしました。

    デプロイ後に測り直したところ、全ページが1.4〜2.6秒。しかも配信網の状態を示すヘッダが、どれも MISS(キャッシュに無い)でした。一瞬「効いていないのか」と思いました。

    原因は私が測り方に入れたキャッシュ除けのパラメータでした。

    やってしまった測り方
    // 毎回ちがうURLになる
    await fetch('/blog?nc=' + Date.now());

    ブラウザのキャッシュを避けるつもりで付けたものですが、配信網から見れば毎回まったく新しいURLです。キャッシュに無いのは当たり前でした。パラメータを外して測り直したのが、上の表の数字です。

    教訓としては、測定の道具が測定対象に影響していないかを疑うということになります。結果がおかしいとき、まずシステムを疑いたくなりますが、測り方のほうが壊れていることは珍しくありません。

    速くした代償で、デプロイが止まった

    ここからが本題です。

    サムネイル画像を記事に紐づけ、一覧のクエリに画像の情報を足して本番に送ったところ、デプロイが失敗しました。

    Vercelのビルドログ
    GraphQL Error (Code: 500)
    <title>500 Internal Server Error</title>
    <p>Please contact the server administrator,
     webmaster@gmoserver.jp ...</p>
    
    > Build error occurred
    Error: Failed to collect page data for /blog/[slug]

    エラーの中身は、共用サーバーが返したApacheの標準エラーページでした。WordPressが処理しきれず、サーバー側で落ちていました。

    ここで重要なのは、なぜ今回から落ちるようになったのかです。

    以前は全ページが動的だったので、ビルド中にCMSへ問い合わせることはありませんでした。CMSが500を返しても、影響は「その1回の表示が失敗する」だけです。

    事前生成に切り替えたことで、ビルド時にCMSへ問い合わせる経路が生まれました。そこへ画像の情報を足したため処理が重くなり、共用サーバーが応答しきれなくなりました。

    構造として理解しておきたいこと

    速くするための最適化は、たいてい依存関係を増やします。事前生成は「ビルド時点でデータが取れる」ことを前提にしています。前提が増えれば、壊れる条件も増えます。速さと壊れにくさは、多くの場合トレードオフの関係にあります。

    2つの対策

    ひとつは、5xxエラーのときに自動で投げ直すことです。

    src/lib/graphql-client.ts
    const MAX_ATTEMPTS = 3;
    const RETRY_DELAY_MS = [1000, 3000];   // 1秒後、3秒後
    
    async function fetchWithRetry(input, init) {
      for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
        if (attempt > 0) await wait(RETRY_DELAY_MS[attempt - 1]);
    
        const response = await fetch(input, init);
    
        // 5xx はサーバー側の一時的な不調の可能性が高いので投げ直す。
        // 4xx はこちらの要求が間違っているので、何度試しても同じ。
        if (response.status >= 500 && attempt < MAX_ATTEMPTS - 1) continue;
    
        return response;
      }
    }

    すぐ投げ直さず間隔を空けているのは、混んでいる相手にすぐ投げ直しても意味がないからです。むしろ負荷を増やします。また、再試行するのは5xxだけにしました。4xxを何度試しても結果は変わらず、ただの遅延になります。

    もうひとつは、事前生成の失敗でビルドを止めないことです。

    src/app/blog/[slug]/page.tsx
    export async function generateStaticParams() {
      try {
        const posts = await getPosts();
        return posts.map(/* ... */);
      } catch (error) {
        console.warn("記事一覧を取得できなかったため、事前生成をスキップします", error);
        return [];   // 空を返す = 訪問時に生成する方式へ切り替わる
      }
    }

    判断の根拠は、この処理の役割が「どのページを先に作っておくか」を決めることでしかないという点です。失敗しても致命的ではありません。最初の1人が待つ代わりに、デプロイは通ります。

    なお、デプロイが失敗しても本番サイトは無事でした。Vercelは失敗時に切り替えを行わず、前回成功した内容を配信し続けます。失敗したデプロイは「何も起きない」で済むという設計です。

    画像を足したら、今度は画像が壊れた

    デプロイが通り、サムネイルが表示されるようになりました。しかし画面を見ると、2つ問題がありました。

    1. 画像が大きすぎた

    ブログ一覧は1列なので、カード幅いっぱいに1200×630の画像を置くと高さが454pxになり、1件で画面の半分近くを占めていました。

    一覧の役割は「複数の記事を見比べて選ぶ」ことです。1件ずつしか見えない一覧は、一覧として機能していません。画像を左、文字を右の横並びに変え、画像の幅を256pxに収めました。

    2. 画像がぼやけていた

    調べると、配信されていた画像は450px幅なのに、864pxに引き伸ばして表示されていました。原因は自分が書いた指定です。

    間違っていた指定
    <Image
      src={...}
      fill
      sizes="(min-width: 768px) 720px, 100vw"   // 実際は864pxだった
    />

    sizes は「この画像は画面上でどれくらいの幅で表示されるか」をブラウザに伝えるものです。ブラウザはこの申告を信じて、取ってくる画像の大きさを決めます。申告が小さすぎれば拡大されてぼやけ、大きすぎれば無駄に重い画像を取ります。

    横並びに変更して表示幅が256pxで確定したので、申告もそれに合わせました。実寸と申告が一致していることが重要です。

    今回の学び

    まず、測ってから直す。今回は最初に測ったおかげで、原因が「Vercelの処理」ではなく「CMSの応答待ち」だと即座に分かりました。測らずに始めていたら、画像の圧縮やJavaScriptの削減といった、効果のない場所を触っていたはずです。

    次に、キャッシュは「効いているつもり」が最も危ない。POSTには fetch キャッシュが効かない、という一点を知らなければ、設定したのに変わらない状態で長く悩んでいました。仕組みがどこに効いてどこに効かないかは、思い込みではなく確認する必要があります。

    そして、最適化は依存関係を増やす。事前生成は「ビルド時にデータが取れる」という前提の上に成り立っています。前提が増えれば壊れる条件も増えます。速くするときは、同時に「壊れたときにどうなるか」を決めておくべきでした。今回は壊れてから決めることになりました。

    最後に、ブラウザに嘘をつかない。sizes の件は、こちらの申告をブラウザが信じて動く仕組みでした。人間が見て分かる間違いではなく、画面がぼやけるという形でしか現れません。自動で最適化してくれる仕組みほど、渡す情報の正しさが結果を決めます。

  • ローカルのサイトを本番公開するまでにハマった話

    ローカルのサイトを本番公開するまでにハマった話

    ローカルのパソコンで作ったWebサイトを、世界中から見られる状態にする——いわゆる「本番公開」の作業をしました。手順書に沿えば1時間で終わるはずが、実際には丸一日かかっています。今回はその全過程を、途中で出てくる用語をひとつずつ説明しながら記録します。

    想定している読者は、HTML・CSS・JavaScriptは書けるけれど、サーバーやDNSやSSLはこれから、という方です。私自身がその立場で、今回いくつも初めてのことに出会いました。

    そもそも何をしようとしているのか

    今回のサイトはヘッドレス構成というものです。まずここから説明します。

    従来のWordPressサイトは、記事の管理も、見た目の表示も、ぜんぶWordPressがやります。訪問者のブラウザはWordPressに直接アクセスし、WordPressがHTMLを組み立てて返す。1つのソフトが全部を担当する形です。

    ヘッドレス構成では、この役割を2つに分けます。

    WordPress … 記事を書く・保存する(管理画面とデータベース)
    Next.js   … 見た目を作る・訪問者に表示する

    「ヘッドレス」は「頭がない」という意味で、この場合の”頭”は表示部分を指します。WordPressから表示機能を取り除き、データを保管して受け渡すことに専念させる、というイメージです。

    2つに分けると、置き場所も2つ必要になります。

    WordPress → お名前.comのレンタルサーバー(cms.kisaku.site)
    Next.js   → Vercel(kisaku.site)

    Vercelは、Next.jsで作ったサイトを公開するためのサービスです。GitHubにコードを置いておくと、それを自動で取ってきて、ビルドして、公開してくれます。

    ビルドという言葉も説明しておきます。開発中に書いているコードは人間が読みやすい形になっていて、そのままではブラウザで動きません。これを実際に動く形に変換する作業がビルドです。Next.jsの場合は、この段階でWordPressからデータを取ってきて、ページの形に組み立てるところまで行います。

    前回までの記事で、WordPress側の移行は終わっています。今回はNext.js側をVercelに載せ、最後に kisaku.site というドメインを新しいサイトに向けるところまでが目標です。

    デプロイ前に見つけた不具合

    Vercelに繋ぐ前に、設定ファイルを確認していて問題を見つけました。next.config.mjs の画像に関する設定です。

    {
      protocol: "https",
      hostname: "cms.kisaku.site",
      pathname: "/wp-content/uploads/**",   // ← ここが間違っていた
    }

    これが何の設定かというと、Next.jsの画像表示機能が使う「許可リスト」です。

    Next.jsには Image というコンポーネントがあり、画像を自動で最適化して表示してくれます。ただし、どこの画像でも無条件に扱うわけではありません。あらかじめ許可したホスト名とパスの画像しか受け付けない仕組みになっています。悪意のあるサイトの画像を勝手に処理させられるのを防ぐためです。

    ところが前回の移行作業で、このサイトのWordPressは /wp/ というフォルダの中にあることが判明していました。画像の実際のURLはこうなります。

    https://cms.kisaku.site/wp/wp-content/uploads/2026/07/image.png
                            ~~~~ ← これが入る

    許可リストのほうには /wp が入っていないので、一致しません。このままだとブログ記事の画像が全部表示されないことになります。

    直すのは1行です。

    pathname: "/wp/wp-content/uploads/**",

    この間違いは、私が以前「WordPressはルートフォルダにある」と誤解していた頃の設定が残っていたものでした。ひとつの誤解が、気付かないうちに別のファイルにも影響していたわけです。誤解が判明した時点で「この前提を使っている場所は他にないか」を探すべきでした。

    失敗1: 自動生成されるファイルをGitに入れていなかった

    最初のデプロイは20秒で失敗しました。原因はGraphQLの型定義ファイルがリポジトリに入っていなかったことです。

    順に説明します。GraphQLは、必要なデータを指定して取り出すための問い合わせ言語です。「記事のタイトルと本文と日付だけください」というように、欲しい項目を書いて送ると、その形で返ってきます。

    このプロジェクトでは GraphQL Codegen というツールを使っています。書いたGraphQLの問い合わせを読み取って、TypeScriptの型定義を自動生成してくれるものです。型定義があると、エディタが「このデータにはtitleという項目がありますよ」と教えてくれたり、存在しない項目を書いたときに警告してくれたりします。

    これは npm run codegen というコマンドで生成されます。自動生成されるものなので、私は .gitignore(Gitで管理しないファイルを指定するリスト)に入れていました。

    ところがVercelは、ビルド時に npm run build しか実行しません。codegenは走らないので、型定義ファイルが存在しないままビルドに入り、そこで落ちるという流れでした。

    対処は2つ考えられます。

    案A: ビルドコマンドを "npm run codegen && npm run build" に変える
    案B: 生成されたファイルをGitにコミットしてしまう

    一般論では案Aのほうがきれいです。自動生成物をGitに入れると、差分が読みにくくなり、複数人で開発するときに衝突の原因にもなります。

    それでも案Bを選びました。理由は、codegenがWordPressのGraphQLに接続してデータの構造を取りに行くからです。案Aにすると、ビルドが成功するかどうかが本番のWordPressが生きているかどうかに左右されます。

    案A: WordPressが落ちている → codegen失敗 → ビルド失敗 → デプロイできない
    案B: WordPressが落ちている → ビルドは成功する(表示するデータは無いが)

    ビルドは、なるべく外部のサービスに依存しないほうが壊れにくい。 そしてこの判断は、直後に正しかったと証明されます。まさにWordPressへの接続が失敗し続ける事態に陥り、案Aを選んでいたら原因調査のためのデプロイすらできなかったからです。

    教科書的な正解と、その場の正解は一致しないことがあります。大事なのはどちらを選ぶかより、選んだ理由を説明できることだと思っています。

    失敗2: ビルドは成功。でもデータが1件も出ない

    2回目のデプロイは43秒で成功し、サイトも表示されました。ところが中身を見ると、こうなっています。

    実績       : まだ実績が登録されていません。
    スキル     : まだスキルが登録されていません。
    ブログ     : まだ記事がありません。
    プロフィール: (WordPress管理画面で作成すると、ここに表示されます)

    WordPress側には投稿7件・実績4件・スキル14件・画像10件が入っています。それが1件も出てきません。

    そして厄介なのが、ビルドが「成功」と表示されていることでした。データ取得に失敗しているのに、エラーとして表面化していません。理由はコードにありました。

    const [profileResult, projectsResult, skillsResult, postsResult] =
      await Promise.allSettled([
        getProfilePage(), getProjects(), getSkills(), getPosts(),
      ]);
    
    const projects =
      projectsResult.status === "fulfilled" ? projectsResult.value : [];

    Promise.allSettled を説明します。JavaScriptで複数の非同期処理(時間のかかる処理)をまとめて実行する方法は主に2つあります。

    Promise.all        … 1つでも失敗したら、その時点で全体が失敗になる
    Promise.allSettled … 全部の結果を待って、成功・失敗を個別に返す(例外を投げない)

    allSettled を選んだのには理由がありました。たとえば「プロフィールページをまだ作っていない」という場合でも、それ以外の部分は表示されてほしいからです。1箇所の欠損でページ全体が真っ白になるのは避けたい。設計としては妥当な判断です。

    ただ今回は、4つ全部が失敗しました。それでも設計どおりに空の配列へ置き換えられ、「まだ登録されていません」という平和な画面が出てしまいます。

    想定していた「一部が欠ける」ではなく「全部が落ちる」が起きたとき、この防御は問題を隠す方向に働きました。

    ここから学べることを一般化すると、こうなります。「壊さない」と「気付ける」は別の要求であり、両立できる。 次の2行があれば、調査は最初の5分で終わっていました。

    if (projectsResult.status === "rejected") {
      console.error("[getProjects] failed:", projectsResult.reason);
    }

    エラーを飲み込む設計自体が悪いのではありません。飲み込んだ痕跡を残さなかったことが問題でした。

    失敗3: 犯人を推測して、2回外した

    接続先の設定を疑いました。GraphQLの接続先はこう書かれています。

    const endpoint =
      process.env.NEXT_PUBLIC_WORDPRESS_API_URL ?? "http://localhost:8080/graphql";

    process.env.○○ は環境変数を読み取る書き方です。環境変数は、コードの外側から値を渡す仕組みで、「開発中はローカルのWordPress、本番は本番のWordPress」のように環境ごとに変わる値を扱うのに使います。

    ?? はnull合体演算子といって、「左側が空だったら右側を使う」という意味です。つまりこのコードは、環境変数が読めなければ黙ってローカル用のURLにフォールバック(代替)するという動きをします。Vercel上にローカルのWordPressは存在しないので、全部失敗する——筋は通っています。

    Vercelの管理画面を見ると、環境変数が Sensitive(機密)という設定で登録されていました。これは値を秘匿するための設定で、登録後は管理画面からも中身が見えなくなります。

    ここで違和感を持ちました。この環境変数には NEXT_PUBLIC_ という接頭辞が付いています。Next.jsではこの接頭辞に特別な意味があり、付いた値はビルド時にコードへ直接埋め込まれます。

    【ビルド前】process.env.NEXT_PUBLIC_WORDPRESS_API_URL
    【ビルド後】"https://cms.kisaku.site/wp/graphql"   ← 値そのものに置き換わる

    埋め込まれたコードはブラウザに配信されるので、この値は原理的に秘密にできません。だから「公開してよい値にだけ NEXT_PUBLIC_ を付ける」というルールがあり、パスワードやAPIキーに付けてはいけないのもこのためです。

    公開前提の値をSensitiveにするのは矛盾している——そう考えて、いったん削除し、Sensitiveなしで登録し直し、再デプロイしました。

    結果は変化なし。 データは相変わらず空のままでした。

    この時点で、私は原因を2回外しています。しかも困ったことに、Sensitiveだと私自身も値を確認できないので、「URLが間違っているのか」「値が届いていないのか」の区別すらついていませんでした。

    観測できない対象を推測で潰しにいっても、当たったか外れたかが分からない。 やるべきだったのは、値を推測することではなく、実際に何が起きているかを表示させることでした。

    決め手: エラーを表示させるためだけのコードを書く

    方針を変えました。エラーが握りつぶされているなら、握りつぶさずにそのまま表示する経路を一時的に作ればいい、という発想です。

    診断専用のURLを1つ追加しました。アクセスすると、次の4つをまとめて返します。

    1. ビルド時に埋め込まれた環境変数の実際の値
    2. GraphQLクライアントが使っているURL
    3. 素のfetchで接続した結果(エラーの中身まで)
    4. アプリと同じ経路で実行した結果

    コードの要点はここです。

    try {
      const r = await fetch(endpoint, { method: "POST", /* ... */ });
      result.rawStatus = r.status;
    } catch (e) {
      result.rawError = {
        name: e.name,
        message: e.message,
        cause: String(e.cause),   // ← これが決定的だった
      };
    }

    e.cause を取っているのが重要な点です。Node.js(サーバー側でJavaScriptを動かす仕組み)の fetch は、通信の失敗をすべて同じメッセージに丸めてしまいます。

    TypeError: fetch failed

    名前解決に失敗しても、接続を拒否されても、証明書が一致しなくても、タイムアウトしても、これだけです。本当の原因は cause というプロパティの中に入っています。

    これはエラーのラッピング(包装)と呼ばれる設計です。低レベルの細かいエラーを、上位の統一されたエラーで包む。使う側が実装の違いを気にしなくて済む利点がありますが、原因を調べるときは包みを開ける必要があります。

    デプロイして、この診断URLを叩いた結果がこれでした。

    {
      "envRaw": "https://cms.kisaku.site/wp/graphql",
      "clientEndpoint": "https://cms.kisaku.site/wp/graphql",
      "rawError": {
        "message": "fetch failed",
        "cause": "Error [ERR_TLS_CERT_ALTNAME_INVALID]:
          Hostname/IP does not match certificate's altnames:
          Host: cms.kisaku.site is not in the cert's altnames:
          DNS:*.gmoserver.jp, DNS:gmoserver.jp"
      }
    }

    SSL証明書の不一致でした。 そして環境変数は正しく届いていたことも同時に分かりました。私が疑って作り直したSensitiveの件は、完全に的外れだったわけです。

    なぜブラウザでは繋がり、サーバーからは繋がらないのか

    この問題が見つけにくかった理由を説明します。ブラウザからは正常にアクセスできていたのです。鍵マークも付き、GraphQLもデータを返していました。

    理解するには、HTTPS通信の最初の手順を知る必要があります。

    HTTPSの「S」はSecure(安全)の意味で、通信を暗号化します。ただしその前に、相手が本物かどうかを確認する手続きがあります。これをTLSハンドシェイク(TLSは暗号化の規格名、ハンドシェイクは握手)といいます。

    1. こちらが「cms.kisaku.site と話したい」と伝える
    2. サーバーが、そのホスト名に対応する証明書を返す
    3. こちらが「証明書に書かれた名前」と「アクセス先の名前」が一致するか確認する
    4. 一致すれば暗号化通信を開始、しなければ接続を中断

    証明書は、身分証明書のようなものです。「このサーバーは確かに cms.kisaku.site です」と第三者機関が保証したデータで、そこには対象のホスト名が書かれています。

    今回失敗したのは3の段階でした。返ってきた証明書には *.gmoserver.jp としか書かれておらず、cms.kisaku.site が含まれていない。だからNode.jsは「名乗っている相手が違う」と判断して接続を打ち切りました。

    ここで重要なのは、この確認はデータを送る前に行われるということです。実際、Vercelのログには「外部への通信が1本もない」と表示されていました。リクエストを送って失敗したのではなく、送る前の握手で決裂していたのです。

    そしてヘッドレス構成では、この違いが致命的になります。

    従来のWordPress: ブラウザ ──────────────→ WordPress
    今回の構成      : ブラウザ → Next.js(Vercel) → WordPress
                                    ↑
                        ここの通信は、ブラウザからは見えない

    従来の構成なら、ブラウザで確認すれば十分でした。しかしヘッドレスではサーバーがサーバーを呼ぶ経路が主役です。私はずっとブラウザから確認して「サーバー側は正常だ」と判断していましたが、その経路は実際に使われる経路ではなかったのです。

    「自分の環境から動く」は「どこからでも動く」ではない。 検証は、実際に通信する経路で行わないと意味がありません。

    証明書はその後サーバー側で正しく適用され、コードを一切変えずにデータが表示されるようになりました。診断用のURLは役目を終えたので削除しています。

    危なかった話: DNSの設定画面を間違えるところだった

    サイトが動いたので、最後の工程です。kisaku.site というドメインを、新しいサイトに向けます。

    DNSを説明します。インターネット上のサーバーは、本来 216.198.79.1 のような数字(IPアドレス)で識別されます。しかし人間が覚えるのは大変なので、kisaku.site のような名前と数字を対応づける仕組みがあり、これがDNS(ドメインネームシステム)です。電話帳のようなものだと考えてください。

    Vercelにドメインを追加すると、設定すべき内容が表示されます。

    kisaku.site       A      216.198.79.1
    www.kisaku.site   CNAME  e2e46a9cb91ef14a.vercel-dns-017.com

    Aレコードは「この名前は、このIPアドレスです」という対応づけ。CNAMEレコードは「この名前は、別の名前の別名です」という指定です。

    さて、これをどこで設定するか。お名前.comの管理画面には「DNSレコード設定」という、いかにもそれらしいメニューがありました。開いてみると、入力欄が並んだ画面が出てきます。

    ところが、その画面の既存レコード一覧が空でした。いま動いているはずの設定が、1件も表示されません。

    ここで手を止めて、確認しました。

    kisaku.site のネームサーバー: dns01.gmoserver.jp / dns02.gmoserver.jp

    ネームサーバーは、そのドメインの答えを実際に持っているサーバーのことです。DNSには2つの階層があります。

    【上位】ネームサーバー … 「このドメインの答えは、どのサーバーが持っているか」
    【下位】レコード       … 「そのサーバーが持っている、実際の答え」

    つまり答えを持っているのはレンタルサーバー側で、私が開いていたのはお名前.comが提供する別のDNSサービスの画面でした。名前がそっくりなので紛らわしいのですが、まったく別物です。

    そして画面の下には「レコードの登録とあわせてネームサーバーも変更する」というチェックボックスがありました。もしこれを有効にして保存していたら、上位の階層が書き換わります。

    変更前: kisaku.site → gmoserverのDNS(3件のレコードが登録済み)
    変更後: kisaku.site → 別サービスのDNS(レコードは空)

    参照先が空のほうに切り替わるので、ぶら下がるすべての名前が一斉に消えます。

    kisaku.site      → 消える
    www.kisaku.site  → 消える
    cms.kisaku.site  → 消える  ← WordPressごと到達不能に

    cms が落ちればGraphQLも止まるので、新サイトもデータを取得できなくなります。「cms のレコードには絶対に触らない」と決めて作業していたのに、1行も触らないまま巻き添えにするところでした。

    「レコードを触らなければ安全」は成り立ちません。 上位を切り替えれば下位は丸ごと入れ替わります。作業前には、個々の行を見る前に「いま自分はどのゾーンを編集しようとしているのか」を確認する必要があります。

    気付けたのは、一覧が空だったことに違和感を持ったからでした。「あるはずのものが無い」は、たいてい自分が別の場所を見ている合図です。

    正しい場所で切り替える

    本来の作業場所は、レンタルサーバーのコントロールパネルでした。開くと、期待どおりのレコードが並んでいます。

    (なし) 標準 A     ← ルートドメイン(kisaku.site そのもの)
    www     標準 A
    ftp / pop / smtp / imap  標準 A   ← メール関連
    (なし) MX 10              ← メールの宛先
    (なし) TXT               ← 送信元の正当性を示す設定

    ルートドメインの行を編集すると「別サーバーを利用する」という選択肢があり、IPアドレスを指定できました。ここにVercelの 216.198.79.1 を入れます。

    画面には注意書きもありました。「ドメインのホスト名なしはAレコードのみ設定できます」。

    これはDNSの仕様上の制約です。理由を説明します。CNAMEは「この名前は別名です」という宣言なので、そのホスト名に他のレコードを共存させられません。ところがルートドメイン(www などが付かない、ドメインそのもの)には、DNSの仕組み上どうしても必要なレコードがあります。

    NS  … このドメインを管理するネームサーバー(必須)
    SOA … ゾーンの管理情報(必須)
    MX  … メールの宛先(メールを使うなら必要)

    これらと共存できないため、ルートドメインにCNAMEは置けないのです。VercelがルートにはAレコード、www にはCNAMEを指定してきたのは、この事情を踏まえたものでした。

    www にCNAMEを使うのは、接続先のIPが変わっても自動で追従できるからです。Aレコードで直接IPを書くと、相手のインフラ変更のたびに手で直す必要が出ます。

    失敗4: 末尾のドット1文字でエラー

    www の設定で、保存に失敗しました。

    「指定先」の入力内容が正しくありません。
    ・ドットの連続やドットとハイフンを連続することはできません。

    原因は末尾のドットでした。Vercelが表示していた値は e2e46a9cb91ef14a.vercel-dns-017.com. と、最後にドットが付いています。

    これはDNSの正式な記法で、「完全修飾名なので、これ以上ドメイン名を補完しない」 という意味を持ちます。ドットが無いと、システムによっては自動的に自分のドメイン名を後ろに付け足してしまうことがあり、それを防ぐための表記です。

    ところがこの入力欄は、ドットを受け付けない実装でした。外して入れ直したら通りました。

    情けないのは、私が事前に「入力欄によってはドットでエラーになるので、その場合は外してください」と自分で書いていたことです。注意点を知っていることと、実行時にそれを適用することは別だと痛感しました。

    反映を待つ ── 「変わらない」の正しい読み方

    DNSを変更した直後に確認すると、まだ古い値が返ってきます。ここで「保存できていないのでは」と疑って設定を触り直すと、事態が混乱します。

    DNSの答えにはTTL(Time To Live)という有効期限が付いています。今回は600秒でした。世界中のDNSサーバーは、一度取得した答えをこの秒数のあいだ保存(キャッシュ)し、その間は問い合わせ直しません。毎回問い合わせていたら世界中のDNSが処理しきれないためです。

    つまり変更直後に古い値が返るのは正常です。問題は「保存されていない」のか「キャッシュを見ているだけ」なのかをどう区別するかでした。

    ここで役に立ったのがTTLの残り秒数です。実際に観測した値を並べます。

    kisaku.site  A 133.130.64.184  ttl600   ← 取得したばかりのキャッシュ
    kisaku.site  A 133.130.64.184  ttl218   ← 382秒前に取得された答えを見ている
    kisaku.site  A 216.198.79.1    ttl600   ← 期限切れ後に取り直した結果

    TTLはキャッシュの残り寿命なので、600から218に減っていれば「これは382秒前の答えだ」と分かります。いま見ている情報がいつ時点のものかを読み取れるわけです。ゼロになるのを待って問い合わせ直せば、確実に現在の状態が得られます。

    なおブラウザで確認する場合は、パソコンとブラウザ自身のキャッシュも重なります。シークレットウィンドウで開くか、別のネットワークから見るのが手軽です。「反映されない」の多くは、どこかの層にキャッシュが挟まっているだけです。

    最終的にこうなりました。

    kisaku.site       → 216.198.79.1                          Vercel
    www.kisaku.site   → CNAME e2e46a9cb91ef14a.vercel-dns...  Vercel
    cms.kisaku.site   → 133.130.64.184                        無事
    

    守りたかった cms は最後まで無傷でした。https://www.kisaku.site/ で新サイトが表示され、SSL証明書も自動で発行されて、公開完了です。

    今回の学び

    技術的な収穫は、fetch failed の cause を必ず見ることでした。表面のメッセージだけ読んでいる限り、永遠に原因にたどり着けません。包まれたエラーは、包みを開けないと中身が分からない。

    進め方の話としては、推測から観測に切り替えるのが遅かったという反省です。今回、原因を2回外しました。どちらも「たぶんこれだろう」で設定をいじり、結果が変わらず、当たったのか外れたのかも判然としない、という時間の使い方です。

    最終的に効いたのは、エラーを表示させるためだけのコードを書くという、一見遠回りな手段でした。書くのに10分もかかっていません。推測で1回試すコストと、確実に答えを得るコストは、思っているほど差がない。

    そして、DNSのように間違えると広範囲が止まる作業では、操作の前に「いま自分はどの階層を見ているか」を確認する習慣が要る、ということ。レコードを1行も触らずにサイト全体を落としかけた今回は、それを実感する良い機会になりました。

    次回は、この作業を通して出てきた用語や仕組みを、もう少し体系立てて解説します。

    契約前に知っておきたかったこと

    ドメインとサーバーを同じ会社で契約すると、DNSの設定画面が2か所に増えます。

    ドメインを買った側の管理画面にもDNSの設定欄があり、レンタルサーバー側にもあります。そして実際に参照されるのは片方だけです。

    厄介なのは、参照されないほうに設定してもエラーが出ないことです。保存は成功し、画面上も正しく見えます。ただ、何も起きません。

    この1点を知っているだけで、丸一日の調査が要らなくなります。

    お名前.comでドメインを取得する


  • なぜこの構成にしたのか — ヘッドレスWordPress + Next.js の技術選定

    なぜこの構成にしたのか — ヘッドレスWordPress + Next.js の技術選定

    ポートフォリオサイトを、素のHTMLで書かれた静的サイトから「ヘッドレスWordPress + Next.js」という構成に作り直しました。前回までの記事では、実際にやった作業と、そこでつまずいた内容を書いてきました。

    今回は少し引いた視点で、そもそもなぜこの構成にしたのかを書きます。技術選定の話です。

    結論から言うと、この構成は万人向けではありません。個人のポートフォリオという規模に対しては、明らかに過剰な部分があります。それでも選んだ理由と、その代わりに何を失ったのかまで含めて残しておきます。選定の記事は、良い点だけ並べても参考にならないと思うからです。

    まず全体像

    完成した構成はこうなっています。

    訪問者のブラウザ

    ↓  https://www.kisaku.site
    Vercel
    Next.js(App Router / TypeScript)
    ページの見た目を作って返す

    ↓  GraphQL(サーバー間の通信)
    お名前.com 共用サーバー
    https://cms.kisaku.site
    WordPress(管理画面 + データベース)
    WPGraphQL / ACF

    記事を書くのはWordPressの管理画面。表示するのはNext.js。両者はGraphQLというやり取りの決まりでつながっています。

    用語

    ヘッドレスとは「頭がない」という意味で、この場合の”頭”は表示部分を指します。WordPressから表示機能を切り離し、データを保管して受け渡すことに専念させる構成のことです。

    なぜヘッドレスにしたのか

    元のサイトは素のHTML・CSS・JavaScriptで書かれた静的サイトでした。表示は速く、構成もシンプル。悪くありません。

    ただ、実績を1件追加するのにHTMLを直接編集する必要がありました。文章の言い回しを直したいだけなのに、エディタでタグを探して、間違えないように書き換えて、アップロードし直す。これを何度か繰り返すうちに、更新のしやすさが欲しくなりました。

    ここで普通に考えると、選択肢は2つあります。

    案 内容 更新のしやすさ 表示の自由度
    A 普通にWordPressテーマを作る ◎ △(PHPのテーマの作法に従う)
    B ヘッドレス構成にする ◎ ◎(フロントは完全に自由)

    案Aで十分だったと思います。実際、更新のしやすさだけが目的ならそれが正解です。

    それでも案Bを選んだ理由は2つあります。

    ひとつは表示側を自由に作りたかったこと。WordPressのテーマを作る場合、PHPのテンプレート階層という作法に沿うことになります。慣れれば速いのですが、今回は「もとのサイトの見た目を保ちつつ、新しい書き方で作り直す」のが目的だったので、表示側は白紙から書きたかった。

    もうひとつが正直なところ大きいのですが、学習目的です。普段の実務ではヘッドレスCMSやGraphQL、Next.jsのApp Routerに触れる機会がありません。自分のポートフォリオという、壊れても誰も困らない題材で試してみたかった、というのが本音です。

    注意

    技術選定において「学習目的」は立派な理由になりますが、それが理由であることを自覚しておくのは大事だと思います。仕事で同じ判断をするなら、案Aのほうが妥当な場面は多いはずです。

    なぜWordPressを残したのか

    ヘッドレス構成にするなら、データを持つ側はWordPressである必要はありません。microCMSやContentfulのような最初からヘッドレス前提で作られたCMSもあります。

    それでもWordPressを選んだ理由は3つです。

    1. 管理画面の完成度

    記事を書く画面、画像をアップロードする画面、カテゴリを整理する画面——WordPressのそれらは20年かけて磨かれています。自分で書くつもりなら、この使い勝手は無視できません。

    2. ACFによる構造化

    今回のサイトには「実績」と「スキル」という、普通の記事とは形の違うデータがあります。

    実績: タイトル / 説明 / 役割 / 期間 / 使用技術 / カテゴリ / 画像
    スキル: 名前 / 習熟度(10段階) / 説明 / カテゴリ

    これを本文にベタ書きすると、あとで「使用技術だけ一覧したい」といった扱いができません。ACF(Advanced Custom Fields)というプラグインを使うと、こうした項目を管理画面に専用の入力欄として追加できます。

    入力する側から見ると「役割」「期間」という欄が並ぶだけですが、データとしてはきちんと項目に分かれているので、表示側で自由に組み替えられます。

    3. 自分が慣れている

    実務でWordPressのテーマ制作をしてきたので、管理画面の構造もカスタム投稿タイプの考え方も分かっています。新しいことを1つ試すときは、他をなるべく既知のもので固める——今回はフロントエンド側で新しいことをたくさんやるので、CMS側は慣れたもので押さえておきたかった、という判断です。

    補足

    「学習目的だから全部新しくする」は失敗しやすいやり方です。問題が起きたとき、原因の候補が多すぎて切り分けられなくなります。実際この作業でも、原因を2回外して数時間を溶かしました。既知の部分が多いほど、その切り分けは楽になります。

    なぜNext.jsなのか

    表示側にはNext.jsを選びました。ReactというUIライブラリをベースにしたフレームワークです。

    選定理由は、サーバー側でデータを取ってHTMLを組み立てられる点にあります。

    Reactだけで作ると、ブラウザ側でJavaScriptが動いてからデータを取りに行く形が基本になります。この場合こういう流れです。

    【ブラウザ側で取得する場合】
    1. ブラウザが空っぽのHTMLを受け取る
    2. JavaScriptを読み込んで実行する
    3. そこからAPIにデータを取りに行く
    4. 返ってきたデータで画面を描く
       → 4に到達するまで、画面には何も出ない
       → 検索エンジンにも中身が伝わりにくい

    Next.jsのサーバーコンポーネントを使うと、この流れが変わります。

    【サーバー側で取得する場合】
    1. サーバーがWordPressからデータを取る
    2. サーバーが中身の入ったHTMLを組み立てる
    3. ブラウザは完成したHTMLを受け取る
       → 最初から中身が見える
       → 検索エンジンにも内容が伝わる

    ポートフォリオは検索から見つけてもらうことに意味があるサイトなので、この違いは重要でした。

    ちなみにこの選択が、後の大トラブルの前提にもなっています。サーバーがサーバーを呼ぶという通信経路ができるため、ブラウザから確認しても分からない問題が起きうるのです。実際、SSL証明書の不一致でデータが一切取れないという事態に陥りました。詳細は前回の記事に書いています。

    なぜGraphQLなのか(RESTではなく)

    WordPressには標準でREST APIという仕組みが備わっています。追加のプラグインなしで使えます。それでもGraphQLを選びました。

    RESTの場合に起きること

    REST APIは「URLごとに決まった内容が返ってくる」方式です。実績一覧が欲しければ、こう叩きます。

    GET /wp-json/wp/v2/project

    返ってくるのは、その投稿タイプが持つほぼ全部の項目です。使うのがタイトルと画像とカテゴリだけでも、更新日時も編集履歴のIDもコメント設定も付いてきます。

    さらに困るのが、関連するデータが別のリクエストになることです。

    1. 実績一覧を取る          → GET /wp/v2/project
    2. 各実績のカテゴリ名を取る  → GET /wp/v2/project_category?include=...
    3. 各実績の画像URLを取る     → GET /wp/v2/media?include=...
       → 画面ひとつ描くのに3往復

    GraphQLの場合

    GraphQLは「欲しい項目を書いて送ると、その形で返ってくる」方式です。

    query GetProjects {
      projects(first: 100) {
        nodes {
          title
          uri
          featuredImage { node { sourceUrl altText } }
          projectCategories { nodes { name slug } }
          projectDetail { role period toolsused }
        }
      }
    }

    これで1回のリクエストです。画像もカテゴリもACFの項目も、まとめて必要なぶんだけ返ってきます。

    決め手は型の自動生成だった

    ただ、往復回数の削減だけならRESTでも工夫でどうにかなります。決め手になったのは別の点でした。

    GraphQLにはスキーマという「どんなデータがどんな形で存在するか」の定義があり、これを機械が読み取れます。この性質を利用したのが GraphQL Codegen というツールです。

    npm run codegen

    これを実行すると、書いたクエリを読み取ってTypeScriptの型定義を自動生成してくれます。結果として、エディタ上でこうなります。

    project.  ← ここまで打つと、使える項目が候補に出る
    project.titel  ← 打ち間違えると即座に警告が出る

    ACFで項目を追加してWordPress側の構造が変わっても、コマンド1つで型が追従します。手で型を書き直す必要がありません。

    選定の勘所

    GraphQLの利点として真っ先に挙げられるのは「必要な項目だけ取れる」ことですが、個人サイト規模ではその差は体感できません。実際に効いたのは型の自動生成でした。 よく語られる利点と、自分の状況で効く利点は違うことがあります。

    なぜVercelなのか

    Next.jsを公開する場所として、Vercelを選びました。Next.jsを作っている会社が運営しているサービスです。

    理由は3つあります。

    1. GitHubに置くだけで公開される

    GitHubにコードをpushすると、Vercelが自動で検知してビルドし、公開まで済ませます。手作業のアップロードが要りません。今回の作業でも、修正をpushするたびに自動で本番へ反映されました。

    2. 設定がほぼ不要

    Next.jsのプロジェクトだと自動で判別され、必要な設定はほぼありません。実際に指定したのは2つだけでした。

    Root Directory : frontend
    環境変数        : NEXT_PUBLIC_WORDPRESS_API_URL

    3. 個人利用なら無料

    ポートフォリオ程度のアクセス数なら無料枠に収まります。独自ドメインの接続もSSL証明書の発行・更新も無料で自動です。

    SSL証明書は通常90日で期限が切れ、更新が必要です。これを忘れるとサイト全体が「安全でない接続」と表示されて事実上使えなくなります。Vercelはこれを自動でやってくれるので、運用の手間がひとつ消えます。

    なぜCMSは共用サーバーのままなのか

    ここは技術選定というより、現実的な事情です。

    WordPressをVercelに置くことはできません。PHPとデータベースが必要だからです。置き場所は別に必要になります。

    選択肢はいくつかありましたが、もともとお名前.comの共用サーバーを契約していて、旧サイトがそこで動いていました。新しく契約すれば月額が増えます。既存の契約で足りるなら、それでいい。

    ただし、この判断には代償がありました。

    実際に起きたこと

    共用サーバーは設定の自由度が低く、次の問題に当たりました。SSL証明書がホスト名と一致せずVercelから接続できない、PHPの設定ファイルがディレクトリごとにしか効かない、WordPressが /wp/ という予期しない場所にインストールされる、クエリ文字列付きのアクセスがWAFに遮断される。安く済ませたぶん、原因調査に時間を使いました。

    とはいえ、これらは調べれば分かる範囲の問題でした。学習目的という文脈では、むしろ良い題材だったとも思っています。

    この構成で払った代償

    ここが、この記事でいちばん書きたかった部分です。

    1. 管理する場所が2つになった

    静的サイトなら、置き場所は1つでした。いまは2つです。それぞれにドメイン、SSL証明書、更新、バックアップの管理が発生します。

    【静的サイト】 1箇所
    【今の構成】   Vercel + 共用サーバー = 2箇所
                   + GitHub(コードの置き場所)= 実質3箇所

    2. CMSが落ちるとサイトが空になる

    これがいちばん大きい代償です。表示側と、データを持つ側が分かれているということは、データ側が落ちると表示側は空を返すということです。

    実際、SSL証明書の問題でWordPressに接続できなかったとき、サイトは正常に表示されるのに実績もスキルも記事も1件も出ない状態になりました。しかもHTTPステータスは200なので、普通の死活監視では検知できません。

    静的サイトなら、こんなことは起きません。HTMLに中身が書いてあるからです。

    3. 分業ゆえの複雑さ

    ヘッドレス構成では「サーバーがサーバーを呼ぶ」経路ができます。この経路はブラウザからは見えません。

    従来:ブラウザ → WordPress
    今回:ブラウザ → Next.js(Vercel) → WordPress
    ※ 後半の「Next.js → WordPress」はサーバー同士の通信なので、ブラウザの開発者ツールからは確認できない。

    今回、ブラウザからは正常にアクセスできるのにサーバーからは失敗する、という状況で数時間はまりました。確認方法そのものを間違えていたわけです。この落とし穴は、構成を分けたことの直接の帰結です。

    4. ビルドという工程が増えた

    静的サイトはファイルを置けば公開できました。いまはビルドが必要で、そこが失敗すると公開できません。実際、最初のデプロイは自動生成ファイルの不足で失敗しています。

    静的サイト 今の構成
    更新のしやすさ ×(HTML直接編集) ◎(管理画面から)
    表示側の自由度 ◎ ◎
    管理する場所 1つ 3つ
    壊れにくさ ◎ △(CMSに依存)
    公開までの手数 ファイルを置くだけ ビルドが必要
    学べること 少ない 多い

    選ばなかった選択肢

    静的サイトジェネレータ + Markdown

    AstroやHugoなどで、記事をMarkdownファイルとして書く方式です。CMSが不要になるので、構成は圧倒的にシンプルになります。管理場所も減り、CMSが落ちる心配もありません。

    選ばなかったのは、記事を書く体験を落としたくなかったからです。画像の挿入、下書きの管理、公開日の設定——このあたりはWordPressの管理画面のほうが圧倒的に楽です。文章を書くこと自体のハードルは下げておきたい、と考えました。

    ヘッドセスCMS(microCMS / Contentful など)

    最初からヘッドレス前提のCMSです。サーバーの管理が不要になるのが大きな利点で、今回はまったSSL証明書やPHP設定の問題は最初から発生しません。

    選ばなかった理由は、既にWordPressの資産(記事・画像・構造)があったこと、そして自分が慣れていることです。ゼロから始めるなら、こちらのほうが素直だと思います。

    WordPressのテーマとして普通に作る

    冒頭に書いた案Aです。この規模では、いちばん妥当な選択だったと思います。管理場所は1つ、ビルド不要、CMSが落ちればサイトも落ちるという単純明快な構造。

    選ばなかったのは学習目的が大きいのですが、正直に言えば「新しい構成を試したかった」という動機が最初にあり、理屈は後から整理した部分もあります。

    この構成が向いている人

    一通りやってみて、こう思っています。

    向いている

    フロントエンドの表示を完全に自分で制御したい人。React/Next.jsを実践的に学びたい人。すでにWordPressの資産と運用の慣れがある人。サイトが一時的に壊れても困らない立場の人。

    向いていない

    とにかく早く公開したい人。運用に手をかけられない人。止まると業務に影響が出るサイトを作る人。管理場所が増えることを許容できない人。

    最後の点が重要だと思っています。今回の構成は部品が増えたぶん、壊れる箇所も増えました。実際、公開までにSSL証明書、DNS、環境変数、ビルド設定と、それぞれ別の理由で止まっています。

    そのすべてが学びになったのは、これが止まっても誰も困らない自分のサイトだったからです。同じ構成を仕事で採用するなら、この「壊れる箇所の多さ」を運用体制で引き受けられるかどうかが判断の分かれ目になります。

    技術選定に唯一の正解はありませんが、何を得て、代わりに何を失ったのかを説明できる状態にはしておきたい——今回それを整理してみて、そう感じました。

  • ポートフォリオサイトをヘッドレスWordPress+Next.jsで作り直した話 (3) Next.js実装編

    ポートフォリオサイトをヘッドレスWordPress+Next.jsで作り直した話 (3) Next.js実装編

    シリーズ3回目です。今回はNext.js側の実装と、そのなかで実際に踏んだ不具合について書きます。

    データ取得の流れ

    WordPress側にWPGraphQLでAPIを用意したので、Next.js側はそれを叩くだけ…なのですが、クエリを文字列のまま書くとレスポンスの型が付かず補完も効きません。そこで GraphQL Codegen を導入し、次のような流れでデータを取得しています。

    // src/lib/queries/projects.ts
    export const GET_PROJECTS = graphql(/* GraphQL */ `
      query GetProjects {
        projects(first: 100) {
          nodes {
            id
            title
            content
            uri
            featuredImage { node { sourceUrl altText } }
            projectCategories { nodes { name slug } }
            projectDetail { role period toolsused }
          }
        }
      }
    `);
    

    graphql() タグでクエリを書いておき、npm run codegen を実行すると、クエリごとに型安全な戻り値の型が自動生成されます。ACFのフィールドを追加・変更してWordPress側のスキーマが変わった場合も、コマンド一発で型を追従させられるのが便利なところです。あとはこれを Server Component の中で await して呼び出すだけなので、データ取得部分はシンプルに書けました。

    GraphQLクエリからCodegen、Server Component、レンダリングまでのデータフロー

    ハマった不具合: 日本語スラッグが404になる

    実装を進めるなかで一番手こずったのが、実績の詳細ページが軒並み404になるという不具合でした。原因を辿ってみると、WordPress側で実績のスラッグ(URLの末尾部分)を日本語のまま(例:「wordpressオリジナルテーマ作成」)にしていたことが原因でした。Next.jsの動的ルート([slug]/page.tsx)がURLからパラメータを受け取る際、環境によってスラッグがパーセントエンコードされたまま渡ってくることがあり、WPGraphQL側の nodeByUri がそれをうまく解決できずにnullを返していた、というのが実態です。

    対処としては、実績のスラッグをすべて英数字(ローマ字)にリネームしたうえで、念のためNext.js側でも decodeURIComponent(params.slug) を挟むようにしました。

    const decodedSlug = decodeURIComponent(params.slug);
    const uri = `/projects/${decodedSlug}/`;
    const project = await getProjectByUri(uri);
    

    この不具合については、原因調査から検証までをもう少し詳しく別記事にまとめてあります。よければあわせてどうぞ。

    日本語スラッグの投稿がNext.jsで404になった話 →

    今回のまとめ

    Codegenのおかげでデータ取得部分は型安全に保てましたが、「URLエンコーディング」のような地味な足回りの部分は結局手を動かして初めて気づく類の問題でした。次回は、デザインをもとのサイトに近づける調整と、このブログ機能の追加について書きます。