Next.jsで高速eコマースアプリを構築する | エピソード9: Progressive Web App（PWA）への変換

こんにちは！前回のエピソードでは、`next-intl`を使って多言語対応を実装し、グローバル市場向けにアプリを拡張しました。今回は、Progressive Web App（PWA）機能を追加して、モバイルでのネイティブアプリのような体験を提供します。`next-pwa`を使ってオフラインサポートやプッシュ通知を導入し、ユーザーエンゲージメントを向上させます！

## このエピソードのゴール

- `next-pwa`を使ってPWA機能を追加。
- マニフェストファイルとサービスワーカーを設定してオフライン対応を実現。
- プッシュ通知をFirebase Cloud Messaging（FCM）で実装。
- モバイルデバイスでのPWA動作を確認。

## 必要なもの

- 前回のプロジェクト（`next-ecommerce`）がセットアップ済み。
- `next-pwa`パッケージ。
- Firebaseアカウント（プッシュ通知用）。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: next-pwaのセットアップ

`next-pwa`は、Next.jsプロジェクトにPWA機能を簡単に追加できるライブラリです。まず、必要なパッケージをインストールします：

```bash
npm install next-pwa
```

`next.config.js`を更新して`next-pwa`を統合します：

```js
/** @type {import('next').NextConfig} */
const withNextIntl = require('next-intl/plugin')();
const withPWA = require('next-pwa')({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development', // 開発環境ではPWAを無効化
});

const nextConfig = {
  images: {
    domains: ['cdn.shopify.com'],
  },
};

module.exports = withNextIntl(withPWA(nextConfig));
```

この設定は：
- PWAのキャッシュファイルを出力先に`public`フォルダを指定。
- 開発環境でのPWA無効化を有効化。
- `next-intl`と共存するように構成。

---

## ステップ2: Webマニフェストの設定

PWAには、アプリのメタデータを定義する`manifest.json`が必要です。`public/manifest.json`ファイルを作成：

```json
{
  "name": "Next.js eCommerce",
  "short_name": "eCommerce",
  "description": "A high-performance eCommerce app built with Next.js",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1d4ed8",
  "icons": [
    {
      "src": "/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}
```

- `start_url`: アプリ起動時のURL。
- `display: standalone`: ネイティブアプリのようなフルスクリーン表示。
- `icons`: ホーム画面に追加されるアイコン。

`public`フォルダに`icon-192x192.png`と`icon-512x512.png`を用意してください（例: ロゴ画像）。デザインツールやオンラインツールで生成可能です。

次に、`src/app/[locale]/layout.tsx`にマニフェストをリンク：

```tsx
import { NextIntlClientProvider } from 'next-intl';
import { notFound } from 'next/navigation';
import Navbar from '@/components/Navbar';
import Head from 'next/head';
import '../styles/globals.css';

export function generateStaticParams() {
  return [{ locale: 'en' }, { locale: 'ja' }];
}

export default async function LocaleLayout({
  children,
  params: { locale },
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
  let messages;
  try {
    messages = (await import(`../../messages/${locale}.json`)).default;
  } catch (error) {
    notFound();
  }

  const alternateLocales = ['en', 'ja'].filter((l) => l !== locale);

  return (
    <html lang={locale}>
      <Head>
        <link rel="manifest" href="/manifest.json" />
        <meta name="theme-color" content="#1d4ed8" />
        <link rel="alternate" hrefLang={locale} href={`https://yourdomain.com/${locale}`} />
        {alternateLocales.map((altLocale) => (
          <link
            key={altLocale}
            rel="alternate"
            hrefLang={altLocale}
            href={`https://yourdomain.com/${altLocale}`}
          />
        ))}
        <link rel="alternate" hrefLang="x-default" href="https://yourdomain.com/en" />
      </Head>
      <body>
        <NextIntlClientProvider locale={locale} messages={messages}>
          <Navbar />
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}
```

---

## ステップ3: サービスワーカーの確認

`next-pwa`は、ビルド時にサービスワーカーを自動生成します。`public/sw.js`と`public/workbox-*.js`が生成されることを確認してください。これにより、ページやアセットがキャッシュされ、オフラインでもアプリが動作します。

デフォルトのキャッシュ戦略をカスタマイズしたい場合、`next.config.js`に以下を追加：

```js
const withPWA = require('next-pwa')({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  runtimeCaching: [
    {
      urlPattern: /^https?.*/,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'offlineCache',
        expiration: {
          maxEntries: 200,
        },
      },
    },
  ],
});
```

この設定は、ネットワーク優先のキャッシュ戦略を採用し、最大200エントリをキャッシュします。

---

## ステップ4: プッシュ通知の実装

プッシュ通知を追加するために、Firebase Cloud Messaging（FCM）を統合します。

1. **Firebaseのセットアップ**：
   - [Firebaseコンソール](https://console.firebase.google.com/)でプロジェクトを作成。
   - Webアプリを追加し、Firebase SDKの設定情報を取得。
   - `public/firebase-messaging-sw.js`ファイルを作成し、サービスワーカーでFCMを初期化：

```js
importScripts('https://www.gstatic.com/firebasejs/9.0.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.0.0/firebase-messaging-compat.js');

