Skip to content
Published on

PWA完全ガイド2025:ネイティブアプリ品質のWeb体験

Authors

はじめに

2025年(ねん)、PWA(Progressive Web App)はもはや実験的(じっけんてき)な技術(ぎじゅつ)ではありません。Twitter(X)、Starbucks、Pinterest、Spotifyなど多数(たすう)の大手(おおて)サービスがPWAを導入(どうにゅう)し、ネイティブアプリに匹敵(ひってき)するユーザー体験(たいけん)を提供(ていきょう)しています。Google PlayストアとMicrosoftストアではPWAを直接(ちょくせつ)登録(とうろく)でき、iOSもPWAサポートを段階的(だんかいてき)に拡大(かくだい)しています。

このガイドでは、Service Workerのライフサイクルから5つのキャッシング戦略(せんりゃく)、Workboxの活用(かつよう)、プッシュ通知(つうち)、オフラインファーストアーキテクチャ、Next.js PWA統合(とうごう)、そしてアプリストア配布(はいふ)まで体系的(たいけいてき)にカバーします。

キーフレーズ整理(せいり)

表現(ひょうげん)意味(いみ)
Service Workerブラウザバックグラウンドで実行(じっこう)されるJavaScriptプロキシ。ネットワークリクエストの傍受(ぼうじゅ)、キャッシング、プッシュ通知処理(つうちしょり)
Cache-First Strategyキャッシュから先(さき)に応答(おうとう)し、なければネットワークから取得(しゅとく)する戦略(せんりゃく)。オフラインサポートの核心(かくしん)
Web App ManifestPWAのメタデータを定義(ていぎ)するJSONファイル。アプリ名(めい)、アイコン、開始(かいし)URL、ディスプレイモードなど
WorkboxGoogleが作(つく)ったService Workerライブラリ。キャッシング戦略(せんりゃく)、プリキャッシング、ルーティングを簡単(かんたん)に実装(じっそう)

1. PWAとは何(なに)か?

1.1 PWAの核心的特性(かくしんてきとくせい)

PWAは、Web技術(ぎじゅつ)で構築(こうちく)されながらもネイティブアプリのような体験(たいけん)を提供(ていきょう)するアプリケーションです。

PWA3つの核心要素:

┌─────────────────────────────────────────┐
Progressive Web App├─────────────┬─────────────┬─────────────┤
ReliableFastEngaging  (信頼性)      (高速)     (没入感)│             │             │             │
- オフライン│ - 即時ロード│ - ホーム画面│
- 安定した  │ - スムーズ  │ - 全画面    │
│   ネットワーク│  スクロール│ - プッシュ  │
│             │ - 60fps     │ - インストール│
└─────────────┴─────────────┴─────────────┘

1.2 PWA vs ネイティブアプリ vs 通常(つうじょう)のWeb

機能(きのう)通常(つうじょう)のWebPWAネイティブアプリ
インストール不要(ふよう)任意(にんい)必須(ひっす)
オフラインサポートなしありあり
プッシュ通知(つうち)なしありあり
アプリストア不可(ふか)可能(かのう)(TWA/PWABuilder)デフォルト
アップデート即時(そくじ)即時(そくじ)/バックグラウンドストア審査(しんさ)が必要(ひつよう)
ファイルサイズ0(URL接続(せつぞく))数(すう)KB~数(すう)MB数十(すうじゅう)~数百(すうひゃく)MB
ハードウェアアクセス限定的(げんていてき)拡大中(かくだいちゅう)完全(かんぜん)
開発(かいはつ)コスト低(ひく)い低(ひく)い高(たか)い(プラットフォーム別(べつ))
SEO優秀(ゆうしゅう)優秀(ゆうしゅう)不可(ふか)

1.3 PWA導入成果(どうにゅうせいか)事例(じれい)

主要企業のPWA成果:

