Use Cases (更新: 2026/6/7)

Claude CodeでPWA化:オフライン対応とService Workerで更新事故を防ぐ

「manifestを置けば完成」で更新事故った僕の失敗から、Claude CodeでPWAをオフライン対応する手順を、コピペできるService Worker付きで紹介します。

Claude CodeでPWA化:オフライン対応とService Workerで更新事故を防ぐ

教材LPを「アプリっぽく」したくて、Claude Codeに「PWAにして」とだけ頼んだ夜のことです。manifestとService Workerがサッと生えてきて、ホーム画面に追加できた。やった、と思いました。

翌日、記事を1本直して公開したら、スマホでは古い見出しのまま。リロードしても変わらない。Service Workerが昨日のHTMLを握って離さなかったんです。「動いた」と「壊れない」は、PWAでは別物でした。

PWAはmanifest.webmanifestを置けば終わり、ではありません。アイコン、Service Worker、オフラインページ、キャッシュ戦略、インストール導線、そして検証まで、ぜんぶ込みで初めて成立します。この記事では、僕が踏んだ地雷を先に踏み抜いておくので、あなたはコピペで安全な最小構成から始めてください。

この記事の要点

  • PWAの正体は「manifestでアプリ情報を渡す」+「Service Workerが通信とキャッシュの間に立つ」の二段構え。難しいのはコードよりキャッシュの判断
  • 一番やりがちな事故は、HTMLをCache Firstにして更新が反映されないこと。HTMLはNetwork First、画像はCache First、CSS/JSはStale While Revalidateから始めると安全。
  • POSTやログイン状態・決済・在庫みたいなユーザー別データは絶対にキャッシュしない。キャッシュは「保存」、保存していいものだけ保存する。
  • Claude Codeには「PWAにして」ではなく、成果物と検証条件を分けて渡す。壊れやすい前提(scope・start_url・デプロイパス)を先に確認させる。
  • PWAは実装より検証で差が出る。まずoffline.htmlが確実に出る最小構成を作り、DevToolsのApplicationパネルで一つずつ目視する。

Claude Codeの基本操作がまだ不安なら、先にClaude Code入門:最初の30分で成果を出す始め方に目を通しておくと、この記事のプロンプトをそのまま投げられます。公式の一次情報は、PWA全体がweb.devのPWA学習ガイド、インストール条件がMDNのinstallable PWAガイド、Claude Code本体がClaude Code公式ドキュメントにまとまっています。

PWAって、結局なにをしているのか

ややこしく見えますが、登場人物は2人だけです。

ひとりは manifest。ブラウザに「このサイトはこういうアプリです」と自己紹介するJSONです。アプリ名、起動URL、アイコン、テーマカラーを渡す。これがあるからホーム画面に追加できる。

もうひとりが Service Worker。ブラウザとネットワークの間に座る小さな受付係です。リクエストが来たら、ネットに取りに行くか、手元のキャッシュから返すか、通信が死んでたらオフラインページを出すか——その場で振り分けます。この受付係がいるおかげで、電波が切れても白画面にならない。

流れにすると、こうです。

ユーザー
  -> index.html を開く
  -> manifest.webmanifest でアプリ情報を読む
  -> register-sw.js で Service Worker を登録する
  -> sw.js が「HTML・画像・CSS/JS」を戦略別に振り分ける
  -> 通信できなければ offline.html を返す

PWA化で迷ったとき、最初に決めるのはコードではなく、この3つです。ここを曖昧にすると、あとで必ず事故ります。

決めること失敗すると起きること
開始URL(start_url / scope)/ または /app/scope外になり、Service Workerが効かない
キャッシュ対象HTML、CSS、JS、画像、offline.html404をキャッシュして永遠に更新されない
オフライン時の挙動offline.html、直近ページ、APIエラー表示白画面や、他人向けの古いデータが出る

「迷ったら全部キャッシュ」が、いちばん危険な発想です。記事・LP・教材サイトなら、HTMLはNetwork First・画像やフォントはCache First・CSS/JSはStale While Revalidateから始めると、頭が混乱しません。戦略ごとの使い分けの考え方はClaude Codeで実アプリ向けキャッシュ戦略を設計する方法と地続きなので、深掘りしたくなったらそちらへ。

Claude Codeには「成果物」と「検証条件」を分けて渡す

ここが今日いちばん伝えたいところです。「PWAにして」だけだと、Claude Codeは空のfetchハンドラを置いてインストール条件だけ満たし、肝心のオフライン挙動がスカスカ、なんてことが普通に起きます。悪気はなくて、ゴールが曖昧だからそうなる。

