Cloudflare Workers によるエッジ SEO

CDN エッジで SEO を修正・最適化する — タグの注入、大規模なリダイレクト、変更できない CMS へのパッチ適用。

📖 12 分で読めます 🕑 更新日 2026-06-22

エッジ SEO とは、オリジンサーバーがページを生成した、クライアントに届くに、ブラウザやクローラーが受け取る内容を変更する手法です。書き換えは CDN 上で行われます。あなたのケースでは、訪問者とサイトの間に位置する Cloudflare Worker 上で実行されます。

郵便局に立つ校正担当者をイメージしてください。著者(あなたの CMS)はすでに手紙を書き終え、封をしています。書き直してもらうことはできません。レガシープラットフォームを使っているのかもしれませんし、テンプレートを別チームが管理しているのかもしれませんし、変更が緊急なのかもしれません。そこで校正担当者は、最後のギリギリのタイミングで封を開け、canonical タグのタイプミスを直し、リンク切れの URL にリダイレクトのスタンプを押して、送り出します。受取人は手紙に手が加えられたことに気づきません。

このサイトはすでに Cloudflare Pages 上で動いているため、あなたはこの機能まで Worker 一つ分の距離 にいます。新しいベンダーも、タグマネージャーも、オリジンへのアクセスも必要ありません。必要なのは fetch ハンドラーと、Cloudflare のストリーミング HTML パーサーである HTMLRewriter です。このガイドの残りでは、いつそれが正しい選択なのか、知っておくべきパターン、完全な実例、そして気づかぬうちに失敗してしまうケースを示します。

いつ使うべきか

エッジ SEO は精密ツールであって、デフォルトの手段ではありません。通常の経路 — オリジンでテンプレートを編集する — が塞がれているか、遅すぎる場合に手を伸ばしてください。

状況なぜエッジが勝るのか
レガシーまたはロックされた CMSプラットフォームが <head> をレンダリングし、テンプレートに手を出せない。エッジはバックエンドに関係なく出力を書き換える。
SaaS / ホスティング型プラットフォームShopify、マーケティングサイトビルダー、エンドツーエンドで制御できないヘッドレスストアフロントなど。SSH で入れるサーバーはない — だが Worker はその前に立てる。
サイト全体の一貫性50,000 件の URL すべてに 同一の canonical や hreflang のロジックが必要。Worker 一つがそれを強制する。50,000 個のテンプレートが揃うことに賭けないで済む。
緊急ホットフィックス不具合のあるデプロイが noindex を本番に出してしまった。開発スプリントは 2 週間先。Worker での修正なら 60 秒で本番反映、しかも取り消し可能。
A/B SEO 実験テンプレートを分岐させずに、あるセクションへのトラフィックの 50% で title タグのパターンをテストする。

🧑‍💻 開発者視点: エッジが正しいレイヤーになるのは、変更が 横断的 (ルールによって多くのページに適用される)か 時間的にクリティカル (オリジンへのデプロイがボトルネックになっている)な場合です。変更が本当にページ固有で、しかもテンプレートを自分で制御できるなら、オリジンで行ってください — そこがあるべき場所であり、次のエンジニアが探しに来る場所です。

裏を返せば、すべてのエッジルールは、リポジトリの通常のレビューフローの 外側 に存在するロジックの一片です。付箋ではなく、本番インフラとして扱ってください。この点は「注意点」で再び取り上げます。

よくあるパターン

これらが主力です。それぞれ数行の HTMLRewriter で済みます。HTMLRewriter はレスポンスをストリーミングし、CSS セレクターにハンドラーをアタッチできるため、ドキュメント全体をメモリにバッファリングすることがありません。

canonical と hreflang を注入・修正する

最も一般的なエッジ SEO の仕事です。CMS が誤ったホストを指す canonical を出力したり(http、ステージングドメイン、末尾スラッシュの不一致)、二言語サイトで hreflang をまったく省略したりします。

class CanonicalFixer {
  constructor(url) {
    this.canonical = `https://example.com${new URL(url).pathname}`;
    this.found = false;
  }
  element(el) {
    // Rewrite an existing canonical to the correct absolute URL.
    el.setAttribute("href", this.canonical);
    this.found = true;
  }
}

これを <head>end ハンドラーと組み合わせ、タグが欠けていた場合にそれを 注入 します — 下の実例で完全な形を示します。

大規模なリダイレクト(301 / 302)

サイト移行は何千ものリンク切れ URL を残します。オリジンの設定や _redirects を肥大化させる代わりに、マップ(大規模な集合の場合は KV)を裏に置いた Worker から駆動します。

const REDIRECTS = {
  "/old-pricing": "/pricing",
  "/blog/2019/seo-tips": "/guides/seo-basics",
};

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const target = REDIRECTS[url.pathname];
    if (target) {
      return Response.redirect(`${url.origin}${target}`, 301);
    }
    return fetch(request); // pass through to origin
  },
};