Twitter Lite:
- ページあたりのデータ消費70%削減
- ツイート送信75%増加
- 直帰率20%減少

Pinterest:
- 広告収益44%増加
- コアエンゲージメント指標60%増加
- ログインユーザー40%増加

Starbucks:
- ネイティブアプリ比99.84%小さいサイズ
- 日次アクティブユーザー2倍増加

Uber:
- 3Gで3秒以内にロード
- コアアプリサイズ50KB

2. Service Workerのライフサイクル

2.1 Service Workerとは?

Service Workerは、ブラウザとネットワークの間(あいだ)で動作(どうさ)するプロキシスクリプトです。メインスレッドとは別(べつ)のスレッドで実行(じっこう)され、DOMに直接(ちょくせつ)アクセスできません。

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
Web App   │────▶│Service Worker│────▶│   Network  (Main  (Proxy)  (Server)Thread)   │◀────│              │◀────│             │
└─────────────┘     │   ┌──────┐   │     └─────────────┘
                    │   │Cache │   │
                    │   │ API  │   │
                    │   └──────┘   │
                    └──────────────┘

2.2 ライフサイクルの段階(だんかい)

Service Workerライフサイクル:

1. Registration(登録)
   └─▶ navigator.serviceWorker.register('/sw.js')

2. Installation(インストール)
   └─▶ 'install'イベント発生
   └─▶ 静的リソースのプリキャッシング

3. Waiting(待機)
   └─▶ 前のSWがアクティブなら待機

4. Activation(アクティベーション)
   └─▶ 'activate'イベント発生
   └─▶ 古いキャッシュの削除

5. Fetch(リクエスト傍受)
   └─▶ 'fetch'イベントでネットワークリクエスト制御

6. Idle / Terminated(アイドル/終了)
   └─▶ イベントがなければブラウザが終了可能

2.3 基本的(きほんてき)なService Worker実装(じっそう)

// sw.js - Service Workerファイル
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.png',
  '/offline.html',
];

// Installイベント: 静的リソースのプリキャッシング
self.addEventListener('install', (event) => {
  console.log('[SW] Installing...');
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('[SW] Pre-caching static assets');
        return cache.addAll(STATIC_ASSETS);
      })
      .then(() => self.skipWaiting())
  );
});

// Activateイベント: 古いキャッシュの削除
self.addEventListener('activate', (event) => {
  console.log('[SW] Activating...');
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => {
            console.log('[SW] Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    }).then(() => self.clients.claim())
  );
});

// Fetchイベント: リクエストの傍受
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        if (cachedResponse) {
          return cachedResponse;
        }
        return fetch(event.request);
      })
      .catch(() => {
        if (event.request.mode === 'navigate') {
          return caches.match('/offline.html');
        }
      })
  );
});

2.4 登録(とうろく)コード

// main.js - アプリエントリポイント
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      });

      console.log('SW registered:', registration.scope);

      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              showUpdateNotification();
            }
          }
        });
      });
    } catch (error) {
      console.error('SW registration failed:', error);
    }
  });
}

function showUpdateNotification() {
  const banner = document.createElement('div');
  banner.innerHTML = '新しいバージョンがあります。<button id="reload">更新</button>';
  banner.className = 'update-banner';
  document.body.appendChild(banner);

  document.getElementById('reload').addEventListener('click', () => {
    window.location.reload();
  });
}

3. 5つのキャッシング戦略(せんりゃく)

3.1 Cache First(キャッシュ優先(ゆうせん))

キャッシュにあればキャッシュから、なければネットワークから取得(しゅとく)します。静的(せいてき)リソースに最適(さいてき)です。

// Cache First戦略
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).then((response) => {
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      });
    })
  );
});
リクエストフロー:
Client ──▶ Cache ──(hit)──▶ レスポンス返却
              
           (miss)
           Network ──▶ レスポンス返却 + キャッシュ保存

3.2 Network First(ネットワーク優先(ゆうせん))