僕は今、こういう粒度で渡しています。成果物のリストと、踏んではいけない地雷を、最初から全部書く。

既存のVite/ReactアプリをPWA化してください。

要件:
- manifest.webmanifest を public 直下に追加
- 192x192、512x512、maskable 512x512 のアイコンを参照
- public/offline.html を追加
- public/sw.js で Service Worker を実装
- src/register-sw.js から登録
- HTML navigation は Network First、画像は Cache First、CSS/JS/font は Stale While Revalidate
- POST や外部オリジンはキャッシュしない
- Lighthouse と Chrome DevTools の Application パネルで確認できるチェックリストを最後に出す

注意:
- 空の fetch ハンドラで installability だけを満たそうとしない
- 古いキャッシュ削除と更新通知を必ず入れる
- 変更したファイルと、手動確認の手順を説明する

このプロンプトの狙いは、Claude Codeに「コードを書く」だけじゃなく「壊れやすい点を自分で確認する」仕事まで持たせることです。Service Workerは影響範囲がとにかく広い。だから既存ルーティング、ビルド出力先、publicディレクトリ、デプロイ先のパスを先に確認させてから編集させます。

冒頭の更新事故もそうでしたが、僕が最初に詰まったのはstart_urlとService Workerのscopeの不一致でした。人間が見落としやすい前提条件をプロンプトに先回りで書いておくと、出てくるコードのレビュー精度が一段上がります。

manifestとアイコンを作る

まずpublic/manifest.webmanifestから。nameはインストール画面やアプリ一覧に出る正式名、short_nameはホーム画面みたいな狭い場所で使う短い名前。scopeはService Workerが面倒を見る範囲、start_urlは起動時に開くURLです。

{
  "id": "/",
  "name": "ClaudeCodeLab PWA Demo",
  "short_name": "CCLab",
  "description": "Claude Codeで作るオフライン対応のPWAデモ",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#0f766e",
  "orientation": "portrait-primary",
  "prefer_related_applications": false,
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-maskable-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "shortcuts": [
    {
      "name": "オフラインで読む",
      "short_name": "Offline",
      "url": "/?shortcut=offline",
      "icons": [
        {
          "src": "/icons/icon-192.png",
          "sizes": "192x192"
        }
      ]
    }
  ]
}

次に、HTML側からmanifestを参照させます。Viteならindex.html、AstroやNext.jsなら共通レイアウトのhead設定に入れます。

<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f766e" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />

アイコンは最低でも192x192と512x512。Androidではmaskable iconが使われる場面があるので、ロゴを端まで広げず中央に余白を残した512x512のPNGを別に用意しておくと安全です。ここで地味に効くのが404チェック。manifestのJSON自体が読めても、参照しているアイコンが404だとインストール画面でつまずきます。Claude Codeにアイコン生成まで任せた場合も、最後は自分の目でブラウザを開いて200を確認してください。あと、manifestのJSONは文法に厳しいので、末尾カンマを残さないこと。これだけで無駄に1時間溶かしたことがあります。

Service Workerを実装する(ここが心臓部)

public/sw.jsを作ります。やることは3つ。インストール時にアプリシェル・オフラインページ・manifest・アイコンをキャッシュし、activate時に古いキャッシュを掃除し、fetch時にリクエストを戦略別に振り分ける。

下のコードはそのまま動きます。長く見えますが、install / activate / fetchの3イベントと、3つの戦略関数に分かれているだけです。

const VERSION = "2026-06-06";
const STATIC_CACHE = `static-${VERSION}`;
const RUNTIME_CACHE = `runtime-${VERSION}`;

// インストール時にまとめてキャッシュする「土台」のファイル群
const APP_SHELL = [
  "/",
  "/offline.html",
  "/manifest.webmanifest",
  "/icons/icon-192.png",
  "/icons/icon-512.png",
  "/icons/icon-maskable-512.png"
];

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches
      .open(STATIC_CACHE)
      .then((cache) => cache.addAll(APP_SHELL))
      .then(() => self.skipWaiting())
  );
});

self.addEventListener("activate", (event) => {
  const allowedCaches = [STATIC_CACHE, RUNTIME_CACHE];

  // バージョンが変わったら、古いキャッシュを全部消す
  event.waitUntil(
    caches
      .keys()
      .then((keys) =>
        Promise.all(
          keys
            .filter((key) => !allowedCaches.includes(key))
            .map((key) => caches.delete(key))
        )
      )
      .then(() => self.clients.claim())
  );
});

