タグ: Next.js

  • 速くしたら、壊れた — 表示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 の件は、こちらの申告をブラウザが信じて動く仕組みでした。人間が見て分かる間違いではなく、画面がぼやけるという形でしか現れません。自動で最適化してくれる仕組みほど、渡す情報の正しさが結果を決めます。

  • 読む体験をつくる — 目次の自動生成から読了状態の保存まで

    読む体験をつくる — 目次の自動生成から読了状態の保存まで

    記事を5本公開したあと、読む側の体験を作り込みました。目次の自動生成、追従する目次、読了状況の表示と保存、スマートフォンのメニュー、読了目安の時間。あわせて、トップページの見た目も見直しています。

    この記事は、その実装記録です。ただ機能を並べるのではなく、採用しなかった案と、その理由を中心に書きます。何を作ったかより、なぜそう決めたかのほうが、後から読んで役に立つためです。

    この記事で扱うこと

    HTML文字列からの見出し抽出、Reactのサーバーコンポーネントとクライアントコンポーネントの境界、IntersectionObserverによる現在位置の判定、localStorageとハイドレーションのずれ、そして「運用で止まらない設計」という考え方。最後に、生成AIが使えない環境でサムネイル画像を作った方法にも触れます。

    手書きの目次は、いつか必ず壊れる

    これまでの記事には、本文の先頭に目次を手書きしていました。見出しと同じ内容を、リンク付きのリストとしてもう一度書いていたわけです。

    この方式には構造的な弱点があります。同じ情報が2か所にあるということです。見出しを1つ足したら目次にも足す、順番を入れ替えたら目次でも入れ替える。どちらかを忘れた瞬間、目次と本文がずれます。

    しかも厄介なのは、ずれていても画面上は正常に見えることです。目次に無い節があっても、リンク先が消えていても、ページは普通に表示されます。壊れていることに気づく手がかりがありません。

    本文から機械的に作れば、この不整合は原理的に起こらなくなります。情報の出どころが1つになるからです。

    HTMLは「ただの長い文字列」でしかない

    実装で最初に理解しておく必要があるのは、記事本文がどういう形でReactに届くかです。

    WordPressから返ってくる本文は、HTMLの文字列です。React側から見ると、これはコンポーネントの木ではなく、ただの長いテキストです。<h2> が何個あるかを数えることも、その中身を取り出すこともできません。React上の要素ではないからです。

    したがって、見出しを拾うには文字列のまま解析するしかありません。

    src/lib/toc.ts
    // h2 と h3 を拾う。後方参照を使い、開始タグと同じ種類の閉じタグに対応させている
    const HEADING_PATTERN = /<(h2|h3)([^>]*)>([\s\S]*?)<\/\1>/gi;
    
    export function buildToc(content) {
      const toc = [];
      let autoIdCount = 0;
    
      const html = content.replace(HEADING_PATTERN, (whole, tag, attrs, inner) => {
        const text = toPlainText(inner);
        if (!text) return whole;
    
        // 既に id があるなら、その id をそのまま使う
        const existingId = extractId(attrs);
        const id = existingId ?? `section-${++autoIdCount}`;
    
        toc.push({ id, text, level: tag.toLowerCase() === "h3" ? 3 : 2 });
    
        if (existingId) return whole;
        return `<${tag}${attrs} id="${id}">${inner}</${tag}>`;
      });
    
      return { html, toc };
    }

    ここで意識したのは、既にidが振ってある見出しは、そのidを使い回すという点です。

    新しく連番を振り直すほうが実装は簡単ですが、それをやると公開済み記事のアンカー付きURLが全部変わります。外部から /blog/xxx#why-headless のようなリンクを張られていた場合、それが黙って壊れます。URLは公開した瞬間に他人が持つ資産になるので、こちらの都合で変えてはいけません。

    正規表現でハマった箇所

    どの記事に手書きの目次が入っているかを調べたとき、5本中3本しか該当しないという結果が出ました。原因は g フラグ付きの正規表現で test() を繰り返したことです。この場合、前回の検索位置が記憶されていて、1回おきに失敗します。件数が想定と違ったので調べ直して気づきました。数が合わないときは、だいたい数え方のほうが間違っています。

    サーバーで作れないものがある

    目次を追従させ、いま読んでいる節をハイライトし、読了状況をバーで示す。これらはすべてスクロール位置に依存します。

    ここで、Next.jsのApp Routerにおける重要な区別が出てきます。ページは既定でサーバーコンポーネントとして動きます。サーバー側でHTMLを組み立てて返すため、ブラウザに送るJavaScriptが減り、表示が速くなります。

    しかしサーバーが作れるのは「最初の1枚」だけです。スクロール位置は、そのあとブラウザの中で刻々と変わる値で、サーバーには存在しません。

    そこで、こうした部分だけをクライアントコンポーネント(ファイル先頭に "use client" と書く)に分けます。

    やりたいこと どちらで動かすか 理由
    本文から目次を抽出する サーバー 本文が手に入った時点で結果が決まる。ブラウザに解析処理を送る必要がない
    目次を表示し、開閉する クライアント 開いている・閉じているという状態を持つ
    いま読んでいる節を判定する クライアント スクロール位置はブラウザにしかない
    読了目安の時間を計算する サーバー 本文の文字数から決まる。何度計算しても同じ

    「取り出す」処理はサーバー、「見せる・動かす」処理はクライアント。この線引きを最初に決めておくと、迷いが減ります。

    スクロールのたびに全部測るのをやめる

    いま画面に見えている見出しを判定する方法として、最初に思いつくのはスクロールイベントです。スクロールのたびに、すべての見出しの座標を測って比べる。動きはします。

    ただしこの方法は、見出しが増えるほど重くなります。1本目の記事は見出しが26個あるので、スクロールのたびに26回の座標計算が走ることになります。スクロールイベントは1回のスクロール操作で何十回も発火するので、掛け算で効いてきます。

    ブラウザには、この用途のための仕組みが用意されています。

    src/components/TableOfContents.tsx
    const observer = new IntersectionObserver(
      (entries) => {
        const visible = entries
          .filter((entry) => entry.isIntersecting)
          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
    
        if (visible[0]) setActiveId(visible[0].target.id);
      },
      {
        // 画面全体ではなく「上から80px〜画面の30%まで」の帯だけを対象にする
        rootMargin: "-80px 0px -70% 0px",
        threshold: 0,
      },
    );
    
    headings.forEach((heading) => observer.observe(heading));

    IntersectionObserverは「この要素が画面に入った・出た」をブラウザ側で判定して教えてくれる仕組みです。座標計算をこちらでやらないので、見出しが増えても負荷がほとんど変わりません。

    rootMargin の指定には意味があります。画面全体を対象にすると、見出しが画面の下端に入った瞬間から「いま読んでいる節」と判定されてしまい、実際に読んでいる場所とずれます。上から80pxのあたりから画面の30%までという帯に絞ることで、体感に合わせています。

    進捗バーは、どこに置くかで意味が変わる

    読了状況を示すバーは、最初、画面の最上端に固定して表示していました。よく見かける形です。

    しかし実際に置いてみると、2つ問題がありました。ヘッダーと重なって見づらいこと。そして何の進捗なのかが、見ただけでは分からないことです。細い線が伸び縮みしているだけでは、それが読了率なのか、読み込み中の表示なのか、判断できません。

    そこで目次の中に移し、上に文字を添えました。

    目次パネルの表示
    読了まで              あと 58%
    ━━━━━━━━━━━━━━━━━━━━━
    
    目次
      まず全体像
      なぜヘッドレスにしたのか
      ...

    目次という「記事全体の地図」と同じ枠に入ることで、バーが何を表しているかが説明なしに伝わります。同じ部品でも、置く場所で有用にも無意味にもなります。

    数値は「読んだ割合」ではなく「残りの割合」にしました。読み手が知りたいのは、通ってきた距離ではなく、あとどれくらいで終わるのかという見通しだと考えたためです。

    細かいが効く指定

    パーセントの数字には tabular-nums という指定を入れています。数字の幅を揃えるCSSで、これが無いと 9% から 10% に変わる瞬間に桁が増え、文字の位置が横に揺れます。動くべきでないものが動くと、それだけで安っぽく見えます。

    自分の設計を取り下げた話

    読了状態をブラウザに保存し、次に開いたときも「読み終えた記事」と分かるようにしたい。そう考えました。

    最初は、やめておこうと考えました。理由はこうです。ページを開き直した直後、読者は記事の先頭にいます。そこで進捗バーが空なのに文字だけ「読み終えました」と出ていたら、表示同士が食い違って見えます。バーの仕事は現在位置を示すことなので、そこに過去の履歴を混ぜると、どちらの情報も信用されなくなる——そう考えました。

    この見立て自体は間違っていなかったと思います。ただし、そこから引き出した結論が違っていました。

    最終的に採用したのは、読了済みならバーを満杯の緑で固定するという形です。現在位置の表示をやめ、「この記事は読み終えている」という状態の表示に切り替えます。スクロールを戻しても「あと58%」には戻りません。

    src/components/ReadingProgress.tsx
    // 一度「読了済み」になったら、その表示のまま固定する。
    // スクロールを戻すたびに表示が行き来すると、
    // 読み終えたという事実が揺らいで見える。
    const [finished, setFinished] = useState(false);
    
    useEffect(() => {
      if (readBefore || remaining === 0) setFinished(true);
    }, [readBefore, remaining]);

    私が見落としていたのは、読み手にとって、読み終えたという事実のほうが、いま何%の位置にいるかより重要だという点でした。矛盾を避けようとして、優先順位の低いほうを守っていたことになります。

    設計の議論では「どちらが正しいか」より「どちらが読み手の関心に近いか」で決めるほうが、たいてい良い答えになります。

    サーバーが知らないことを表示する

    読了状態の保存先には、CookieではなくlocalStorageを選びました。

    Cookieは、そのドメインへのリクエストのたびに自動でサーバーへ送られます。読了状態はサーバーがまったく使わない情報なので、毎回送るのは通信の無駄です。容量も4KB程度しかなく、記事が増えると窮屈になります。localStorageはブラウザ内に閉じた保存領域で、サーバーには送られず、容量も数MBあります。

    ただしlocalStorageを使うと、必ずハイドレーションのずれという問題に当たります。

    ハイドレーションとは、サーバーが作ったHTMLに対して、ブラウザ側のReactが「同じものを作れるか」を確認しながら操作を結びつけていく処理のことです。ここで両者の出力が食い違うと、Reactが警告を出します。

    読了状態はまさに食い違います。サーバーはその人が記事を読んだかどうかを知りません。localStorageはブラウザの中にしか無いからです。したがってサーバーは常に「未読」として描き、ブラウザは「既読」と判断します。

    src/components/ReadBadge.tsx
    export default function ReadBadge({ slug }) {
      const [read, setRead] = useState(false);
    
      // 最初の描画では必ず false のまま。
      // 画面に出たあと(useEffect の中)で初めて localStorage を読む。
      // こうするとサーバーとブラウザの初回の出力が一致する。
      useEffect(() => {
        setRead(hasRead(slug));
      }, [slug]);
    
      if (!read) return null;
      return <span>読了済み</span>;
    }

    印が一瞬遅れて出ることになりますが、これが正しい作りです。サーバーが知り得ない情報は、サーバーの描画に混ぜてはいけません。

    もうひとつ、localStorageの読み書きは例外を投げることがあります。プライベートモードやブラウザの設定で無効化されている場合です。全ての操作を try/catch で囲み、失敗したら「記録しないだけ」で動き続けるようにしました。付加的な機能のために、記事そのものが表示できなくなってはいけません。

    押しても同じものが出てくるリンクを消す

    ここからは、実装というより判断の話です。

    実績のカードは、これまでカード全体が詳細ページへのリンクになっていました。ところが実際に運用してみると、詳細ページで増える情報は担当役割・実施期間・関連リンクの3つだけで、本文は一覧と同じものが出るだけでした。

    押しても、ほぼ同じ内容がもう一度出てくるリンクは、読み手の時間を奪います。そこで、詳細ページにしか無かった項目をカードに取り込み、遷移そのものを無くしました。情報を削るのではなく、1階層に畳んで見せる形です。

    外部サイトへのリンクだけは残しています。これは「同じ内容の別ページ」ではなく、実物を見に行く導線なので、押す価値があります。

    遷移を消したあとの後始末

    どこからもリンクされなくなった詳細ページは、URLを直接叩けばまだ表示されます。これは同じ内容が2つのURLで見られる状態で、検索エンジンからは重複コンテンツとして扱われます。使わないページは、リンクを外すだけでなく削除まで行う必要があります。

    情報を持たない画像は、面積の無駄になる

    トップページの実績カードには、鮮やかな抽象イラストが載っていました。オレンジ、紫、緑、青。見た目には賑やかです。

    しかしその画像は、実際に作ったものではなく、汎用のダミーUIやグラフのイラストでした。読み手はまず画像を見ますが、そこから得られるものが何もありません。カード内で最も面積を取っている要素が、最も情報量が少ない状態でした。

    さらに、サイト全体の配色方針と衝突していました。設定ファイルには「ニュートラルなグレー基調 + アクセント1色」と書いてあるのに、画面には5色以上が同時に出ていました。方針と実装がずれていたわけです。

    画像を外し、カテゴリのアイコンと名前を置く帯に置き換えました。同時に、本文側にあった重複したカテゴリ表示も削除しています。画像とアイコンで同じことを2回言っていた状態が、これで解消されました。

    色は、意味の違いを示すときに使う

    スキルの習熟度バーは、スキルごとに違う色が割り当てられていました。これも青1色に統一しました。

    判断の根拠はシンプルです。色の違いに意味が無かったからです。HTMLが橙でCSSが青であることに、何の情報もありません。差を表しているのはバーの長さです。色を外しても、失われる情報は1つもありませんでした。

    逆に、色を残した箇所もあります。読了済みの印を緑にしたのは、「未読と既読」という意味の違いを示しているからです。

    運用で止まる仕組みは、優れていても採用しない

    記事のサムネイル画像を作る段になって、いちばん考えさせられた判断がこれでした。

    最初の案には、記事ごとに内容を表すアイコンと、SERIES 01〜05という通し番号を入れていました。見た目は整っていました。

    ところが、並べてみて気づきました。この形は運用する際に困ります。

    連番のほうは分かりやすい問題です。番号は「順序」という情報を画像に焼き付けます。記事を1本追加した瞬間、あるいは途中に差し込んだ瞬間、番号は嘘になります。画像に焼いた情報は後から直せません。

    アイコンのほうは、もう少し見えにくい問題です。絵柄と内容は対応していたので、機能としては悪くありませんでした。問題は、記事を書くたびに「この記事のアイコンは何にするか」を決める作業が発生することです。これは判断を要する作業で、数分では終わりません。そして決めきれずに保留した記事は、アイコンが空白のまま公開されます。

    ここから引き出せる原則は、こうです。運用が止まる原因になる仕組みは、機能として優れていても採用すべきではない。更新されなくなったブログは、装飾が丁寧なことより痛手が大きいからです。

    改訂後のサムネイルで、記事ごとに決める要素はカテゴリとタイトルとサブタイトルだけになりました。カテゴリとタイトルはWordPressに入力済みなので、実質的に新しく決めるのはサブタイトルの1行です。

    あわせて、タイトルの文字数に応じて文字サイズを自動で切り替えるようにしました。長いタイトルのときに手で調整しなくて済みます。運用で困る箇所は、あらかじめ潰しておきます。

    生成AIが使えない環境で画像を作る

    サムネイルは当初、画像生成AIで作る想定でした。しかし使おうとした環境では画像生成が利用できず、別の方法を探すことになりました。

    次に試したのがストックフォトの検索です。結果は芳しくありませんでした。「ヘッドレスCMSの構成」「デプロイでハマった話」といった内容に対して返ってきたのは、住宅の設計図、青い積み木、白い展示台。この手の技術記事の内容に対応する写真は、そもそも存在しないという性質の問題でした。

    これを貼れば、さきほど実績カードから外したのとまったく同じ状態を作り直すことになります。

    そこで採った方法が、HTMLとCSSでデザインして、ヘッドレスブラウザで画像として書き出すというものです。

    サムネイル生成スクリプト(抜粋)
    const browser = await chromium.launch();
    const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });
    
    for (const article of ARTICLES) {
      await page.setContent(html(article), { waitUntil: "load" });
      await page.screenshot({ path: article.file });
    }
    
    await browser.close();

    ブラウザを画面なしで動かし、作ったHTMLを表示させ、その画面を撮る。それだけです。この方式には、絵を描く方法として見たときに明確な利点があります。

    サイトのヒーローとまったく同じCSS(グラデーション、グリッド、ロゴ)を使えるので、デザインが自然に揃います。タイトルは文字として載るため、内容と必ず一致します。そして修正がききます。タイトルを変えたらコードを1行直して再実行するだけで、全部作り直せます。

    サイズは1200×630にしました。ブログ一覧のカードだけでなく、SNSで共有されたときのOGP画像としてもそのまま使える寸法です。

    作業中に踏んだ罠

    実装そのものより時間を取られた箇所を、3つ記録しておきます。どれも「コードは正しいのに動かない」という形で現れました。

    1. 見ていたサーバーが違った

    変更を反映したのに画面が404のまま、という状態が続きました。原因はターミナルの警告文にありました。

    見落としていた1行
    ⚠ Port 3000 is in use, trying 3001 instead.

    前に起動した開発サーバーが完全に止まっておらず、ポート3000を掴んだままでした。新しいサーバーは3001で起動しており、私たちが見ていた3000は、古い設定で動いていた別のサーバーだったわけです。

    2. 設定ファイルを読まないツールがあった

    GraphQLの型を再生成しようとして、何度やっても失敗しました。設定はこう書かれています。

    codegen.ts
    schema: process.env.NEXT_PUBLIC_WORDPRESS_API_URL ?? "http://localhost:8080/graphql"

    環境変数は .env.local に書いてあります。しかし.env.local を自動で読み込むのはNext.jsであって、型生成ツールではありません。環境変数が空のまま右側にフォールバックし、起動していないローカルWordPressを見に行って失敗していました。

    「同じフォルダにある設定ファイルは、全部のツールが読んでくれる」と思い込んでいると、この種の問題は見つかりません。

    3. 古いサイトが表示され続けた

    本番サイトを開いたら、更新前の古いデザインが出ました。一瞬デプロイの失敗を疑いましたが、URLに意味のないパラメータを付けて開き直すと、新しいサイトが表示されました。ブラウザのキャッシュです。

    共通しているのは、3つとも「コードの問題」ではなかったという点です。コードを疑って読み直しても答えは出ません。何かおかしいと感じたら、コードの外側——どのサーバーを見ているか、どの設定が読まれているか、いつのデータを見ているか——を先に確認するほうが早いことが多いです。

    今回の学び

    実装の話として3つ、判断の話として2つ、残しておきます。

    実装では、同じ情報を2か所に持たないこと。目次を手書きしていたのは、見出しという情報を二重に持っていた状態でした。片方から機械的に作れば、ずれようがありません。

    次に、サーバーが知り得ない情報は、サーバーの描画に混ぜないこと。localStorageの中身はブラウザにしかありません。初回は何も出さず、画面に出てから読む。遠回りに見えて、これが正しい順序です。

    そして、仕組みに用意された道具を使うこと。スクロールのたびに全要素を測る代わりにIntersectionObserverを使う。自分で書けば動きますが、ブラウザ側で最適化されている処理には敵いません。

    判断のほうは、まず面積と情報量を一致させること。大きく見せているものほど、多くを伝えるべきです。意味のない画像が最大面積を占めていたら、それは削るべき対象です。

    最後に、運用で止まる仕組みは採用しないこと。これが今回いちばん考えさせられた点でした。作っている最中は、機能が多いほど良いものに見えます。しかし公開したあとに待っているのは、記事を書き続ける日々です。そのとき障害になるものは、どれだけ丁寧に作られていても負債になります。

  • 本番公開でつまずいた11のこと — 仕組みから解説

    本番公開でつまずいた11のこと — 仕組みから解説

    前回、ローカルで作ったサイトを本番公開するまでの記録を書きました。今回はその中で出てきた仕組みを、初めて触る人向けに整理して解説します。

    作業記録だけだと「こう直した」で終わってしまい、次に似た場面で応用が効きません。なぜそうなるのかが分かっていれば、症状が違っても同じ考え方でたどり着けます。前回の記事を読んでいなくても分かるように書きますが、実例は前回のものを使います。

    1. URLの「実体」と「転送先」は別物

    まず、いちばん基本的で、いちばん引っかかりやすい話から。

    ブラウザで https://example.com/admin/ を開いて、ページが表示されたとします。このとき「/admin/ にページがある」とは限りません。

    Webサーバーにはリダイレクト(転送)という仕組みがあります。「そのURLは別の場所に移ったので、こちらへどうぞ」とブラウザに指示するものです。指示を受けたブラウザは自動的に転送先へアクセスし直します。

    あなた: /admin/ をください
    サーバー: それは /wp/admin/ にあります(301 転送)
    ブラウザ: では /wp/admin/ をください(自動)
    サーバー: どうぞ(200 OK)
         ↓
    画面には「表示された」としか見えない

    この自動追従は普段は便利です。しかし構成を調べる目的では邪魔になります。私は実際にこれで誤解し、「WordPressはルートフォルダにある」と思い込んだまま作業を進めてしまいました。

    調べ方: 追従を切る

    ブラウザの fetch には、転送を追いかけない設定があります。

    await fetch('/admin/',     { redirect: 'manual' })  // → status 0(転送された)
    await fetch('/wp/admin/',  { redirect: 'manual' })  // → status 200(実体がある)

    redirect: 'manual' を指定すると、転送は「ステータス0」という特殊な状態で返ってきます。本物の200と、転送された先の200を区別できるわけです。コマンドラインの curl なら、追従オプション -L を付けないのが同じ意味になります。

    覚えておくとよい原則: 「調べるための通信」と「使うための通信」では、適切な設定が違います。使うときは自動で追ってくれたほうが楽ですが、調べるときは加工されていない生の応答が必要です。

    2. WordPressには基準となるURLが2つある

    これはWordPressを使う人だけの話ですが、はまると長引くので書いておきます。

    WordPressの「設定 → 一般」には、よく似た2つの項目があります。

    WordPress アドレス (siteurl) … WordPress本体のファイルが置いてある場所
    サイトアドレス     (home)    … 訪問者がアクセスするURL

    普通のインストールでは両方が同じ値になるので、違いを意識することはありません。ところが「WordPressを専用フォルダに入れる」構成にすると、この2つが分かれます。

    siteurl = https://cms.example.com/wp    ← 本体は /wp/ の中
    home    = https://cms.example.com       ← 見せるURLはすっきり

    この構成では、トップに置いた小さな中継ファイルが /wp/ の中身を呼び出します。訪問者に見えるURLはきれいなまま、本体のファイル群はフォルダにまとめられる、という利点があります。

    ここからが重要です。 WordPressのプラグインやテーマは、URLを組み立てるときに2つの関数を使い分けます。

    home_url()  → home    を基準にする
    site_url()  → siteurl を基準にする

    どちらを使うかは機能ごとの実装しだいです。実際、2つのAPIで判断が分かれていました。

    REST API  … home_url() 基準 → https://cms.example.com/wp-json/
    GraphQL   … site_url() 基準 → https://cms.example.com/wp/graphql

    私は「REST APIがルートに出ているのだから、GraphQLもルートだろう」と考えました。同じサイトの、同じようなAPIなのだから揃っているはずだ、と。揃っていませんでした。

    この構成を使うときは、「このURLは home と siteurl のどちらから作られるのか」を機能ごとに確認するのが正しい態度です。アップロード画像のURLも同じ理屈で影響を受けるので、画像が表示されない不具合にもつながります。

    3. エラーは「包まれている」ことがある

    これはWeb開発全般で役立つ話です。

    サーバー側のJavaScript(Node.js)で通信に失敗すると、こういうエラーが出ます。

    TypeError: fetch failed

    これだけです。 情報がほぼありません。しかも、原因が何であってもこの同じメッセージになります。

    名前解決に失敗した          → fetch failed
    接続を拒否された            → fetch failed
    証明書が一致しなかった      → fetch failed
    時間内に応答がなかった      → fetch failed

    本当の原因は cause というプロパティの中に入っています。

    try {
      await fetch(url);
    } catch (e) {
      console.error(e.message);   // "fetch failed" ← 情報ゼロ
      console.error(e.cause);     // ← ここに本体がある
    }

    実際に開けてみたら、こう書かれていました。

    Error [ERR_TLS_CERT_ALTNAME_INVALID]:
      Hostname/IP does not match certificate's altnames:
      Host: cms.example.com is not in the cert's altnames:
      DNS:*.gmoserver.jp

    一目で原因が分かります。証明書に書かれた名前が違う、と。

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

    cause はJavaScriptの標準仕様に入っている機能です。名前は環境によって違いますが(innerException、__cause__、Unwrap() など)、包む設計はどの言語にもあります。

    覚えておくとよい原則: エラーメッセージが妙に短くて抽象的なときは、その下にもう一段あります。

    4. HTTPSは「暗号化」の前に「本人確認」をしている

    HTTPSの「S」はSecure(安全)で、通信を暗号化します。ただし、暗号化の前に相手が本物かを確認する手続きがあります。

    これをTLSハンドシェイクといいます。TLSは暗号化の規格名、ハンドシェイクは握手のことです。

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

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

    ここで押さえておきたいのは、この確認はデータを送る前に行われるという点です。前回の失敗では、サーバーのログに「外部への通信が1本もない」と出ていました。リクエストを送って失敗したのではなく、送る前の握手で決裂していたのです。

    証明書に書かれる名前のルール

    証明書には複数の名前を書けます。*.example.com のようにワイルドカード(なんでも当てはまる記号)を使うこともできますが、ワイルドカードは1階層ぶんしかカバーしません。

    *.example.com は…
      shop.example.com     → カバーする
      a.b.example.com      → カバーしない(階層が1つ多い)
      example.com          → カバーしない(サブドメイン部分が無い)

    前回のケースでは、共用サーバーの既定の証明書 *.gmoserver.jp が返ってきていました。当然 cms.kisaku.site は含まれません。

    5. 「自分の環境から動く」は「どこからでも動く」ではない

    前回いちばん学びが大きかったのが、この点です。

    証明書の問題が見つけにくかったのは、ブラウザからは正常にアクセスできていたからでした。鍵マークも付き、データも返ってくる。それでもサーバーからは失敗する。

    なぜ結果が違うのか。通信する経路が違うからです。

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

    従来のWordPressサイトなら、ブラウザがWordPressに直接アクセスするので、ブラウザで確認すれば十分でした。しかしヘッドレス構成ではサーバーがサーバーを呼ぶ経路が主役になります。

    この経路は、ブラウザとは別のソフトが、別の場所から、別の設定で通信しています。証明書の検証も、ブラウザとNode.jsでは厳密さが違うことがあります。

    覚えておくとよい原則: 検証は、実際に通信する経路で行わないと意味がありません。手元で動くことは、他の場所から動くことを保証しません。

    6. エラーを握りつぶす設計の功罪

    前回、データが取れていないのにビルドが「成功」し、画面も表示されてしまいました。原因はコードの書き方です。

    const results = await Promise.allSettled([
      getProjects(), getSkills(), getPosts(),
    ]);
    
    const projects = results[0].status === "fulfilled" ? results[0].value : [];

    複数の非同期処理をまとめる方法は主に2つあります。

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

    allSettled を選んだ理由は「プロフィールページをまだ作っていない」といった部分的な欠損でページ全体が真っ白になるのを防ぐためでした。妥当な判断です。

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

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

    ただし、これは allSettled が悪いという話ではありません。次の2行があれば済んでいました。

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

    覚えておくとよい原則: 「壊さない」と「気付ける」は別の要求で、両立できます。エラーを飲み込むなら、飲み込んだ記録は残す。

    7. 環境変数は「いつ」読まれるのか

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

    Next.jsでは、変数名の頭に NEXT_PUBLIC_ を付けるかどうかで挙動が変わります。付けた場合、その値はビルド時にコードへ直接埋め込まれます。

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

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

    実務上の帰結が2つあります。

    ひとつは、変更したらビルドし直さないと反映されないこと。管理画面で値を書き換えても、既存のデプロイには影響しません。

    もうひとつは、フォールバックの書き方に注意が要ることです。

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

    ?? は「左が空なら右を使う」という意味です。便利ですが、本番で設定漏れがあってもエラーにならず、黙ってローカル用のURLで動き続けます。前回まさにこれで、原因の特定が遅れました。

    本番では明示的に落とす、という書き方も選択肢です。

    const endpoint = process.env.NEXT_PUBLIC_API_URL;
    if (!endpoint) throw new Error("NEXT_PUBLIC_API_URL is not set");

    覚えておくとよい原則: 親切なフォールバックは、設定ミスを見えなくします。6節と同じ構図です。

    8. DNSは2階層でできている

    DNSは、example.com のような名前と 216.198.79.1 のような数字(IPアドレス)を対応づける仕組みです。インターネットの電話帳のようなものだと考えてください。

    重要なのは、DNSが2つの階層でできていることです。

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

    ドメインを買った会社の管理画面で「DNS設定」を開いても、そこが実際に使われているゾーンとは限りません。ネームサーバーが別の会社を向いていれば、答えを持っているのはそちらです。

    前回、まさにこれで事故を起こしかけました。開いた画面のレコード一覧が空だったので、そこに新しく設定して「ネームサーバーも変更する」にチェックを入れていたら——

    変更前: example.com → A社のDNS(3件のレコードが登録済み)
    変更後: example.com → B社のDNS(レコードは空)

    ぶら下がるすべての名前が一斉に消えます。 「このレコードには触らない」と決めていたサブドメインも、1行も触らないまま巻き添えになります。

    覚えておくとよい原則: 「レコードを触らなければ安全」は成り立ちません。上位を切り替えれば下位は丸ごと入れ替わります。そして「あるはずのものが無い」は、たいてい自分が別の場所を見ている合図です。

    9. ルートドメインにCNAMEが置けない理由

    DNSのレコードには種類があります。よく使うのは次の2つです。

    Aレコード    … この名前は、このIPアドレスです
    CNAMEレコード … この名前は、別の名前の別名です

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

    ところがルートドメイン(www などが付かない、ドメインそのもの)にはCNAMEを置けません。理由はこうです。

    CNAMEは「この名前は別名です」という宣言なので、そのホスト名に他のレコードを共存させられません。「別名だ」と言っているのに他の情報も持っていたら矛盾するからです。

    ところがルートドメインには、DNSの仕組み上どうしても必要なレコードがあります。

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

    これらと共存できないため、置けないわけです。だからVercelはルートにはAレコード、www にはCNAMEを指定してきます。

    「ルートを www に転送して、www を本体にする」という構成がよく使われるのも、この事情と関係があります。実体を柔軟に追従できる側に置いておく、という設計です。

    10. 「反映されない」の9割はキャッシュ

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

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

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

    役に立つのがTTLの残り秒数です。

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

    TTLはキャッシュの残り寿命なので、600から218に減っていれば「これは382秒前の答えだ」と分かります。いま見ている情報がいつ時点のものかを読み取れるわけです。

    さらに、パソコンとブラウザも独自にキャッシュを持っています。「サーバー側は切り替わっているのに自分のブラウザだけ古い」という状態はよく起こります。シークレットウィンドウで開くか、スマホの回線から見ると切り分けられます。

    11. 原因が分からないときに何をするか

    最後に、いちばん学びが大きかった進め方の話です。

    前回、原因を2回外しました。「環境変数の設定が悪いのでは」「ファイアウォールが遮断しているのでは」——どれも筋は通っていますが、確かめずに設定をいじり、結果が変わらず、当たったのか外れたのかも判然としない、という時間を重ねました。

    打開したのは、原因を推測するのをやめて、エラーを表示させるためだけのコードを書いたことでした。書くのに10分もかかっていません。そして一発で答えが出ました。

    この経験から言えることが2つあります。

    ひとつは、推測で1回試すコストと、確実に答えを得るコストは、思っているほど差がないということ。「調べるのは面倒だから、とりあえず怪しいところを直してみよう」と考えがちですが、外したときの時間と、外したかどうかすら分からない不確かさを考えると割に合いません。

    もうひとつは、観測できない対象を推測で潰しても意味がないということ。値が見えない状態のまま「値が間違っているかも」と考えても、直したかどうかを確認する手段がありません。まずやるべきは見えるようにすることでした。

    この記事で扱った話は、実は同じ構造を持っています。

    cause を開ける              … 隠れているエラーを見えるようにする
    redirect: 'manual' で調べる … 隠れている転送を見えるようにする
    TTLの残り秒数を読む         … 情報の鮮度を見えるようにする
    診断用のコードを書く        … 内部の状態を見えるようにする

    デバッグとは、推理することではなく、観測できる範囲を広げていく作業なのだと思います。 見えていないものについて考えている時間が長いと感じたら、それは考えを進めるタイミングではなく、見る手段を用意するタイミングです。

    まとめ

    次に似た場面で思い出せるよう、要点だけ並べておきます。

    URLを調べるときは転送の自動追従を切ること。WordPressには home と siteurl の2つの基準があり、機能によってどちらを使うかが違うこと。fetch failed は cause を開けること。HTTPSは暗号化の前に本人確認をしていて、その段階で切れると通信は1バイトも発生しないこと。検証は実際に通信する経路で行うこと。エラーを飲み込むなら記録を残すこと。NEXT_PUBLIC_ の値はビルド時に埋め込まれるので、変更後は再ビルドが要ること。DNSは2階層で、上位を切り替えると下位は丸ごと入れ替わること。ルートドメインにCNAMEは置けないこと。「反映されない」はたいていキャッシュで、TTLがいつ時点の情報かを教えてくれること。

    そして、原因が分からないときは推測を重ねるより、観測できる範囲を広げること。これがいちばんの学びでした。

  • なぜこの構成にしたのか — ヘッドレス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で作り直した話 (4) デザイン調整・ブログ機能追加編

    ポートフォリオサイトをヘッドレスWordPress+Next.jsで作り直した話 (4) デザイン調整・ブログ機能追加編

    シリーズ最終回です。データがきちんと表示できるようになった後、「もとのサイトとだいぶ印象が違う」というフィードバックをもとに行ったデザイン調整と、このブログ機能を追加した経緯についてまとめます。

    もとのサイトの雰囲気に近づける

    ページ構成(実績・スキル・プロフィールを別ページに分ける形)はそのまま維持しつつ、見た目だけをもとのサイトに近づける方針にしました。具体的には、中央寄せの見出し+下線バーという共通の見出しコンポーネントを作ったり、ヘッダー・フッターに以前から使っていた「肉球マーク」のロゴを置き直したりしています。

    実績カードの画像・リンク・配色を見直す

    もともと実績カードには画像がなかったので、商用利用可能なオリジナル画像を4枚用意してカードに追加しました。また、実績の詳細ページへのリンクが軒並み404になっていた件は、前回の記事の通りスラッグの日本語表記が原因だったため、英数字への統一で解消しています。

    配色については、技術タグの色をハッシュ値だけで機械的に割り当てていたため、たとえば「JavaScript」が黄色ではなく別の色になってしまうなど、見た人が持っている技術のイメージと合わない配色になっていました。そこで、HTML・CSS・JavaScript・WordPress・PHPなど、よく知られた技術については実際のブランドカラーに近い色を明示的に割り当て、それ以外の名前だけハッシュ値ベースの配色にするよう調整しました。

    プロフィール画像は、新しく作り直すのではなく「もとのサイトの画像をそのまま使う」と決めたので、WordPressのメディアライブラリにもとの画像を取り込み直して差し替えています。

    カラーパレットの調整とブログ機能の追加イメージ

    ブログ機能を追加する

    ここまでで実績・スキル・プロフィールのリニューアルは一段落したので、追加で「ブログ」機能を作りました。専用のカスタム投稿タイプは作らず、WordPress標準の「投稿」をそのまま使い、カテゴリだけ「技術系」「読んだ本の感想」の2つを新設しています。Next.js側では /blog に一覧ページ、/blog/[slug] に詳細ページを用意し、一覧ページではカテゴリ別の絞り込みタブも付けました。今読んでいただいているこの記事も、その「技術系」カテゴリの投稿の1つです。

    シリーズを終えて

    静的サイトの書き換えから始まり、WordPressのDocker環境構築、GraphQLでのデータ取得、細かい不具合対応、デザインの調整、そしてブログ機能の追加まで、全4回で振り返ってきました。実務であまり触れる機会のないヘッドレスCMS構成を、自分のポートフォリオという実物を通して一通り経験できたのは、学習の題材として良かったと思っています。今後は「技術系」「読んだ本の感想」の記事を少しずつ増やしていく予定です。

  • ポートフォリオサイトをヘッドレス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エンコーディング」のような地味な足回りの部分は結局手を動かして初めて気づく類の問題でした。次回は、デザインをもとのサイトに近づける調整と、このブログ機能の追加について書きます。

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

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

    ヘッドレスWordPress + Next.jsで実績ページ(/projects/[slug])を作っていたとき、一覧からリンクをクリックすると404になる不具合に遭遇しました。

    原因

    WordPress側の投稿スラッグを日本語のまま(例: 「wordpressオリジナルテーマ作成」)にしていたことが原因でした。一覧ページのリンクはブラウザによってURLエンコードされるため、実際のリンク先は次のようになります。

    /projects/wordpress%E3%82%AA%E3%83%AA%E3%82%B8%E3%83%8A%E3%83%AB%E3%83%86%E3%83%BC%E3%83%9E%E4%BD%9C%E6%88%90

    Next.jsのApp Routerはこの値を自動でデコードしてくれると思い込んでいましたが、実行環境によってはparams.slugがエンコードされたまま渡ってくるケースがあり、WordPress側のGraphQL(nodeByUri)に渡すURIと文字列が一致せず、該当データが見つからない(=404)という結果になっていました。

    対処

    1. WordPress側の投稿スラッグをローマ字(例: wordpress-theme)に変更した(パーマリンクが読みやすくなる副次効果もある)
    2. 念のためNext.js側でもdecodeURIComponent(params.slug)を通してからGraphQLに渡すようにし、日本語スラッグの投稿が増えても壊れないよう防御的にした

    日本語タイトルの投稿を作るときは、パーマリンク欄でスラッグをローマ字に直す一手間を忘れないようにしたいです。