ネットワークを先(さき)に試(ため)し、失敗(しっぱい)したらキャッシュから取得(しゅとく)します。APIデータに適(てき)しています。

// Network First戦略
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => caches.match(event.request))
  );
});

3.3 Stale While Revalidate(古(ふる)いものを使用後(しようご)に更新(こうしん))

キャッシュから即座(そくざ)に応答(おうとう)しつつ、バックグラウンドでネットワークからキャッシュを更新(こうしん)します。

// Stale While Revalidate戦略
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return cached || fetchPromise;
      });
    })
  );
});

3.4 Cache Only / Network Only

// Cache Only - プリキャッシュされたリソースのみ
self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request));
});

// Network Only - キャッシュ不要なリクエスト(例:分析データ)
self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request));
});

3.5 戦略選択(せんりゃくせんたく)ガイド

戦略(せんりゃく)ユースケースメリットデメリット
Cache First静的リソース(CSS, JS, 画像)高速応答、オフライン更新遅延
Network FirstAPIデータ、ニュースフィード常に最新低速ネットワークで遅延
Stale While Revalidate頻繁に変わる静的リソース高速かつ最新最初のリクエストは古いデータ
Cache Onlyバージョン管理されたリソース完全オフライン更新不可
Network Only決済、認証常に最新オフライン不可

4. Web App Manifest

4.1 Manifestの作成(さくせい)

{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "An awesome PWA that works offline",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2196F3",
  "orientation": "portrait-primary",
  "scope": "/",
  "lang": "ja",
  "dir": "ltr",
  "categories": ["productivity", "utilities"],
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Desktop view"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Mobile view"
    }
  ],
  "shortcuts": [
    {
      "name": "New Task",
      "short_name": "Task",
      "url": "/tasks/new",
      "icons": [{ "src": "/icons/task.png", "sizes": "96x96" }]
    }
  ]
}

4.2 HTMLでのManifestリンク

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- Manifestリンク -->
  <link rel="manifest" href="/manifest.json">

  <!-- iOSサポート -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="default">
  <meta name="apple-mobile-web-app-title" content="MyPWA">
  <link rel="apple-touch-icon" href="/icons/icon-152x152.png">

  <!-- テーマカラー -->
  <meta name="theme-color" content="#2196F3">

  <title>My PWA</title>
</head>
<body>
  <!-- アプリコンテンツ -->
</body>
</html>

4.3 Displayモード比較(ひかく)

モード説明(せつめい)ブラウザUI
fullscreen全画面(ぜんがめん)、ステータスバー非表示(ひひょうじ)なし
standalone独立(どくりつ)アプリとして表示(ひょうじ)ステータスバーのみ
minimal-ui最小限(さいしょうげん)のブラウザUI戻(もど)る/更新(こうしん)ボタン
browser通常(つうじょう)のブラウザタブ完全(かんぜん)なブラウザUI

5. WorkboxによるService Worker

5.1 Workboxの紹介(しょうかい)

WorkboxはGoogleが作(つく)ったService Workerライブラリで、キャッシング戦略(せんりゃく)を宣言的(せんげんてき)に実装(じっそう)できます。

# Workbox CLIインストール
npm install workbox-cli --save-dev

# またはWebpackプラグイン
npm install workbox-webpack-plugin --save-dev

5.2 Workboxでキャッシング戦略(せんりゃく)を実装(じっそう)

// sw.js with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// プリキャッシング(ビルド時に注入)
precacheAndRoute(self.__WB_MANIFEST);

// 画像: Cache First + 有効期限設定
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images-cache',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30日
      }),
    ],
  })
);

// CSS/JS: Stale While Revalidate
registerRoute(
  ({ request }) =>
    request.destination === 'style' ||
    request.destination === 'script',
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
  })
);

// API呼び出し: Network First
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 5,
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5分
      }),
    ],
  })
);