self.addEventListener("fetch", (event) => {
  const { request } = event;

  // GET以外(POSTなど)は触らない=キャッシュしない
  if (request.method !== "GET") return;

  // 外部オリジンも触らない
  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return;

  // 画面遷移(HTML)は Network First
  if (request.mode === "navigate") {
    event.respondWith(networkFirstPage(request));
    return;
  }

  // 画像は Cache First
  if (request.destination === "image") {
    event.respondWith(cacheFirst(request));
    return;
  }

  // CSS/JS/フォントは Stale While Revalidate
  if (["style", "script", "font"].includes(request.destination)) {
    event.respondWith(staleWhileRevalidate(request));
  }
});

// HTML:まずネットに取りに行き、ダメなら直近キャッシュ→offline.html
async function networkFirstPage(request) {
  const cache = await caches.open(RUNTIME_CACHE);

  try {
    const response = await fetch(request);
    if (response.ok) {
      await cache.put(request, response.clone());
    }
    return response;
  } catch {
    const cached = await cache.match(request);
    return (
      cached ||
      (await caches.match("/offline.html")) ||
      new Response("Offline", {
        status: 503,
        headers: { "Content-Type": "text/plain; charset=utf-8" }
      })
    );
  }
}

// 画像:あればキャッシュから即返す
async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(RUNTIME_CACHE);
    await cache.put(request, response.clone());
  }
  return response;
}

// CSS/JS:古い版をすぐ返しつつ、裏で新しい版を取りに行く
async function staleWhileRevalidate(request) {
  const cache = await caches.open(RUNTIME_CACHE);
  const cached = await cache.match(request);

  const networkPromise = fetch(request)
    .then((response) => {
      if (response.ok) {
        cache.put(request, response.clone());
      }
      return response;
    })
    .catch(() => undefined);

  if (cached) return cached;

  return (
    (await networkPromise) ||
    new Response("Network error", {
      status: 504,
      headers: { "Content-Type": "text/plain; charset=utf-8" }
    })
  );
}

この実装は、POSTと外部APIを意図的にキャッシュしません。ここを甘く見ると本当に痛い目に遭います。ログイン状態・決済・在庫・問い合わせフォームの中身を雑にキャッシュすると、ユーザーに古い情報や、最悪他人向けの情報を見せてしまう。PWAは便利ですが、キャッシュはあくまで「保存」です。保存していいものだけを保存する、という線引きを最初に決めておく。これが事故と無事故の分かれ目です。

オフラインfallbackを用意する

public/offline.htmlは、通信が切れたときに出す最後の砦です。凝ったUIはいりません。「何が起きたか」「いま何ならできるか」を短く伝えるだけ。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>オフラインです</title>
    <style>
      body {
        margin: 0;
        font-family: system-ui, sans-serif;
        background: #f8fafc;
        color: #0f172a;
        display: grid;
        min-height: 100vh;
        place-items: center;
      }
      main {
        max-width: 36rem;
        padding: 2rem;
      }
      a {
        color: #0f766e;
        font-weight: 700;
      }
    </style>
  </head>
  <body>
    <main>
      <h1>現在オフラインです</h1>
      <p>通信が戻ったら再読み込みしてください。直近に開いたページは表示できる場合があります。</p>
      <p><a href="/">トップへ戻る</a></p>
    </main>
  </body>
</html>

オフラインページには広告タグや外部フォントを入れないこと。そもそもネットがない前提の画面なので、外部依存が増えるほど「白画面」の引き金になります。

それから、これも僕がハマった罠。cache.addAll(APP_SHELL)は、リストの中に1つでも404が混ざると丸ごと失敗します。アイコンやoffline.htmlを追加した直後は、DevToolsのNetworkタブで全部200になっているか確認してから先に進んでください。1ファイルのtypoで「Service Workerがなぜか登録できない」と30分悩む、よくあるやつです。

Service Workerの登録と更新通知

Service Workerは、ブラウザに登録されて初めて動きます。src/register-sw.jsを作って、アプリのエントリーポイントから読み込みます。冒頭の「古いHTMLが残る」事故の対策が、この更新通知です。

