この記事のポイント
  • HTMLRewriterはCloudflare Workersの標準API。オリジンが返したHTMLを、エッジでストリーミングしながらCSSセレクタ単位で「切り貼り」できる
  • ページ全体をメモリに展開しないゼロコピーのストリーミング設計(Rust製lol-htmlをWASM化)なので、巨大なHTMLでも低遅延・低メモリで処理できる
  • 用途はOGP/メタタグの動的差し替え、A/Bテスト、JSON-LD注入、Cookie同意バナーの後付け、URL書き換えなど。ビルドを介さずリクエスト単位で出し分けたいときに効く
  • 当サイト ai-heartland.com はHTMLRewriterは使っていない。代わりにPages Functionsのmiddlewareでボディを丸ごと差し替える方式を採っている(後述)

「ビルドし直さずに、エッジで返す直前のHTMLだけちょっと書き換えたい」——この願いをかなえるのがCloudflareのHTMLRewriterだ。

OGP画像のURLをアクセス元に応じて差し替える。記事ページにJSON-LDを後付けする。広告や同意バナーを全ページに一括で注入する。こうした「HTMLへの後付け加工」を、オリジンのコードに手を入れずWorkersの数十行で実現できる。

この記事では、HTMLRewriterが何者で、どんな仕組みで動き、どこで使うべきかを、Cloudflare公式仕様と当サイト自身の実装事情をふまえて整理する。

30秒で理解するHTMLRewriter

正体:Cloudflare Workers上でHTMLを解析・変換するAPI。new HTMLRewriter() を作り、CSSセレクタにハンドラを紐づけ、transform(response) で適用する
やり方:DOMを全部メモリに読む「のではなく」、HTMLをストリーム(小さなチャンクの連続)として流しながら、一致した要素だけを書き換える
強み:低遅延・低メモリ。ハンドラ内で await fetch も使えるので、外部APIの結果を差し込める
向く用途:リクエストごとに出し分けたい動的なHTML加工(OGP・A/Bテスト・構造化データ注入)
向かない用途:全ユーザー共通で固定の内容(それはビルド時に作るほうが速い)

HTMLRewriterとは|Cloudflare WorkersのHTML変換API

HTMLRewriterは、Cloudflare Workersランタイムが標準で備えるHTML変換APIだ。公式ドキュメントはこれを「Workersアプリケーションの中で使えるjQueryのような体験」と表現している。CSSセレクタで要素を選び、属性を足したり、タグを差し込んだり消したりできる。

使い方の骨格は驚くほど単純だ。HTMLRewriter のインスタンスを1つ作り、on(セレクタ, ハンドラ) でハンドラを登録し、最後に transform(response) でレスポンスに適用する。

export default {
  async fetch(request) {
    // オリジン(または静的アセット)からHTMLを取得
    const response = await fetch(request);

    // <title> を書き換えるだけの最小例
    return new HTMLRewriter()
      .on("title", {
        element(element) {
          element.setInnerContent("エッジで書き換えたタイトル");
        },
      })
      .transform(response);
  },
};

ポイントは transform(response) が返すのが新しいResponseオブジェクトであることだ。元のレスポンスのボディを読み切ってから加工するのではなく、ボディのストリームに変換器を挟む。つまりブラウザは、Workersがオリジンから受け取ったぶんから順に、書き換え済みのHTMLを受け取っていく。

HTMLRewriterの本質は「HTMLを文字列として置換する」ことではなく、「HTMLの構造を理解したストリーミングパーサに、書き換えフックを差し込む」ことだ。だから正規表現でタグを置換するような壊れ方をしない。

なお、Cloudflare PagesでホスティングしているサイトでもPages Functions(後述)の中で同じAPIを呼べる。Workers単体かPagesかに関係なく、同じランタイムの同じ構文で書ける。

ai-heartland.com(自サイト)でHTMLRewriterは使っているか

結論から言うと、当サイト ai-heartland.com(このメディア)はHTMLRewriterを使っていない。正直に書いておく。リポジトリを実測した結果は以下のとおりだった。

・トップ階層の _worker.js は無し
HTMLRewriter という文字列はソースコードのどこにも登場しない
・ただしCloudflareのエッジ機能自体は使っている

