🦁

Next.js と Serwist でオフラインアプリを構築する

に公開

LocallyTools がリリースされてしばらく経ちました。製品はブラウザの機能を利用してローカルでデータを処理していますが、ユーザーからは常に信頼性について疑問を持たれていました。「本当にファイルがアップロードされないのですか?」「データが漏洩しないことをどう証明できますか?」

これらの疑問により、完全なオフライン機能の実装を決意しました。考え方はシンプルです:アプリがインターネットから切断された状態で正常に動作すれば、データがユーザーのデバイスから離れないことを確実に証明できます。

実装過程で、オフライン化がもたらす利点は予想をはるかに超えていることがわかりました:

ユーザーの信頼構築:製品がオフラインで動作することで、データのセキュリティを直接証明できます。ユーザーは私の約束を「信じる」必要がなく、自分で検証できます。

サーバー負荷の軽減:リソースがローカルにキャッシュされることで、ほとんどのリクエストがローカルキャッシュから処理され、サーバーの負荷と帯域幅コストが大幅に削減されます。

SEO 競争の回避:現在、ツールボックス市場は激しい競争状態で、誰もが SEO ランキングを争っています。しかし、LocallyTools を PWA にすることで、ユーザーは直接デスクトップに保存して使用でき、毎回検索エンジンでツールを探す必要がありません。これにより激しい SEO 競争を回避し、ユーザーの定着率を大幅に向上させることができます。

もちろん、実装は最初に思っていたよりも複雑でした。Next.js アプリでオフライン機能を実装した経験を共有します。

技術スタック

使用したのは:

  • Next.js 15 (app router)
  • Pnpm
  • TypeScript
  • TailwindCSS 4.0
  • @serwist/next

最初は Workbox を試しましたが、Serwist の方が設定が簡単で、特に Next.js との組み合わせに優れていることがわかりました。これは Next.js 公式ドキュメントでも推奨されている方法の一つです。

Serwist の設定

インストールは簡単です:

pnpm add @serwist/next serwist

next.config.tsで Serwist を設定:

// next.config.ts

import withSerwistInit from "@serwist/next";
import { execSync } from "child_process";

// git commit hashをキャッシュバージョン番号として使用
const revision = execSync("git rev-parse HEAD", { encoding: "utf8" })
  .trim()
  .slice(0, 7);

const withSerwist = withSerwistInit({
  cacheOnNavigation: true,
  reloadOnOnline: false,
  swSrc: "app/sw.ts",
  swDest: "public/sw.js",
  disable: process.env.NODE_ENV === "development",
  additionalPrecacheEntries: [
    { url: "/", revision },
    // オプション
    { url: "/offline", revision },
  ],
});

export default withSerwist({
  // その他の設定...
});

Serwist コア実装

これはオフラインアプリケーションの核心部分です。キャッシュ戦略を間違えると、ユーザーが古いコンテンツを見るか、オフライン時に何も読み込まれません。

キャッシュ戦略の選択

4 つの主要なキャッシュ戦略の比較:

戦略 動作原理 適用場面 利点 欠点 典型的な用例
CacheFirst キャッシュを優先読み取り、なければネットワークリクエスト 静的リソース 極めて高速な読み込み 古いコンテンツの可能性 画像、フォント、動画
NetworkFirst ネットワークを優先リクエスト、失敗時にキャッシュ読み取り 動的コンテンツ データが常に新鮮 ネットワーク依存 API インターフェース、ユーザーデータ
StaleWhileRevalidate 即座にキャッシュを返し、同時にバックグラウンドで更新 バランス場面 高速レスポンス + バックグラウンド更新 実装の複雑度が高い CSS、JS ファイル
NetworkOnly 常にネットワークリクエスト、キャッシュを使用しない 機密操作 データが絶対に新鮮 オフライン時利用不可 ログイン、決済インターフェース

Service Worker 設定

app/sw.tsファイルはキャッシュ戦略を定義する場所です:

// app/sw.ts

import {
  Serwist,
  StaleWhileRevalidate,
  ExpirationPlugin,
  type RuntimeCaching,
  type PrecacheEntry,
  type SerwistGlobalConfig,
} from "serwist";
import { defaultCache } from "@serwist/next/worker";

declare global {
  interface WorkerGlobalScope extends SerwistGlobalConfig {
    // Change this attribute's name to your `injectionPoint`.
    // `injectionPoint` is an InjectManifest option.
    // See https://serwist.pages.dev/docs/build/configuring
    __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
  }
}

declare const self: ServiceWorkerGlobalScope;

// カスタムキャッシュ戦略
const cacheStrategies: RuntimeCaching[] = [
  {
    matcher: ({ request, url: { pathname }, sameOrigin }) =>
      request.headers.get("RSC") === "1" &&
      request.headers.get("Next-Router-Prefetch") === "1" &&
      sameOrigin &&
      !pathname.startsWith("/api/"),
    handler: new StaleWhileRevalidate({
      cacheName: "pages-rsc-prefetch",
      plugins: [
        new ExpirationPlugin({
          maxEntries: 200,
          maxAgeSeconds: 24 * 60 * 60, // 24時間
          maxAgeFrom: "last-used",
        }),
      ],
    }),
  },
  {
    matcher: ({ request, url: { pathname }, sameOrigin }) =>
      request.headers.get("RSC") === "1" &&
      sameOrigin &&
      !pathname.startsWith("/api/"),
    handler: new StaleWhileRevalidate({
      cacheName: "pages-rsc",
      plugins: [
        new ExpirationPlugin({
          maxEntries: 200,
          maxAgeSeconds: 24 * 60 * 60, // 24時間
          maxAgeFrom: "last-used",
        }),
      ],
    }),
  },
  {
    matcher: ({ request, url: { pathname }, sameOrigin }) =>
      request.headers.get("Content-Type")?.includes("text/html") &&
      sameOrigin &&
      !pathname.startsWith("/api/"),
    handler: new StaleWhileRevalidate({
      cacheName: "pages",
      plugins: [
        new ExpirationPlugin({
          maxEntries: 200,
          maxAgeSeconds: 24 * 60 * 60, // 24時間
          maxAgeFrom: "last-used",
        }),
      ],
    }),
  },

  // その他のリソースのキャッシュ戦略
  // {
  //   matcher: /\.(?:mp4|webm)$/i,
  //   handler: new StaleWhileRevalidate({
  //     cacheName: 'static-video-assets',
  //     plugins: [
  //       new ExpirationPlugin({
  //         maxEntries: 32,
  //         maxAgeSeconds: 7 * 24 * 60 * 60,
  //         maxAgeFrom: 'last-used',
  //       }),
  //      new RangeRequestsPlugin(),
  //     ],
  //   }),
  // },
  ...
];

const serwist = new Serwist({
  precacheEntries: self.__SW_MANIFEST,
  skipWaiting: true,
  clientsClaim: true,
  navigationPreload: true,
  runtimeCaching: [...cacheStrategies, ...defaultCache],

  // オプション
  fallbacks: {
    entries: [
      {
        url: "/offline",
        matcher({ request }) {
          return request.destination === "document";
        },
      },
    ],
  },
});

serwist.addEventListeners();

上記の設定では、ページナビゲーション関連の特別なリクエストを特別に処理しています:

  • RSC プリフェッチ (Prefetch)RSC: 1 + Next-Router-Prefetch: 1、リンクにマウスホバー時にトリガーされ、Next.js がバックグラウンドで事前読み込みします。
  • RSC ナビゲーション (Navigation)RSC: 1、ユーザーが実際にリンクをクリックしてページジャンプする時にトリガーされます。
  • HTML ドキュメント (Page Shell)Content-Type: text/html、初回アクセスまたはハードリフレッシュ時にトリガーされます。