export async function registerServiceWorker() {
  if (!("serviceWorker" in navigator)) {
    console.info("Service Worker is not supported in this browser.");
    return;
  }

  window.addEventListener("load", async () => {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js", {
        scope: "/"
      });

      // 新しい Service Worker が見つかったら、更新ボタンを出す
      registration.addEventListener("updatefound", () => {
        const worker = registration.installing;
        if (!worker) return;

        worker.addEventListener("statechange", () => {
          if (worker.state === "installed" && navigator.serviceWorker.controller) {
            showUpdateNotice();
          }
        });
      });
    } catch (error) {
      console.error("Service Worker registration failed:", error);
    }
  });
}

function showUpdateNotice() {
  const button = document.querySelector("[data-refresh-app]");
  if (!button) return;

  button.hidden = false;
  button.addEventListener(
    "click",
    () => {
      window.location.reload();
    },
    { once: true }
  );
}

Reactならmain.jsxmain.tsxで一行呼ぶだけです。

import { registerServiceWorker } from "./register-sw.js";

registerServiceWorker();

更新通知をわざわざ入れる理由は、Service Workerの更新が「すぐ全タブに反映される」とは限らないからです。古いタブが残っていると、ユーザーは古いHTMLと新しいJSの組み合わせを踏むことがある。これが地味にバグの温床になる。業務アプリや教材サイトでは、「更新があります」ボタンを明示するだけで問い合わせがはっきり減りました。Reactアプリ全体の設計を整える話はClaude CodeでReact開発を実戦投入する方法も合わせてどうぞ。

インストール導線を作る

Chrome系ブラウザでは、条件を満たすとbeforeinstallpromptイベントが発火する場合があります。ただし、これは全ブラウザ共通の標準的な合図ではありません。だから「使える環境ではボタンを出す、使えない環境はブラウザのメニューから追加してもらう」という控えめな実装にします。

let deferredPrompt = null;

window.addEventListener("beforeinstallprompt", (event) => {
  event.preventDefault();
  deferredPrompt = event;

  const installButton = document.querySelector("[data-install-app]");
  if (installButton) {
    installButton.hidden = false;
  }
});

document.querySelector("[data-install-app]")?.addEventListener("click", async () => {
  if (!deferredPrompt) return;

  deferredPrompt.prompt();
  const choice = await deferredPrompt.userChoice;
  console.info("Install prompt result:", choice.outcome);

  deferredPrompt = null;
});

window.addEventListener("appinstalled", () => {
  console.info("PWA was installed.");
});

このインストールボタンは、そのままCV導線にもなります。教材サイトなら「ホーム画面に追加して続きを読む」、SaaSなら「毎日使うダッシュボードをアプリ化する」、イベントサイトなら「会場でオフラインでも開けるように」。ただし、いきなり全画面モーダルで押し売りすると逆効果なので、記事末尾やダッシュボード右上みたいな、邪魔にならない場所にそっと置きます。導線の設計そのものを詰めたいならClaude CodeでCVするランディングページを実装する実践ガイドが参考になります。

LighthouseとDevToolsで検証する

正直に言うと、PWAは実装より検証で差が出ます。コードを書く時間より、こっちに時間を使うと思っておいてください。

いまのChromeでは、古いLighthouseのPWAカテゴリだけに頼った検証は適切ではありません。Chrome公式もPWA監査の扱いが変わったと説明しています。実務では、DevToolsのApplicationパネルでmanifest・Service Worker・Cache Storage・オフライン挙動を直接見て、Lighthouseのほうは Performance / Accessibility / Best Practices / SEO の確認に使う、という分担が現実的です。背景はChrome DevelopersのPWA install criteria更新記事にまとまっています。

ローカルで確認するときは、ビルド済みファイルをHTTPSかlocalhostで配信します。localhostはService Workerの検証に使えます。

npm run build
npx serve dist -l 4173
npx lighthouse http://localhost:4173 --view --only-categories=performance,accessibility,best-practices,seo

僕が毎回つぶしている手動チェックリストはこれです。上から順に見れば、たいていの事故は公開前に止まります。

チェック見る場所合格ライン
manifestが読めるDevTools > Application > Manifestname、start_url、iconsにエラーがない
Service Workerが登録されるApplication > Service Workerssw.jsがactivatedになっている
オフライン表示NetworkをOfflineにして再読み込みoffline.htmlか直近ページが出る
キャッシュの分離Application > Cache Storagestaticとruntimeが意図どおり分かれている
主要スコアLighthouseレポートパフォーマンスとSEOが悪化していない