実際に使っているのは、HTMLRewriterではなくPages Functionsのミドルウェアだ。docs/functions/_middleware.js が、同じURLに対してリクエスタの Accept ヘッダを見て出し分けている。

// docs/functions/_middleware.js(抜粋・要約)
export const onRequest = async (context) => {
  const { request, env, next } = context;
  const url = new URL(request.url);

  // AIエージェントが Accept: text/markdown で来たら…
  if (wantsMarkdown(request) && POST_PATH_RE.test(pathname)) {
    // ビルド時に用意した Markdown ソースを「丸ごと」返す
    const mdResponse = await env.ASSETS.fetch(
      new URL(`/posts-md${pathname}index.md`, url.origin).toString()
    );
    if (mdResponse.status === 200) {
      const headers = new Headers(mdResponse.headers);
      headers.set("content-type", "text/markdown; charset=utf-8");
      return new Response(mdResponse.body, { status: 200, headers });
    }
  }
  return next(); // 通常はそのままHTMLを返す
};

ここで注目してほしいのは、当サイトのmiddlewareがHTMLを書き換えていない点だ。やっているのは「別ファイル(Markdownソース)を丸ごと差し替えて返す」ことであり、HTMLの中身に手を入れる処理ではない。だからHTMLRewriterは不要だった。

もしこのmiddlewareでHTMLRewriterを使うなら?

たとえば「AIクローラーが来たときだけ、HTMLの <head> に追加のJSON-LDを注入する」「記事ページの末尾に出典リンクのブロックを後付けする」といったHTML自体の加工が必要になった瞬間、HTMLRewriterの出番になる。next() が返したHTMLレスポンスを new HTMLRewriter().on("head", …).transform(res) に通せばよい。当サイトは今のところその要件が無いため使っていない、という整理だ。

このように「使っていない理由」まで含めて把握しておくと、HTMLRewriterを導入すべき分岐点が見えてくる。鍵は「返すファイルを差し替えたいのか/返すHTMLの中身を書き換えたいのか」だ。後者ならHTMLRewriterになる。

HTMLRewriterのアーキテクチャ|lol-htmlとストリーミングパーサ

HTMLRewriterの速さと安全性は、その内部実装に由来する。エンジンはCloudflareがOSSとして公開しているRust製HTMLパーサlol-html(Low Output Latency HTML rewriter)で、これがWebAssemblyとしてWorkersランタイムに組み込まれている。

lol-htmlの設計思想は名前のとおり「出力遅延を最小化する」ことにある。一般的なDOMパーサはHTML全体を読み込んでツリーを構築してから操作するが、lol-htmlはゼロコピーのストリーミング処理を行う。入ってきたバイト列を順に解析し、書き換えが確定した部分から即座に出力していく。

この設計が公式ドキュメントの重要な注意書きにつながる。「テキストチャンクは、字句ツリー上のテキストノードと同じものではない」——つまり1つのテキストノードが、ネットワークの都合で複数のチャンクに分割されてハンドラに届くことがある。

flowchart LR A[オリジンHTML
ReadableStream] --> B[lol-html
ストリーミングパーサ] B --> C{セレクタ照合} C -->|一致| D[ハンドラ呼び出し
element / text / comment] C -->|不一致| E[そのまま通過] D --> F[書き換えを適用] E --> G[出力ストリーム] F --> G G --> H[ブラウザへ
逐次レスポンス]

この図のとおり、HTMLは左から右へ「流れながら」処理される。一致しなかった大部分の要素はパーサを素通りし、一致した要素だけがハンドラに渡って書き換えられる。だからページ全体をメモリに展開せずに済み、巨大なHTMLでもメモリ使用量が一定に保たれる。

「全部読んでから加工する」のではなく「流しながら必要箇所だけ加工する」——これがHTMLRewriterが低遅延・低メモリである理由だ。

HTMLRewriterの基本構文|element / text / commentハンドラ

HTMLRewriterで書き換えを行うには、on(セレクタ, ハンドラ) にハンドラオブジェクトを渡す。ハンドラは3種類のメソッドを任意で持てる。

element(element):セレクタに一致した要素に対して呼ばれる
text(text):要素内のテキストチャンクごとに呼ばれる
comments(comment):要素内のHTMLコメントに対して呼ばれる