// Google Fonts: Cache First
registerRoute(
  ({ url }) => url.origin === 'https://fonts.googleapis.com' ||
               url.origin === 'https://fonts.gstatic.com',
  new CacheFirst({
    cacheName: 'google-fonts',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 30,
        maxAgeSeconds: 365 * 24 * 60 * 60, // 1年
      }),
    ],
  })
);

5.3 バックグラウンド同期(どうき)

// Background Syncでオフラインフォーム送信
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';

const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {
  maxRetentionTime: 24 * 60, // 24時間(分単位)
  onSync: async ({ queue }) => {
    let entry;
    while ((entry = await queue.shiftRequest())) {
      try {
        await fetch(entry.request);
        console.log('[BG Sync] Replayed:', entry.request.url);
      } catch (error) {
        console.error('[BG Sync] Failed:', error);
        await queue.unshiftRequest(entry);
        throw error;
      }
    }
  },
});

// POSTリクエストにBackground Syncを適用
registerRoute(
  ({ url, request }) =>
    url.pathname === '/api/submit' && request.method === 'POST',
  new NetworkOnly({
    plugins: [bgSyncPlugin],
  }),
  'POST'
);

6. プッシュ通知(つうち)

6.1 プッシュ通知(つうち)アーキテクチャ

プッシュ通知フロー:

┌──────────┐ サブスクライブ ┌──────────────┐
Client  │──────────────▶│  Push Server (Browser) (FCM/APNS)└────┬─────┘               └──────┬───────┘
     │ サブスクリプション情報        │
     │ 送信                        │
     ▼                            │
┌──────────┐   プッシュ送信  ◀───┘
AppServer└──────────┘

6.2 サブスクリプション実装(じっそう)

// クライアント: プッシュ通知のサブスクライブ
async function subscribePush() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.log('Notification permission denied');
    return;
  }

  const registration = await navigator.serviceWorker.ready;

  const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
  const convertedKey = urlBase64ToUint8Array(vapidPublicKey);

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: convertedKey,
  });

  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  console.log('Push subscription successful');
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

6.3 サーバーからプッシュ送信(そうしん)

// Node.jsサーバー: web-pushライブラリ
const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:admin@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: '新しいメッセージ',
        body: '田中さんがメッセージを送りました。',
        icon: '/icons/icon-192x192.png',
        badge: '/icons/badge-72x72.png',
        data: {
          url: '/messages/123',
          timestamp: Date.now(),
        },
        actions: [
          { action: 'reply', title: '返信', icon: '/icons/reply.png' },
          { action: 'dismiss', title: '閉じる', icon: '/icons/close.png' },
        ],
      })
    );
    console.log('Push sent successfully');
  } catch (error) {
    if (error.statusCode === 410) {
      await removeSubscription(subscription.endpoint);
    }
    console.error('Push failed:', error);
  }
}

6.4 Service Workerでのプッシュ受信(じゅしん)

// sw.js: プッシュイベント処理
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};

  const options = {
    body: data.body || 'New notification',
    icon: data.icon || '/icons/icon-192x192.png',
    badge: data.badge || '/icons/badge-72x72.png',
    vibrate: [200, 100, 200],
    data: data.data || {},
    actions: data.actions || [],
    tag: data.tag || 'default',
    renotify: true,
  };

  event.waitUntil(
    self.registration.showNotification(data.title || 'Notification', options)
  );
});

// 通知クリック処理
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const urlToOpen = event.notification.data.url || '/';

  if (event.action === 'reply') {
    event.waitUntil(clients.openWindow(urlToOpen + '?action=reply'));
  } else {
    event.waitUntil(
      clients.matchAll({ type: 'window' }).then((clientList) => {
        for (const client of clientList) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus();
          }
        }
        return clients.openWindow(urlToOpen);
      })
    );
  }
});

7. オフラインファーストアーキテクチャ