💡 ヒント: 数万件のリダイレクトには、マップを Workers KV に移してキーで参照しましょう。KV の読み取りはエッジでキャッシュされるため、参照は高速なままで、コールドスタートのたびに巨大なオブジェクトを送り出さずに済みます。

メタタグと構造化データにパッチを当てる

欠けている meta description を追加したり、迷い込んだ noindex を削除したり、CMS が生成できない JSON-LD を注入したりします。構造化データの注入は、単に <script type="application/ld+json"><head> に追加するだけです。

class JsonLdInjector {
  constructor(data) {
    this.json = JSON.stringify(data);
  }
  element(head) {
    head.append(
      `<script type="application/ld+json">${this.json}</script>`,
      { html: true }
    );
  }
}

SEO の A/B 実験

トラフィックを決定論的に分割し(ハッシュ化した Cookie やパスのバケットで)、片方のバケットに variant の title/description を配信し、Search Console で表示回数と CTR の差を測定します。ユーザー向けの コンテンツ は同一に保ってください — テストしているのは SERP に表示される スニペットであって、異なるページを見せること(クローキング)ではありません。

User-Agent によるクローラー対応の処理

Googlebot や Bingbot を検出してルールを適用します — 例えば、完全にレンダリングされたフォールバックを配信したり、クローラー向けに調整したキャッシュヘッダーを追加したりします。これが正当なのは、クローラーとユーザーが 同じ意味のあるコンテンツ を受け取る場合 のみ です。コンテンツそのものを分岐させるのはクローキングです。「注意点」を参照してください。

const ua = request.headers.get("user-agent") || "";
const isBot = /googlebot|bingbot|duckduckbot/i.test(ua);

実例

以下は、このサイトのような二言語サイトにとって最も価値ある単一の仕事を行う、本番品質の完全な Worker です。それは、すべてのページで正しい canonical と一致する hreflang セットを保証する ことです — タグが存在すれば修正し、なければ注入します。これは冪等であり(二度実行しても何も変わらない)、それ以外のすべてはそのまま通過させます。

const SITE = "https://example.com";