加えて、ドキュメント全体に対する onDocument(ハンドラ) もあり、こちらは doctype / comments / text / end(ドキュメント終端)を扱える。

Element APIでできること

element ハンドラが受け取るElementオブジェクトは、属性操作と挿入・削除のメソッドを備える。主なものは以下のとおりだ。

・属性:getAttribute(name) / hasAttribute(name) / setAttribute(name, value) / removeAttribute(name)
・挿入:before(content) / after(content) / prepend(content) / append(content)(開始タグ直後/終了タグ直前などに挿入)
・置換・削除:replace(content) / remove() / removeAndKeepContent()(タグだけ消して中身は残す) / setInnerContent(content)
・終了タグ:onEndTag(handler) で終了タグを捕まえて加工できる

挿入系メソッドの第2引数 { html: true } を渡すと、コンテンツを生のHTMLとして挿入する。省略するとテキストとしてエスケープされる。XSSを避けるため、ユーザー入力を差し込むときは html: true を付けないのが鉄則だ。

実用的な例として、OGP画像を後付け・差し替えするコードを示す。

// <head> に og:image を注入し、既存の og:title を書き換える
const rewriter = new HTMLRewriter()
  .on('meta[property="og:image"]', {
    element(el) {
      el.setAttribute("content", "https://example.com/dynamic-ogp.webp");
    },
  })
  .on("head", {
    element(el) {
      // og:image が存在しないページには末尾に追記
      el.append(
        '<meta property="og:image" content="https://example.com/dynamic-ogp.webp">',
        { html: true }
      );
    },
  });

return rewriter.transform(await fetch(request));

text APIとストリーミングの落とし穴

text ハンドラは、HTMLの「流れる」性質がもっとも表面化する場所だ。Textオブジェクトは読み取り専用の text(チャンクの文字列)と lastInTextNode(このチャンクがテキストノードの最後かどうか)を持つ。

// テキストノード全体を対象に置換したい場合のバッファリング
let buffer = "";
const rewriter = new HTMLRewriter().on("p.notice", {
  text(chunk) {
    buffer += chunk.text;
    if (chunk.lastInTextNode) {
      // ここで初めてテキストノード全体が揃う
      chunk.replace(buffer.replace("旧表記", "新表記"));
      buffer = "";
    } else {
      // 途中チャンクは消しておき、最後にまとめて差し込む
      chunk.remove();
    }
  },
});

ありがちな失敗text(chunk) { if (chunk.text.includes("検索語")) … } のように、単一チャンクの文字列だけで判定してしまうこと。語句がチャンク境界をまたぐと検知できず、たまに取りこぼす不安定なバグになる。テキストノード単位の判定は必ず lastInTextNode までバッファに溜めてから行う。

ハンドラ内の非同期処理

3種類のハンドラはいずれも Promise<void> を返せる。つまり async にして await できる。これにより、要素を見つけてから外部リソースを参照し、結果を差し込むという処理が書ける。

// 商品要素を見つけて在庫APIを叩き、バッジを差し込む
new HTMLRewriter().on("div.product[data-id]", {
  async element(el) {
    const id = el.getAttribute("data-id");
    const stock = await fetch(`https://api.example.com/stock/${id}`).then((r) => r.json());
    if (stock.soldOut) {
      el.prepend('<span class="badge">売り切れ</span>', { html: true });
    }
  },
});

ただしハンドラ内で多数のfetchを直列に待つと、その分レスポンスの開始が遅れる。ストリーミングの利点を活かすなら、外部参照は最小限にとどめたい。

HTMLRewriterのユースケース|OGP差し替え・A/Bテスト・構造化データ注入

HTMLRewriterが力を発揮するのは「オリジンのHTMLには手を入れず、リクエストごとにエッジで後付け加工したい」場面だ。代表的なユースケースを挙げる。

OGP/メタタグの動的差し替え:アクセス元の言語・キャンペーン・ABグループに応じて og:imageog:title を出し分ける
A/Bテスト:Cookieで振り分けたグループに応じて、見出しやCTAボタンの文言・色を書き換える。クライアントJSのちらつき(FOUC)が出ない
JSON-LD / 構造化データの注入:記事ページの <head> にArticleやFAQのJSON-LDを後付けし、テンプレートを汚さずSEOを底上げする
Cookie同意バナーの後付け:全ページのHTMLに同意バナーのHTMLとスクリプトを一括注入する。各ページを編集しなくてよい
URL書き換え・リンク補正a[href]img[src] を走査し、相対パスの正規化やCDNドメインへの差し替えを行う
広告・計測タグの注入</body> 直前に計測スニペットを差し込む