Claude Codeに検証まで頼むなら、「ブラウザで見て」と曖昧に言わないこと。「Applicationパネルのどこを確認したか」「Network Offlineで何が表示されたか」「Lighthouseの主要スコア」を具体的に報告させます。余力があればPlaywrightでオンライン/オフラインのE2Eを足しておくと、次の更新でPWAが壊れたとき即座に気づけます。Core Web Vitalsまで踏み込みたいならClaude Codeでパフォーマンス最適化:計測からCore Web Vitals改善までと組み合わせると効きます。

どんなサイトでPWA化が効くのか

最初に身も蓋もないことを言うと、PWAは全サイトに必要ではありません。効くのは、再訪問が多くて、通信状態に左右され、ホーム画面に置く理由があるサービスです。

ユースケースPWA化の価値収益導線の例
技術ブログ・教材サイト通勤中や移動中に続きを読める有料テンプレート、講座、相談へ誘導
社内ダッシュボード毎日同じURLを開く手間が消える導入支援、運用設計、研修
イベント・会場案内回線が混む場所でも地図や予定が見られるスポンサー枠、追加資料、問い合わせ
EC・予約サイト商品画像や閲覧履歴を軽く表示できる再訪問、カート復帰、会員登録

PWAを収益につなげるなら、「アプリ化できます」で止めず、読了率・再訪率・CTAクリック・問い合わせ数を一緒に測るところまでやる。数字が動いて初めて、PWA化の良し悪しが言えます。

よくある質問

Q. iPhoneのSafariでもPWAは使えますか? A. ホーム画面への追加とオフライン表示は使えます。ただしAndroid/Chromeに比べて挙動の差があり、beforeinstallpromptは発火しません。iOSではユーザーが共有メニューから「ホーム画面に追加」する前提で導線を作ってください。

Q. PWAにするとApp StoreやGoogle Playに出せますか? A. PWAそのものはWebなので、ストア配信は別の話です。ストアに出したい場合はネイティブ寄りの選択肢になります。Web技術でモバイルアプリを作る方向ならClaude CodeでReact Nativeアプリを作るも検討してください。

Q. ローカルでService Workerを試したいのですが、httpでも動きますか? A. localhostなら動きます。本番はHTTPSが必須です。npx serve dist -l 4173でビルド成果物を配信すれば、手元でインストールやオフライン挙動を確認できます。

Q. コードを変えたのにブラウザが古いsw.jsを使い続けます。 A. よくある状態です。DevToolsのApplicationパネルから対象のService WorkerをUnregisterし、Cache Storageを消してから、初回訪問と更新後の両方を確認してください。VERSIONの値を更新すると、activate時に古いキャッシュが掃除されます。

Q. ブログ記事をオフラインで全部読めるようにしたいです。 A. やれますが、最初は欲張らないほうがいいです。まずoffline.htmlが確実に出る最小構成を作り、トップや直近に開いたページだけをNetwork Firstで拾う。全記事のプリキャッシュは、更新管理が一気に重くなるので、運用が回り始めてから段階的に広げてください。

実際に試した結果

この記事の内容を、僕の小さなVite製教材LPで実際に通しました。

ひとことで言えば、manifestとService Workerの追加そのものは短時間で終わりました。Claude Codeにファイル生成を任せれば、コードはあっという間に生えてきます。差がついたのはその先です。いちばん時間を食ったのは、アイコンパスが本当に200で返るかの確認、Network Offlineにしての再読み込み、そして冒頭でやらかした「古いキャッシュが残る」問題の潰し込みでした。

VERSIONを更新してactivate時に古いキャッシュを掃除する、updatefoundで更新ボタンを出す——この2つを入れた瞬間、あの「リロードしても古い見出し」が再現しなくなりました。Claude Codeはコードを速く書きますが、キャッシュ対象・更新タイミング・デプロイパスの判断は、最後は人間がレビューする領域です。

だからおすすめの順番はいつも同じ。まずoffline.htmlが確実に出る最小構成を作る。次に画像、CSS/JSと、壊れにくいものから一段ずつキャッシュを広げる。HTMLとAPIは最後まで慎重に。PWAは「動いた」で満足せず、「壊れない」まで検証して初めて完成です。

実装テンプレートやClaude Code用のプロンプトを丸ごと整えたい方は教材一覧からどうぞ。既存サイトのPWA化なら、Service Workerの設計レビュー、キャッシュ対象の棚卸し、Lighthouse/DevTools検証までまとめて研修・相談で扱えます。

#Claude Code #PWA #Service Worker #オフライン対応 #キャッシュ戦略
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。