firebase.initializeApp({
  apiKey: 'あなたのAPIキー',
  authDomain: 'あなたのドメイン',
  projectId: 'あなたのプロジェクトID',
  storageBucket: 'あなたのストレージバケット',
  messagingSenderId: 'あなたの送信者ID',
  appId: 'あなたのアプリID',
  measurementId: 'あなたの測定ID',
});

const messaging = firebase.messaging();
```

2. **クライアントサイドの設定**：
   `src/lib/firebase.ts`ファイルを作成：

```ts
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, onMessage } from 'firebase/messaging';

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
  measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
};

const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);

export async function requestNotificationPermission() {
  try {
    const permission = await Notification.requestPermission();
    if (permission === 'granted') {
      const token = await getToken(messaging, {
        vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY,
      });
      console.log('FCMトークン:', token);
      return token;
    }
  } catch (error) {
    console.error('通知許可エラー:', error);
  }
}

export function onMessageListener(callback: (payload: any) => void) {
  onMessage(messaging, callback);
}
```

`.env.local`にFirebase設定を追加：

```env
NEXT_PUBLIC_FIREBASE_API_KEY=あなたのAPIキー
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=あなたのドメイン
NEXT_PUBLIC_FIREBASE_PROJECT_ID=あなたのプロジェクトID
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=あなたのストレージバケット
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=あなたの送信者ID
NEXT_PUBLIC_FIREBASE_APP_ID=あなたのアプリID
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=あなたの測定ID
NEXT_PUBLIC_FIREBASE_VAPID_KEY=あなたのVAPIDキー
```

3. **通知のトリガー**：
   `src/components/NotificationButton.tsx`を作成して通知許可をリクエスト：

```tsx
import { useState } from 'react';
import { requestNotificationPermission, onMessageListener } from '@/lib/firebase';

export default function NotificationButton() {
  const [subscribed, setSubscribed] = useState(false);

  const handleSubscribe = async () => {
    const token = await requestNotificationPermission();
    if (token) {
      setSubscribed(true);
      onMessageListener((payload) => {
        alert(`通知: ${payload.notification.title}`);
      });
    }
  };

  return (
    <button
      onClick={handleSubscribe}
      disabled={subscribed}
      className="bg-primary text-white px-4 py-2 rounded hover:bg-opacity-90 disabled:opacity-50"
    >
      {subscribed ? '通知を許可済み' : '通知を有効にする'}
    </button>
  );
}
```

`src/components/Navbar.tsx`にボタンを追加：

```tsx
import Link from 'next/link';
import { useCartStore } from '@/lib/cartStore';
import NotificationButton from './NotificationButton';

export default function Navbar() {
  const items = useCartStore((state) => state.items);
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);

  return (
    <nav className="bg-primary text-white p-4">
      <div className="container mx-auto flex justify-between items-center">
        <Link href="/" className="text-2xl font-bold">
          Next.js eCommerce
        </Link>
        <div className="flex items-center gap-4">
          <Link href="/cart" className="relative">
            カート
            {itemCount > 0 && (
              <span className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full px-2 text-sm">
                {itemCount}
              </span>
            )}
          </Link>
          <NotificationButton />
        </div>
      </div>
    </nav>
  );
}
```

---

## ステップ5: 動作確認

1. Firebaseコンソールでプッシュ通知をテスト送信。
2. プロジェクトをビルド（`npm run build`）してローカルで実行（`npm run start`）。
3. `http://localhost:3000`にアクセスし、以下の点を確認：
   - 「通知を有効にする」ボタンをクリックすると、通知許可ダイアログが表示される。
   - アプリをホーム画面に追加できる（Chromeの「インストール」アイコン）。
   - オフラインでページにアクセスし、キャッシュされたコンテンツが表示される。
   - モバイルデバイスでPWAをインストールし、ネイティブアプリのような動作を確認。
4. Firebaseからテスト通知を送信し、受信されることを確認。

エラーがあれば、Firebase設定やサービスワーカーのログを確認してください。

---

## まとめと次のステップ

このエピソードでは、`next-pwa`を使ってPWA機能を追加し、オフラインサポートとプッシュ通知を実装しました。これにより、モバイルユーザーにネイティブアプリのような体験を提供できました。

次回のエピソードでは、アプリケーションをVercelにデプロイし、セキュリティ強化と拡張性を確保します。CI/CDの設定やAIを活用した商品レコメンドのアイデアも紹介しますので、引き続きお楽しみに！

---

この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです！次回のエピソードもお楽しみに！