これらを「実装コスト」と「効果」で並べると、優先順位が見えてくる。

quadrantChart title HTMLRewriterユースケースのコストと効果 x-axis 実装コスト低 --> 実装コスト高 y-axis 効果小 --> 効果大 quadrant-1 まず着手 quadrant-2 投資対象 quadrant-3 後回し quadrant-4 限定的 OGP動的差し替え: [0.3, 0.82] JSON-LD注入: [0.25, 0.72] ABテスト: [0.62, 0.78] Cookie同意バナー注入: [0.42, 0.6] URL書き換え: [0.22, 0.45] 計測タグ注入: [0.3, 0.4]

まず効果が出やすいのは「OGP差し替え」と「JSON-LD注入」だ。実装が軽く、SEOやソーシャル流入に直結する。A/Bテストは効果が大きい一方で、グループ振り分け・計測・統計判定の仕組みまで含めると実装コストが上がる。

HTMLRewriterの制限と注意点

便利な反面、ストリーミングAPIゆえの制約がある。導入前に押さえておきたい。

Workersの実行制限に従う:CPU時間・メモリ・サブリクエスト数の上限はWorkersのプラン制限がそのまま効く。ハンドラ内の重い処理はCPU時間を食う
例外で全体が止まる:ハンドラが例外を投げると、その時点でパースは即時中断され、変換後のレスポンスボディはエラーになる。元の未変換ボディは破棄(クローズ)される。ハンドラ内は確実にtry/catchで握るか、失敗してもよい設計にする
テキストはチャンク分割される:前述のとおり、テキストノード単位の判定は lastInTextNode を見る。文字列連結に頼った素朴な実装は不安定になる
セレクタはCSSセレクタ* / 要素型 / クラス .x / ID #x / 属性 [a=b] / 子孫 E F / 子 E > F / :nth-child などに対応する。一方でXPathや複雑な兄弟探索は使えない
文字エンコーディング:UTF-8前提で考えるのが安全。レスポンスの content-type のcharsetと実体がずれていると意図しない結果になりうる
前方しか見られない:ストリーミングなので「後ろに出てくる要素を見てから前を書き換える」ことは原則できない。後方参照が必要な加工はHTMLRewriter単体では不向き

設計の指針

・ハンドラは軽く・冪等に。1要素1責務で書く
・外部参照(fetch/KV)は本当に必要なときだけ。直列待ちはレスポンス開始を遅らせる
・テキスト書き換えは必ず lastInTextNode で締める
・失敗時に素のHTMLを返すフォールバックを用意しておく

HTMLRewriterと類似技術の比較|ESI・ビルド時生成

「HTMLを動的に組み立てる」アプローチはHTMLRewriterだけではない。代表的な3方式を比較する。

観点HTMLRewriterESI(Edge Side Includes)ビルド時生成(SSG)
処理タイミングリクエスト時・エッジリクエスト時・エッジ/キャッシュ層デプロイ前に1回
書き換えの粒度CSSセレクタ単位で任意esi:include等の専用タグ位置テンプレート全体
動的な出し分け得意(Cookie/地域/時刻)断片の差し込みは可不可(全員同じ)
プログラマビリティJSで自由に記述宣言的・限定的ビルドスクリプト次第
レイテンシ低(ストリーミング)低〜中最速(静的配信)
向く用途後付け加工・AB・OGP・注入パーソナライズ断片の合成全員共通の確定コンテンツ

判断軸はシンプルだ。全員に同じものを返すならビルド時生成が最速。返す直前にHTMLの中身を出し分けたいならHTMLRewriter。当サイトのように「返すファイルそのものを丸ごと差し替える」だけならHTMLRewriterすら不要で、middlewareの分岐で足りる。

Pages FunctionsとWorkersでのHTMLRewriterの違い

HTMLRewriterのAPIはWorkersでもPages Functionsでも同一だが、「どこに差し込むか」が少し違う。