StaleWhileRevalidateを使用することで、ページの高速読み込みを確保しながら、コンテンツの更新も維持できます。

プラグインの説明

  • ExpirationPlugin:キャッシュの有効期限を管理し、ストレージ容量の無制限増加を防ぎます
  • RangeRequestsPlugin:大きなファイル(動画、音声など)の範囲リクエストをサポートします

manifest と metadata の設定

manifest の設定

app/manifest.tsで Web アプリケーションの情報を定義します。これは Web アプリケーションの身分証明書のようなもので、このマニフェストファイルがあって初めて、ブラウザがあなたのサイトを「インストール可能」(Installable)と認識し、ユーザーに「ホーム画面に追加」のプロンプトを表示します。

ローカルでhttp://localhost:3000/manifest.webmanifestにアクセスして設定が正しいかチェックできます。

// app/manifest.ts

import type { MetadataRoute } from "next";

export default async function manifest(): Promise<MetadataRoute.Manifest> {
  return {
    name: "Your App Name",
    short_name: "Your App Name",
    description: "Your App Description",
    start_url: "/",
    display: "standalone",
    background_color: "#ffffff",
    theme_color: "#ffffff",
    icons: [
      {
        src: "/android-chrome-192x192.png",
        sizes: "192x192",
        type: "image/png",
        purpose: "any",
      },
      {
        src: "/android-chrome-512x512.png",
        sizes: "512x512",
        type: "image/png",
        purpose: "any",
      },
      {
        src: "/android-chrome-512x512.png",
        sizes: "512x512",
        type: "image/png",
        purpose: "maskable",
      },
    ],
    orientation: "portrait",
  };
}

metadata の設定

ルートレイアウトファイルapp/layout.tsxmetadataオブジェクトを更新し、manifestファイルの位置情報を追加します:

// app/layout.tsx

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Your App Name",
  description: "Your App Description",

  // この行が重要
  manifest: "/manifest.webmanifest",
};

Service Worker のデバッグ

Service Worker のデバッグは主に Chrome DevTools(または Edge DevTools)を通じて行います。

Application -> Service Workers

このパネルにはいくつかのオプションとボタンがあり、Service Worker のデバッグに役立ちます。

1. 上部のチェックボックス

  • Offline: チェックすると、Chrome が切断状態をシミュレートします。すべてのネットワークリクエストが即座に失敗し、ネットケーブルを抜いたような状態になります。PWA のオフライン機能テストに非常に有用です。
  • Update on reload: チェックすると、ページをリフレッシュするたびにブラウザが最新の SW を強制的にインストール・アクティベートし、「コードが更新されない」キャッシュ問題を回避します。開発時にチェックすることを推奨します。
  • Bypass for network: チェックすると、一時的に SW を完全にバイパスし、すべてのリソースをネットワークから直接読み込みます。SW を素早く「無効化」するのと同等で、問題の切り分けに非常に有用です。問題が SW キャッシュにあるのかアプリ自体にあるのかを判断するのによく使われます。
  • Push, Sync, Periodic sync: プッシュ通知やバックグラウンド同期を使用している場合、ここでこれらのイベントを手動でトリガーしてテストできます。

2. 操作ボタン

  • Update: ブラウザにサーバーでsw.jsファイルの更新をチェックするよう促します。更新があれば、ブラウザは新しいバージョンのダウンロードとインストールを開始します。
  • Unregister: 現在の Service Worker を登録解除します
  • start/stop: 現在の Service Worker を開始/停止します
  • skipWaiting: 新しいバージョンの Service Worker が waiting 状態にある時にこのボタンが表示されます。クリックすると、待機中の新しい Service Worker を強制的に activate 状態に移行させ、ページを制御します。

Application -> Cache Storage