7.1 IndexedDBを活用(かつよう)したローカルデータ保存(ほぞん)

// IndexedDBラッパー(idbライブラリ使用)
import { openDB } from 'idb';

class LocalStore {
  constructor(dbName, storeName) {
    this.dbName = dbName;
    this.storeName = storeName;
  }

  async getDB() {
    return openDB(this.dbName, 1, {
      upgrade(db) {
        if (!db.objectStoreNames.contains('tasks')) {
          const store = db.createObjectStore('tasks', {
            keyPath: 'id',
            autoIncrement: true,
          });
          store.createIndex('status', 'status');
          store.createIndex('syncStatus', 'syncStatus');
          store.createIndex('updatedAt', 'updatedAt');
        }
      },
    });
  }

  async getAll() {
    const db = await this.getDB();
    return db.getAll(this.storeName);
  }

  async add(item) {
    const db = await this.getDB();
    item.syncStatus = 'pending';
    item.updatedAt = Date.now();
    return db.add(this.storeName, item);
  }

  async update(item) {
    const db = await this.getDB();
    item.syncStatus = 'pending';
    item.updatedAt = Date.now();
    return db.put(this.storeName, item);
  }

  async getPendingSync() {
    const db = await this.getDB();
    const tx = db.transaction(this.storeName, 'readonly');
    const index = tx.store.index('syncStatus');
    return index.getAll('pending');
  }
}

const taskStore = new LocalStore('myApp', 'tasks');
await taskStore.add({
  title: '新しいタスク',
  status: 'active',
  createdAt: Date.now(),
});

7.2 同期(どうき)戦略(せんりゃく)

// オンライン復帰時の同期
class SyncManager {
  constructor(localStore, apiUrl) {
    this.localStore = localStore;
    this.apiUrl = apiUrl;
    this.setupListeners();
  }

  setupListeners() {
    window.addEventListener('online', () => this.sync());
  }

  async sync() {
    const pendingItems = await this.localStore.getPendingSync();

    for (const item of pendingItems) {
      try {
        const response = await fetch(this.apiUrl, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(item),
        });

        if (response.ok) {
          item.syncStatus = 'synced';
          await this.localStore.update(item);
        }
      } catch (error) {
        console.error('Sync failed for item:', item.id, error);
      }
    }
  }
}

8. Next.js PWA統合(とうごう)

8.1 next-pwaセットアップ

npm install @ducanh2912/next-pwa
// next.config.js
const withPWA = require('@ducanh2912/next-pwa').default({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  register: true,
  skipWaiting: true,
  cacheOnFrontEndNav: true,
  workboxOptions: {
    runtimeCaching: [
      {
        urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
        handler: 'CacheFirst',
        options: {
          cacheName: 'google-fonts',
          expiration: {
            maxEntries: 4,
            maxAgeSeconds: 365 * 24 * 60 * 60,
          },
        },
      },
      {
        urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
        handler: 'StaleWhileRevalidate',
        options: {
          cacheName: 'static-image-assets',
          expiration: {
            maxEntries: 64,
            maxAgeSeconds: 24 * 60 * 60,
          },
        },
      },
      {
        urlPattern: /\/api\/.*$/i,
        handler: 'NetworkFirst',
        options: {
          cacheName: 'apis',
          networkTimeoutSeconds: 10,
          expiration: {
            maxEntries: 16,
            maxAgeSeconds: 24 * 60 * 60,
          },
        },
      },
    ],
  },
});

module.exports = withPWA({
  reactStrictMode: true,
});

8.2 App RouterでのManifest

// app/manifest.ts (Next.js App Router)
import type { MetadataRoute } from 'next';

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'My Next.js PWA',
    short_name: 'NextPWA',
    description: 'A Progressive Web App built with Next.js',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#000000',
    icons: [
      {
        src: '/icons/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icons/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
      },
    ],
  };
}

