pwa-storefront について
カスタムまたはヘッドレスの EC サイト向けに、プログレッシブ Web アプリのストアフロント構築を案内するスキルです。オフラインカタログ、キャッシュ、ホーム画面への追加、注文通知などを扱います。
主な機能
- 01Web App Manifest の作成
- 02Workbox によるサービスワーカー登録
- 03カタログのキャッシュとオフラインページ
- 04Web Push 通知の実装
- 05接続状態の検知と画面更新
活用シーン
- 01通信が不安定な地域でのオフライン閲覧
- 02ストアのホーム画面への追加
- 03キャッシュによる再訪時の読み込み短縮
- 04注文状況や再入荷の通知
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ finsilabs/ awesome-ecommerce-skills --skill pwa-storefrontインストール後、Claude Code で/pwa-storefrontを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
tile.jsonevals/push-notifications-and-offline-ux/criteria.jsontask.mdevals/service-worker-caching-strategies/criteria.jsontask.mdevals/web-app-manifest-and-installability/criteria.jsontask.md詳細
PWA ストアフロント
概要
プログレッシブウェブアプリ(PWA)のストアフロントは、ウェブの広いリーチとネイティブアプリのような機能を組み合わせます。オフラインでの商品カタログ閲覧、プッシュ通知、ホーム画面へのインストール、キャッシュによる高速な再読み込みが可能です。サービスワーカーはネットワークリクエストを横取りし、不安定な接続でもストアが機能し続けるキャッシュ戦略を実装します。このスキルでは、Workbox を使ったサービスワーカーの実装、Web App Manifest の作成、商品カタログのキャッシュ、注文状況更新のプッシュ通知送信について説明します。
このスキルを使う場面
- モバイルインターネット接続が不安定な地域に顧客がいる場合
- ネイティブアプリを用意せずに「ホーム画面に追加」を有効にして、再エンゲージメント率を高めたい場合
- キャッシュからアセットを配信し、ページをすばやく再読み込みしたい場合
- 注文状況の更新、再入荷のお知らせ、プロモーションのプッシュ通知を送りたい場合
- ネイティブアプリに匹敵する UX 品質が必要な、モバイルファーストのストアフロントを構築する場合
前提条件とプラットフォームに関する注意事項
このスキルはカスタム/ヘッドレスのストアフロント向けです(Node.js、Python、または同様のバックエンド)。コード例では TypeScript/Node.js を使用していますが、どのスタックにも適応できます。
Shopify:Shopify Hydrogen は Shopify のヘッドレスフレームワークです。Shopify をコマースバックエンドとしてカスタムフロントエンドを使用する場合や、Shopify と他の最適なサービスを組み合わせる場合は、MACH/コンポーザブルのパターンが適用されます。 WooCommerce:WooCommerce は、REST API と WPGraphQL を介してヘッドレスバックエンドとして利用できます。これらのパターンは、フロントエンドを WordPress から切り離す場合に適用されます。 Magento:Magento の GraphQL API と PWA Studio は、ヘッドレスアーキテクチャをサポートします。これらのコンポーザブルなパターンは、MACH スタックのバックエンドサービスとして Magento を使用する場合に適用されます。
必要なもの:
- Node.js 18+(またはバックエンドの言語に合わせて調整)
- PostgreSQL(または任意のリレーショナルデータベース)
- キャッシュ/キュー用の Redis
- メール送信サービス(SendGrid、AWS SES、または Postmark)
- CDN(Cloudflare、CloudFront、または Fastly)
主な手順
-
Web App Manifest を作成する
マニフェストにより、アプリを Android および iOS にインストールできるようになります(iOS のサポートは一部に限られます)。
// public/manifest.json { "name": "My Commerce Store", "short_name": "MyStore", "description": "Fast, reliable shopping from anywhere", "start_url": "/?source=pwa", "display": "standalone", "background_color": "#ffffff", "theme_color": "#1a1a2e", "orientation": "portrait-primary", "icons": [ { "src": "/icons/icon-72x72.png", "sizes": "72x72", "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/home.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow" } ], "categories": ["shopping"], "share_target": { "action": "/search", "method": "GET", "params": { "title": "q" } } }HTML にリンクを追加します:
<link rel="manifest" href="/manifest.json"> <meta name="theme-color" content="#1a1a2e"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="default"> <link rel="apple-touch-icon" href="/icons/icon-192x192.png"> -
Workbox でサービスワーカーを登録する
npm install workbox-webpack-plugin # or for Vite: npm install vite-plugin-pwavite-plugin-pwaを使用する場合(Vite/Next.js プロジェクトに推奨):// vite.config.ts import {VitePWA} from 'vite-plugin-pwa'; export default { plugins: [ VitePWA({ registerType: 'autoUpdate', workbox: { globPatterns: ['**/*.{js,css,html,svg,png,webp,woff2}'], runtimeCaching: [ { urlPattern: /^https:\/\/fonts\.googleapis\.com/, handler: 'CacheFirst', options: {cacheName: 'google-fonts-cache', expiration: {maxAgeSeconds: 60 * 60 * 24 * 365}}, }, { urlPattern: /\/api\/products/, handler: 'StaleWhileRevalidate', options: { cacheName: 'products-cache', expiration: {maxEntries: 500, maxAgeSeconds: 60 * 60 * 24}, // 24h cacheableResponse: {statuses: [0, 200]}, }, }, { urlPattern: /\/api\/collections/, handler: 'NetworkFirst', options: { cacheName: 'collections-cache', networkTimeoutSeconds: 3, expiration: {maxEntries: 50, maxAgeSeconds: 60 * 60}, }, }, ], }, manifest: {/* inline manifest or path */}, }), ], }; -
オフラインの商品カタログ用にカスタムサービスワーカーを実装する
細かく制御するには、サービスワーカーを直接記述します:
// public/sw.js import {precacheAndRoute, cleanupOutdatedCaches} from 'workbox-precaching'; import {registerRoute} from 'workbox-routing'; import {StaleWhileRevalidate, CacheFirst, NetworkFirst} from 'workbox-strategies'; import {ExpirationPlugin} from 'workbox-expiration'; import {BackgroundSyncPlugin} from 'workbox-background-sync'; // Precache app shell (injected by build tool) precacheAndRoute(self.__WB_MANIFEST); cleanupOutdatedCaches(); // Product images: Cache-first with 7-day expiry registerRoute( ({url}) => url.hostname.includes('cdn.shopify.com') || url.pathname.includes('/product-images/'), new CacheFirst({ cacheName: 'product-images', plugins: [ new ExpirationPlugin({maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7}), ], }) ); // Product API: Stale-while-revalidate (show cached, refresh in background) registerRoute( ({url}) => url.pathname.startsWith('/api/products') || url.pathname.startsWith('/api/collections'), new StaleWhileRevalidate({ cacheName: 'api-products', plugins: [ new ExpirationPlugin({maxEntries: 500, maxAgeSeconds: 60 * 60 * 24}), ], }) ); // Background sync for cart operations when offline const cartSyncPlugin = new BackgroundSyncPlugin('cart-sync-queue', { maxRetentionTime: 24 * 60, // Retry for 24 hours }); registerRoute( ({url, request}) => url.pathname.startsWith('/api/cart') && request.method !== 'GET', new NetworkFirst({plugins: [cartSyncPlugin]}), 'POST' ); -
オフライン時のフォールバックページを表示する
// In the service worker import {setCatchHandler, setDefaultHandler} from 'workbox-routing'; // Precache the offline page during installation precacheAndRoute([{url: '/offline', revision: '1'}]); // Serve offline page for navigation requests when network fails setCatchHandler(async ({event}) => { if (event.request.destination === 'document') { return caches.match('/offline'); } return Response.error(); });// app/offline/page.tsx export default function OfflinePage() { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <h1>You're offline</h1> <p>Check your connection. Recently viewed products are still available below.</p> <RecentlyViewedProducts /> {/* Reads from IndexedDB */} </div> ); } -
Web Push 通知を実装する
// client: subscribe to push notifications async function subscribeToPush() { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!), }); await fetch('/api/push/subscribe', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({subscription, customerId: user.id}), }); } // server: send push notification for order status update import webpush from 'web-push'; webpush.setVapidDetails( 'mailto:[email protected]', process.env.VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY! ); export async function sendOrderUpdatePush(customerId: string, order: Order) { const subscriptions = await db.pushSubscriptions.findByCustomer(customerId); await Promise.allSettled( subscriptions.map(sub => webpush.sendNotification(sub.data, JSON.stringify({ title: `Order #${order.number} Update`, body: `Your order is now ${order.status}`, icon: '/icons/icon-192x192.png', url: `/orders/${order.id}`, tag: `order-${order.id}`, })) ) ); }サービスワーカーでプッシュイベントを処理します:
self.addEventListener('push', (event) => { const data = event.data?.json(); event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: data.icon, badge: '/icons/badge-72x72.png', data: {url: data.url}, tag: data.tag, renotify: true, }) ); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); event.waitUntil(clients.openWindow(event.notification.data.url)); }); -
UI でオフライン状態を検出して対応する
// hooks/use-online-status.ts import {useState, useEffect} from 'react'; export function useOnlineStatus() { const [isOnline, setIsOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true); useEffect(() => { const setOnline = () => setIsOnline(true); const setOffline = () => setIsOnline(false); window.addEventListener('online', setOnline); window.addEventListener('offline', setOffline); return () => { window.removeEventListener('online', setOnline); window.removeEventListener('offline', setOffline); }; }, []); return isOnline; } // Usage in a component function CartButton() { const isOnline = useOnlineStatus(); return ( <button disabled={!isOnline} title={isOnline ? undefined : 'You are offline'}> Add to Cart </button> ); }
例
Lighthouse PWA 監査チェックリスト(自動)
# Install Lighthouse CLI
npm install -g lighthouse
# Audit PWA criteria
lighthouse https://mystore.com --preset=desktop --only-categories=pwa --output=json --output-path=./lighthouse-pwa.json
# Key scores to target:
# - "Installable" checks: manifest, service worker, HTTPS
# - "PWA Optimized" checks: themed address bar, offline page, mobile viewport
オフライン閲覧用の IndexedDB 商品カタログキャッシュ
import {openDB} from 'idb';
const db = await openDB('catalog-db', 1, {
upgrade(db) {
db.createObjectStore('products', {keyPath: 'id'});
db.createObjectStore('collections', {keyPath: 'id'});
},
});
// Store products when user browses online
export async function cacheProductsLocally(products: Product[]) {
const tx = db.transaction('products', 'readwrite');
await Promise.all([...products.map(p => tx.store.put(p)), tx.done]);
}
// Retrieve from IDB when offline
export async function getProductFromCache(id: string): Promise<Product | null> {
return db.get('products', id) ?? null;
}
ベストプラクティス
- 商品データには
StaleWhileRevalidateを使う — サービスワーカーがバックグラウンドで最新データを取得する間、ユーザーにはキャッシュ済みのコンテンツがすぐに表示されます - カートやチェックアウトのページは絶対にキャッシュしない — 常に最新の状態である必要があります。
/cart、/checkout、アカウントページにはNetworkOnly戦略を使います - サービスワーカーのキャッシュ名にバージョンを付ける — アプリを更新するときにキャッシュ名を増やすと、古いアセットが自動的に削除されます
- Chrome DevTools でオフラインモードをテストする — デプロイ前に、Network タブ →「Offline」スロットリングを使ってオフライン時の動作を確認します
- VAPID キーは一度だけ生成し、安全に保管する — VAPID の秘密鍵を失うと、既存のプッシュ購読がすべて失われます。シークレットマネージャーに保管してください
- 状況に応じてプッシュ通知の許可を求める — オプトイン率を高めるため、注文完了や再入荷への関心など、関連する操作の後にのみ通知の許可を求めます
- 妥当なキャッシュサイズの上限を設定する —
maxEntriesを指定したExpirationPluginを使い、サービスワーカーのキャッシュがデバイスのストレージを使いすぎないようにします
よくある落とし穴
| 問題 | 解決策 |
|---|---|
| デプロイ後にサービスワーカーが更新されない | registerType: 'autoUpdate' を使用し、サービスワーカー内で skipWaiting() を呼び出してすぐに制御を引き継ぎ、「新しいバージョンが利用可能です」というトーストを表示します |
| iOS でプッシュ通知が表示されない | iOS では、まずユーザーが PWA をホーム画面に追加する必要があります。iOS Safari の Web Push には iOS 16.4 以降と standalone の表示モードが必要です |
| 価格変更後もキャッシュされた API レスポンスが返される | maxAgeSeconds を適切に設定します。デプロイ時にキャッシュ名を変更して、オンデマンドでキャッシュを無効化します |
| バックグラウンド同期がエラーを通知せずに失敗する | バックグラウンド同期を try/catch で囲んでエラーを記録します。DevTools の Application → Background Sync パネルでテストします |
| Lighthouse 監査でアプリのインストール可能性が不合格になる | HTTPS、512×512 のマスカブルアイコンを含む有効なマニフェスト、登録済みのサービスワーカー、200 を返す start_url を確認します |
関連スキル
- @jamstack-storefront
- @image-optimization-cdn
- @edge-commerce
- @monitoring-alerting-commerce