このパネルはキャッシュの検査と管理に使用されます:

  • runtimeCachingが有効かチェック: app/sw.tsで定義したcacheName(例:apis, pages)がここに独立したエントリとして表示されます。
  • キャッシュ内容の確認: 任意のキャッシュエントリをクリックして、キャッシュされた内容がここに正しく表示されるかを確認します。
  • 手動クリーンアップ: 右クリックで「Delete」ボタンを押し、任意のキャッシュエントリを削除します。

Network

このパネルでリソースの出所を確認します。主に重要な情報欄Sizeから確認します:

  • Size: (ServiceWorker): このリソースが Service Worker キャッシュから提供され、ネットワークリクエストを経由していないことを示します。これはキャッシュ戦略(特にCacheFirstStaleWhileRevalidate)が期待通りに動作していることを確認する直接的な証拠です。
  • Size: (from memory cache)または(from disk cache)は、リソースがブラウザの HTTP キャッシュから来ており、Service Worker キャッシュからではないことを示します。

NetworkパネルのDisable cacheオプションをチェックしても、これはブラウザの HTTP キャッシュのみを無効化し、Service Worker キャッシュは無効化されません。Service Worker を無効化するには、Application パネルのBypass for networkを使用してください。

潜在的な落とし穴

  • HTTPS 制限:Service Worker は HTTPS 環境でのみ動作しますが、localhostは例外です。ローカル IP(例:192.168.31.10)でアクセスする場合、SW は登録されません。

  • コードが更新されない問題:99%の場合、古い SW の問題です。解決方法:

    1. DevTools のUpdate on reloadがチェックされているか確認
    2. Service Workers パネルで手動で skipWaiting または Unregister 古い SW
    3. 最終手段:Application -> Storage パネルでClear site dataをクリック。これによりすべてのキャッシュ、SW、IndexedDB などがクリアされ、サイトが「工場出荷時設定」に戻ります。
  • 開発環境の推奨事項:PWA 関連機能の開発完了後、serwistを無効化することを強く推奨します。PWA 機能を専門的にデバッグする必要がある時のみ有効化し、キャッシュ地獄問題を回避します。「明らかにコードを変更したのに、なぜページが変わらないのか?!」ほどイライラすることはありません。

// next.config.ts

const withSerwist = withSerwistInit({
  disable: process.env.NODE_ENV === "development",
   ...
});
  • 強制リフレッシュの回避reloadOnOnline: falseに設定します。reloadOnOnline: trueに設定すると、ユーザーが offline から online に変わった後、location.reload()がトリガーされ、ページが強制的にリフレッシュされます!ユーザーがフォームを入力中で、サイトにリアルタイム保存機能がない場合、ユーザーが苦労して入力したフォーム内容が瞬時に消失します。そのため、reloadOnOnlinefalseに設定することを推奨します。
// next.config.ts

const withSerwist = withSerwistInit({
  reloadOnOnline: false,
  ...
});

オフラインの旅を始めよう

アプリをオフライン対応にしたい場合、以下のステップから始めることを推奨します:

  1. 既存アプリの評価:どの機能をローカル化できるか?
  2. 静的リソースから開始:画像、CSS、JS にまず CacheFirst を使用
  3. 段階的に API キャッシュを追加:データの重要性に基づいて戦略を選択
  4. 様々なネットワーク状況をテスト:切断テスト

オフライン機能は技術の誇示ではなく、ユーザーの時間への敬意です。ユーザーが地下鉄、飛行機、またはネットワークが不安定な環境でも、スムーズにアプリを使用できることは非常に価値があります。

本当のオフラインアプリがどのようなものか体験したいですか?LocallyToolsを試してみてください。


関連記事

オフライン優先開発に興味がある場合、推奨記事:


オフライン機能の実装でどのような落とし穴に遭遇しましたか? またはキャッシュ戦略について質問がありますか?コメント欄で経験を共有し、一緒にオフラインアプリのベストプラクティスを完善しましょう。

この記事が役に立った場合は、いいねとシェアをお忘れなく! 👍

Discussion