9. アプリストア配布(はいふ)

9.1 Google Playストア(TWA)

Trusted Web Activity(TWA)を使用(しよう)して、PWAをGoogle Playストアに配布(はいふ)できます。

# BubblewrapでTWA作成
npx @nicolo-ribaudo/bubblewrap init --manifest https://myapp.com/manifest.json
npx @nicolo-ribaudo/bubblewrap build
Google Play配布の手順:

1. Digital Asset Linksの設定
   - /.well-known/assetlinks.jsonファイルを作成
   - アプリ署名証明書のSHA-256フィンガープリントを含める

2. TWAプロジェクト作成
   - BubblewrapまたはPWABuilderを使用
   - Androidプロジェクトを自動生成

3. 署名とビルド
   - Android keystoreを生成
   - APKまたはAABをビルド

4. Play Consoleにアップロード
   - 内部/非公開/本番トラックを選択
   - ストアリスティングを作成

9.2 Microsoftストア(PWABuilder)

Microsoftストア配布の手順:

1. PWABuilder (https://www.pwabuilder.com/) にアクセス
2. PWAURLを入力
3. スコアを確認しパッケージをダウンロード
4. MSIXパッケージを生成
5. Partner Centerでアプリを登録
6. MSIXパッケージをアップロード

9.3 iOS(Safariサポート)

iOS PWAの制限事項(2025年基準):

サポート:
- ホーム画面への追加
- オフラインキャッシング
- Web App Manifest(一部)
- 基本プッシュ通知(iOS 16.4+
制限:
- バックグラウンド同期は未サポート
- 50MBストレージ制限
- サードパーティブラウザからのPWAインストール不可
- 一部Web APIは未サポート

10. PWAパフォーマンス最適化(さいてきか)

10.1 Lighthouse PWAチェックリスト

Lighthouse PWAチェックリスト:

[必須]
- HTTPSを使用
- Service Workerが登録済み
- オフラインで200レスポンス
- Web App Manifestが存在
- start_urlが設定済み
- アイコン(192px、512px)
- viewportメタタグ
- theme-colorメタタグ

[推奨]
- HTTPHTTPSにリダイレクト
- 3Gで高速ロード(10秒以内)
- カスタムスプラッシュスクリーン
- アドレスバーのテーマカラー
- ビューポートに合ったコンテンツ
- apple-touch-iconの設定

10.2 キャッシュサイズ管理(かんり)

// キャッシュサイズの監視
async function getCacheSize() {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const estimate = await navigator.storage.estimate();
    const usedMB = (estimate.usage / (1024 * 1024)).toFixed(2);
    const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(2);
    console.log('Storage used:', usedMB, 'MB of', quotaMB, 'MB');
    return estimate;
  }
}

// 永続ストレージのリクエスト
async function requestPersistentStorage() {
  if (navigator.storage && navigator.storage.persist) {
    const granted = await navigator.storage.persist();
    console.log('Persistent storage:', granted ? 'granted' : 'denied');
  }
}

10.3 インストールプロンプトのカスタマイズ

// インストールプロンプトの制御
let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;
  showInstallButton();
});

function showInstallButton() {
  const installBtn = document.getElementById('install-btn');
  installBtn.style.display = 'block';

  installBtn.addEventListener('click', async () => {
    if (!deferredPrompt) return;

    deferredPrompt.prompt();
    const result = await deferredPrompt.userChoice;

    console.log('Install prompt result:', result.outcome);
    deferredPrompt = null;
    installBtn.style.display = 'none';
  });
}

// インストール完了の検出
window.addEventListener('appinstalled', () => {
  console.log('PWA was installed');
  deferredPrompt = null;
  analytics.track('pwa_installed');
});

11. 実践(じっせん)クイズ

各(かく)問題(もんだい)を解(と)いて答(こた)えを確認(かくにん)しましょう。