Workers単体では、fetch ハンドラ内でオリジンへのリクエストを送り、その戻り値を transform に通すのが基本形だ。一方Pages Functionsでは、_middleware.jsfunctions/api/*.js の中で context.next() が返すレスポンス(=Pagesが本来返すHTML)を transform に通す。

flowchart TD CF[Cloudflare エッジ] --> W[Workers
単体スクリプト] CF --> P[Pages
静的サイト + Functions] W --> HR1[fetch の戻りを
transform で書き換え] P --> MW[Pages Functions
_middleware.js] MW --> NX[context.next の
HTMLレスポンス] NX --> HR2[transform で書き換え]

Pages Functionsで <head> にJSON-LDを注入する例は次のようになる。当サイトの _middleware.js を拡張するなら、まさにこの形だ。

// functions/_middleware.js — next() の HTML に後付け注入する形
export const onRequest = async (context) => {
  const response = await context.next();
  const ct = response.headers.get("content-type") || "";
  if (!ct.includes("text/html")) return response; // HTML以外は素通し

  return new HTMLRewriter()
    .on("head", {
      element(el) {
        el.append(
          '<script type="application/ld+json">{"@context":"https://schema.org"}</script>',
          { html: true }
        );
      },
    })
    .transform(response);
};

注意点として、content-type がHTMLでないレスポンス(画像・JSON・Markdown配信など)にHTMLRewriterを通すのは無意味かつ有害だ。必ずHTMLだけに限定して適用する。当サイトのmiddlewareがMarkdownを返すルートでは、HTMLRewriterを通さない設計にするのが正しい。

Jekyll・静的サイト × Cloudflare HTMLRewriterのパターン

当サイトはJekyll製の静的サイトをCloudflare Pagesで配信している。こうした「静的サイト+エッジ」構成でHTMLRewriterが効く典型パターンを挙げる。

ビルドを汚さない後付け:テーマやレイアウトを編集せず、全ページ共通の要素(バナー・計測・構造化データ)をエッジで注入する。テーマ更新のたびに差分が出ない
A/Bテストのちらつき回避:静的HTMLにクライアントJSでABを当てるとFOUCが出るが、エッジで書き換えればHTMLが届いた時点で確定している
段階的な動的化:基本は静的配信で高速・低コストを維持しつつ、必要なページ・必要な要素だけをHTMLRewriterで動的化する。全面SSRに踏み切らずに済む

静的サイトの「速さ・安さ」を保ったまま、ピンポイントで動的化できるのがHTMLRewriterの旨味だ。当サイトが今は使っていないのは、こうした要件がまだ無いからにすぎない。OGPをアクセス文脈で出し分けたくなった日には、_middleware.js に数十行足すだけで導入できる——という見通しが立っている点が重要だ。

なお、ホスティング基盤の選定そのものについては、Cloudflare PagesとVercelを比較した解説も参考になる。エッジ実行・サーバーレスの全体像はVercelとは?Next.js最適化サーバーレスからAI SDK・BotID・AI Gatewayまで2026年最新ガイドで整理している。

まとめ|HTMLRewriterは「返す直前のHTMLを切り貼り」する道具

HTMLRewriterは、Cloudflare Workers/Pagesのエッジで、HTMLをストリーミングしながらCSSセレクタ単位で書き換えるAPIだ。Rust製lol-htmlのゼロコピー設計により、巨大なHTMLでも低遅延・低メモリで扱える。

使う判断:返す直前のHTMLの中身をリクエストごとに出し分けたいとき
使わない判断:全員に同じHTMLを返す(→ビルド時生成)/返すファイルを丸ごと差し替えるだけ(→middlewareの分岐で足りる)
当サイトの現状:HTMLRewriterは不使用。Pages Functionsのmiddlewareでボディを丸ごと差し替える方式を採用している

「ビルドし直さずにエッジでHTMLをちょっと書き換えたい」という要件が出てきたら、HTMLRewriterを思い出してほしい。数十行で、テンプレートを汚さずに動的化できる。

参照ソース

HTMLRewriter · Cloudflare Workers docs — 公式リファレンス。コンストラクタ・on/onDocument・各ハンドラAPI・セレクタ仕様・エラー時挙動の一次ソース
cloudflare/lol-html (GitHub) — HTMLRewriterの内部エンジンであるRust製ストリーミングHTMLパーサのOSSリポジトリ