// Map a path to its language alternates. Adapt to your routing.
function alternatesFor(pathname) {
  const en = pathname.startsWith("/zh/")
    ? pathname.replace(/^\/zh\//, "/en/")
    : pathname;
  const zh = pathname.startsWith("/en/")
    ? pathname.replace(/^\/en\//, "/zh/")
    : pathname;
  return [
    { lang: "en", href: `${SITE}${en}` },
    { lang: "zh", href: `${SITE}${zh}` },
    { lang: "x-default", href: `${SITE}${en}` },
  ];
}

// Rewrite an existing canonical to the canonical absolute URL.
class CanonicalRewriter {
  constructor(canonical) {
    this.canonical = canonical;
    this.seen = false;
  }
  element(el) {
    el.setAttribute("href", this.canonical);
    this.seen = true;
  }
}

// On </head>, inject anything the page was missing.
class HeadCloser {
  constructor(canonical, alternates, rewriter) {
    this.canonical = canonical;
    this.alternates = alternates;
    this.rewriter = rewriter;
  }
  element(head) {
    if (!this.rewriter.seen) {
      head.append(
        `<link rel="canonical" href="${this.canonical}">`,
        { html: true }
      );
    }
    // Always (re)assert hreflang. We strip old ones first (below),
    // so appending here yields exactly one correct set.
    for (const alt of this.alternates) {
      head.append(
        `<link rel="alternate" hreflang="${alt.lang}" href="${alt.href}">`,
        { html: true }
      );
    }
  }
}

export default {
  async fetch(request) {
    const response = await fetch(request);

    // Only rewrite HTML; leave assets, JSON, redirects untouched.
    const type = response.headers.get("content-type") || "";
    if (!type.includes("text/html")) return response;

    const url = new URL(request.url);
    const canonical = `${SITE}${url.pathname}`;
    const alternates = alternatesFor(url.pathname);
    const rewriter = new CanonicalRewriter(canonical);

    return new HTMLRewriter()
      // Remove any stale hreflang so we don't duplicate them.
      .on('link[rel="alternate"][hreflang]', { element: (el) => el.remove() })
      .on('link[rel="canonical"]', rewriter)
      .on("head", new HeadCloser(canonical, alternates, rewriter))
      .transform(response);
  },
};

これを安全にデプロイできる理由は次のとおりです。

  • コンテンツタイプでスコープを切っている。 HTML 以外のレスポンスはそのまま通過する — 画像や JSON API を壊すことは決してない。
  • 冪等である。 古い hreflang タグは削除され、その後で正しいセットがちょうど一つだけ追加される。再実行しても同一の結果になる。
  • ストリーミングする。 HTMLRewriter はレスポンスが流れるそばから処理する。ドキュメント全体のバッファはなく、レイテンシのコストもほぼゼロ。
  • canonical を修正または注入する を一度のパスで行う。CanonicalRewriter は既存のタグを修正し、HeadCloser はタグが一つも見つからなかった場合にのみ追加する。

Wrangler でデプロイし、ゾーンにルーティングします。

npx wrangler deploy
# Then bind a route in wrangler.toml, e.g.
# routes = [{ pattern = "example.com/*", zone_name = "example.com" }]

🧑‍💻 開発者視点: SITE 定数と alternatesFor のマッピングは一箇所にまとめ、マッピング関数をユニットテストしてください。言語ルーティングのロジックこそ、エッジ SEO のバグが潜む場所です — /en/en/ やトップページを誤って扱う正規表現は、サイト全体にわたって誤った alternates を黙って出力します。

注意点

エッジ SEO が強力なのは、まさにそれが見えないからです — そしてそれが、あなたを噛む仕組みでもあります。本番トラフィックに Worker をルーティングする前に、以下を体に染み込ませてください。

  • すべての書き換えを冪等にする。 オリジンが後になって、あなたが注入しているタグを出力し始めた場合でも、Worker が重複を生んではいけません。上記のパターンは、まさにこの理由で「削除してから追加」しています。重複した canonical は、canonical がないことよりも悪いです。
  • オリジンと争わない。 エッジと CMS が 異なる canonical や矛盾する robots ディレクティブを主張してはいけません。将来のテンプレート変更がエッジルールと衝突すると、Google は矛盾するシグナルを目にし、インデックスが非決定論的になります。すべてのエッジルールをドキュメント化し、オリジンチームがその存在を知れるようにしてください。
  • キャッシュに注意する。 Cloudflare は 書き換え後の HTML をキャッシュすることがあります。書き換えがリクエスト(User-Agent、Cookie、地域)に依存する場合、それに応じてキャッシュキーを変える必要があります。さもないと、一つの variant を全員に配信してしまいます。リクエスト依存のロジックには、Cache-Control / Vary を意図的に設定するか、それらのルートではキャッシュをバイパスしてください。
  • レイテンシとエラー予算に気を配る。 エッジケースで例外を投げる Worker は、クローラーに対してページを空白にしかねません。リスクのあるロジックを try/catch で包み、フェイルオープン してください — 500 ではなく、元の response をそのまま返します。
  • オリジンのコードと同じように監視する。 wrangler tail を仕込み、書き換え回数をログに記録し、変更のたびに Search Console の ページ削除 レポートを確認してください。エッジのバグは、スタックトレースではなくインデックスの異常として現れます。
  • 決してクローキングに使わない。 クローラーにユーザーとは異なる コンテンツ を配信すること — Googlebot にはキーワードを詰め込んだテキスト、人間にはクリーンなページ — は Google のスパムポリシーに違反し、手動による対策のリスクを負います。UA ベースの処理は配信とキャッシュには問題ありませんが、二つの異なるページを見せる許可証ではありません。

⚠️ 注意: エッジ SEO の最大の罪は、ユーザーが見るものとクローラーが見るものとの間の 見えない乖離 です。Search Console の URL 検査(「クロールされたページを表示」)で、ボットが受け取ったレンダリング済み HTML が、通常のブラウザが受け取るものと一致することを確認してください。両者が実質的に異なるなら、意図の有無にかかわらず、あなたはクローキングをしています。

このガイドの位置づけ

エッジ SEO は、スタックのより広範な build レイヤーの内側にある一つの戦術です — テンプレートに手を出せないときに、テクニカル SEO をどう強制するかという話です。その土台となる基礎(レンダリング、canonical 戦略、内部リンク)については、Build レイヤー を参照してください。そして Worker がレスポンスを形作り始めたら、クロールディレクティブがそれと一致しているか確認してください。robots & sitemap ツールrobots.txt とサイトマップを検証し、エッジ・オリジン・クロールルールがすべて Google に同じ物語を伝えるようにしましょう。

重要なポイント

  • ✅ オリジンの かつクライアントの で SEO を修正するためにエッジを使う — ロックされた CMS、ホスティング型プラットフォーム、サイト全体のルール、緊急ホットフィックスに最適。
  • HTMLRewriter はレスポンスをストリーミングするため、canonical・hreflang・meta・JSON-LD の注入はレイテンシをほぼ消費せず、ドキュメント全体のバッファも不要。
  • ✅ すべての書き換えを 冪等 にする(削除してから追加)。これにより、オリジンが後で出力するかもしれないタグを決して重複させない。
  • ✅ 書き換えを text/html にスコープし、ロジックを try/catch で包み、元のレスポンスに フェイルオープン する。
  • ✅ Worker を本番コードとして扱う: バージョン管理し、各ルールをドキュメント化してオリジンと衝突しないようにし、変更のたびに Search Console を監視する。
  • ✅ ユーザーとクローラーでコンテンツを決して乖離させない — UA 処理は配信のためであり、クローキングのためではない。