Q1. Service Workerのライフサイクル段階(だんかい)を順番(じゅんばん)に挙(あ)げてください。

Registration - Installation - Waiting - Activation - Fetch(Idle/Terminated)

登録(とうろく)(Registration)後(ご)、インストール(Installation)で静的(せいてき)リソースをプリキャッシングし、前(まえ)のSWがあれば待機(たいき)(Waiting)状態(じょうたい)に入(はい)ります。アクティベーション(Activation)で古(ふる)いキャッシュを削除(さくじょ)し、その後(ご)Fetchイベントでネットワークリクエストを制御(せいぎょ)します。

Q2. Cache-First戦略(せんりゃく)が最(もっと)も適(てき)したリソースタイプは?

静的(せいてき)リソース(CSS、JSバンドル、画像(がぞう)、フォント)

変更頻度(へんこうひんど)が低(ひく)く、バージョン管理(かんり)されているリソースに適(てき)しています。キャッシュから即座(そくざ)に応答(おうとう)するため高速(こうそく)で、オフラインでも動作(どうさ)します。APIデータのように頻繁(ひんぱん)に変(か)わるデータにはNetwork FirstやStale While Revalidateが適(てき)しています。

Q3. Web App Manifestでdisplay: standaloneの意味(いみ)は?

ブラウザのアドレスバーなしで独立(どくりつ)アプリとして表示(ひょうじ)されるモード

standaloneモードではステータスバーのみ表示(ひょうじ)され、通常(つうじょう)のアプリのように見(み)えます。fullscreenはステータスバーもなく、minimal-uiは最小限(さいしょうげん)のブラウザUI(戻(もど)るボタンなど)が表示(ひょうじ)され、browserは通常(つうじょう)のブラウザタブで開(ひら)きます。

Q4. WorkboxのBackgroundSyncPluginの用途(ようと)は?

オフライン状態(じょうたい)で失敗(しっぱい)したネットワークリクエストをキューに保存(ほぞん)し、オンライン復帰時(ふっきじ)に自動(じどう)リトライすること

例(たと)えば、オフライン状態(じょうたい)でフォーム送信(そうしん)をすると、リクエストをIndexedDBに保存(ほぞん)しておき、ネットワークが復旧(ふっきゅう)したら自動的(じどうてき)にサーバーに送信(そうしん)します。これにより、オフラインでもデータ損失(そんしつ)なく作業(さぎょう)できます。

Q5. PWAをGoogle Playストアに配布(はいふ)するための技術(ぎじゅつ)は?

TWA(Trusted Web Activity)

TWAはChrome Custom Tabsをベースに、PWAをネイティブAndroidアプリとしてラップします。Digital Asset Linksファイル(assetlinks.json)を通(つう)じてアプリとWebサイト間(かん)の所有権(しょゆうけん)を証明(しょうめい)します。BubblewrapやPWABuilderなどのツールでTWAプロジェクトを簡単(かんたん)に作成(さくせい)できます。


12. 参考資料(さんこうしりょう)

  1. web.dev PWAガイド - GoogleのPWA公式(こうしき)ガイド
  2. MDN Service Worker API - Service Worker APIドキュメント
  3. Workbox公式(こうしき)ドキュメント - Workboxライブラリドキュメント
  4. PWABuilder - PWAビルドおよびデプロイツール
  5. Web Push Protocol - Webプッシュ通知(つうち)の概要(がいよう)
  6. Lighthouse - PWA監査(かんさ)ツール
  7. next-pwa - Next.js PWAプラグイン
  8. idb - IndexedDB Promiseラッパー
  9. Project Fugu - WebプラットフォームAPIトラッカー
  10. Bubblewrap - TWAビルドツール
  11. What PWA Can Do Today - PWA機能(きのう)デモ
  12. Microsoft PWAドキュメント - Microsoft Edge PWAガイド
  13. Apple Developer - Web Apps - iOS PWAドキュメント