はじめに/ガイド/PWA

Next.jsでプログレッシブウェブアプリケーション (PWA) を構築する方法

プログレッシブウェブアプリケーション (PWA) は、ウェブアプリケーションの到達性とアクセシビリティに、ネイティブモバイルアプリの機能とユーザー体験を組み合わせたものです。Next.jsを使用すれば、複数のコードベースやアプリストアの承認を必要とせず、すべてのプラットフォームでシームレスなアプリのような体験を提供するPWAを作成できます。

PWAを使用すると次のことが可能です:

  • アプリストアの承認を待たずに即座に更新をデプロイ
  • 単一のコードベースでクロスプラットフォームアプリケーションを作成
  • ホーム画面へのインストールやプッシュ通知などのネイティブのような機能を提供

Next.jsでPWAを作成する

1. Webアプリマニフェストの作成

Next.jsはApp Routerを使用したウェブアプリマニフェストの作成を組み込みでサポートしています。静的または動的なマニフェストファイルを作成できます:

例えば、app/manifest.ts または app/manifest.json ファイルを作成します:

import type { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'Next.js PWA',
    short_name: 'NextPWA',
    description: 'Next.jsで構築されたプログレッシブウェブアプリ',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#000000',
    icons: [
      {
        src: '/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
      },
    ],
  }
}

このファイルには、アプリの名前、アイコン、ユーザーのデバイス上でアイコンとして表示される方法に関する情報が含まれている必要があります。これにより、ユーザーはPWAをホーム画面にインストールでき、ネイティブアプリのような体験を得られます。

ファビコンジェネレーターなどのツールを使用してさまざまなアイコンセットを作成し、生成されたファイルをpublic/フォルダに配置できます。

2. Webプッシュ通知の実装

Webプッシュ通知は、以下のすべての最新ブラウザでサポートされています:

  • ホーム画面にインストールされたアプリ用のiOS 16.4+
  • macOS 13以降のSafari 16
  • Chromiumベースのブラウザ
  • Firefox

これにより、PWAはネイティブアプリの代替として有効です。特に、オフラインサポートがなくてもインストールプロンプトをトリガーできます。

Webプッシュ通知を使用すると、ユーザーがアプリを積極的に使用していない場合でも再エンゲージメントが可能です。Next.jsアプリケーションで実装する方法は次のとおりです:

まず、app/page.tsxにメインページコンポーネントを作成しましょう。理解しやすいように小さな部分に分割します。まず、必要なインポートとユーティリティを追加します。参照されているServer Actionがまだ存在しなくても問題ありません:

'use client'

import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'

function urlBase64ToUint8Array(base64String: string) {
  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
}
'use client'

import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'

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
}

次に、プッシュ通知の購読、購読解除、送信を管理するコンポーネントを追加しましょう。

function PushNotificationManager() {
  const [isSupported, setIsSupported] = useState(false)
  const [subscription, setSubscription] = useState<PushSubscription | null>(
    null
  )
  const [message, setMessage] = useState('')

  useEffect(() => {
    if ('serviceWorker' in navigator && 'PushManager' in window) {
      setIsSupported(true)
      registerServiceWorker()
    }
  }, [])

  async function registerServiceWorker() {
    const registration = await navigator.serviceWorker.register('/sw.js', {
      scope: '/',
      updateViaCache: 'none',
    })
    const sub = await registration.pushManager.getSubscription()
    setSubscription(sub)
  }

  async function subscribeToPush() {
    const registration = await navigator.serviceWorker.ready
    const sub = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(
        process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
      ),
    })
    setSubscription(sub)
    const serializedSub = JSON.parse(JSON.stringify(sub))
    await subscribeUser(serializedSub)
  }

  async function unsubscribeFromPush() {
    await subscription?.unsubscribe()
    setSubscription(null)
    await unsubscribeUser()
  }

  async function sendTestNotification() {
    if (subscription) {
      await sendNotification(message)
      setMessage('')
    }
  }

  if (!isSupported) {
    return <p>このブラウザではプッシュ通知がサポートされていません。</p>
  }

  return (
    <div>
      <h3>プッシュ通知</h3>
      {subscription ? (
        <>
          <p>プッシュ通知を購読中です。</p>
          <button onClick={unsubscribeFromPush}>購読解除</button>
          <input
            type="text"
            placeholder="通知メッセージを入力"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
          />
          <button onClick={sendTestNotification}>テスト送信</button>
        </>
      ) : (
        <>
          <p>プッシュ通知を購読していません。</p>
          <button onClick={subscribeToPush}>購読</button>
        </>
      )}
    </div>
  )
}
function PushNotificationManager() {
  const [isSupported, setIsSupported] = useState(false);
  const [subscription, setSubscription] = useState(null);
  const [message, setMessage] = useState('');

  useEffect(() => {
    if ('serviceWorker' in navigator && 'PushManager' in window) {
      setIsSupported(true);
      registerServiceWorker();
    }
  }, []);

  async function registerServiceWorker() {
    const registration = await navigator.serviceWorker.register('/sw.js', {
      scope: '/',
      updateViaCache: 'none',
    });
    const sub = await registration.pushManager.getSubscription();
    setSubscription(sub);
  }

  async function subscribeToPush() {
    const registration = await navigator.serviceWorker.ready;
    const sub = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(
        process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
      ),
    });
    setSubscription(sub);
    await subscribeUser(sub);
  }

  async function unsubscribeFromPush() {
    await subscription?.unsubscribe();
    setSubscription(null);
    await unsubscribeUser();
  }

  async function sendTestNotification() {
    if (subscription) {
      await sendNotification(message);
      setMessage('');
    }
  }

  if (!isSupported) {
    return <p>このブラウザではプッシュ通知がサポートされていません。</p>;
  }

  return (
    <div>
      <h3>プッシュ通知</h3>
      {subscription ? (
        <>
          <p>プッシュ通知を購読中です。</p>
          <button onClick={unsubscribeFromPush}>購読解除</button>
          <input
            type="text"
            placeholder="通知メッセージを入力"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
          />
          <button onClick={sendTestNotification}>テスト送信</button>
        </>
      ) : (
        <>
          <p>プッシュ通知を購読していません。</p>
          <button onClick={subscribeToPush}>購読</button>
        </>
      )}
    </div>
  );
}

最後に、iOSデバイス向けにホーム画面にインストールする方法を指示するメッセージを表示するコンポーネントを作成し、アプリがまだインストールされていない場合にのみ表示します。

function InstallPrompt() {
  const [isIOS, setIsIOS] = useState(false)
  const [isStandalone, setIsStandalone] = useState(false)

  useEffect(() => {
    setIsIOS(
      /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
    )

    setIsStandalone(window.matchMedia('(display-mode: standalone)').matches)
  }, [])

  if (isStandalone) {
    return null // すでにインストールされている場合はインストールボタンを表示しない
  }

  return (
    <div>
      <h3>アプリをインストール</h3>
      <button>ホーム画面に追加</button>
      {isIOS && (
        <p>
          iOSデバイスにこのアプリをインストールするには、共有ボタン
          <span role="img" aria-label="共有アイコン">
            {' '}
            ⎋{' '}
          </span>
          をタップし、「ホーム画面に追加」
          <span role="img" aria-label="プラスアイコン">
            {' '}
            ➕{' '}
          </span>
          を選択してください。
        </p>
      )}
    </div>
  )
}

export default function Page() {
  return (
    <div>
      <PushNotificationManager />
      <InstallPrompt />
    </div>
  )
}
function InstallPrompt() {
  const [isIOS, setIsIOS] = useState(false);
  const [isStandalone, setIsStandalone] = useState(false);

  useEffect(() => {
    setIsIOS(
      /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
    );

    setIsStandalone(window.matchMedia('(display-mode: standalone)').matches);
  }, []);

  if (isStandalone) {
    return null; // すでにインストールされている場合はインストールボタンを表示しない
  }

  return (
    <div>
      <h3>アプリをインストール</h3>
      <button>ホーム画面に追加</button>
      {isIOS && (
        <p>
          iOSデバイスにこのアプリをインストールするには、共有ボタン
          <span role="img" aria-label="共有アイコン">
            {' '}
            ⎋{' '}
          </span>
          をタップし、「ホーム画面に追加」
          <span role="img" aria-label="プラスアイコン">
            {' '}
            ➕{' '}
          </span>
          を選択してください。
        </p>
      )}
    </div>
  );
}

export default function Page() {
  return (
    <div>
      <PushNotificationManager />
      <InstallPrompt />
    </div>
  );
}

次に、このファイルが呼び出すServer Actionを作成しましょう。

3. Server Actionの実装

app/actions.tsに新しいファイルを作成して、購読の作成、削除、通知の送信を処理します。

'use server'

import webpush from 'web-push'

webpush.setVapidDetails(
  '<mailto:[email protected]>',
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
)

let subscription: PushSubscription | null = null

export async function subscribeUser(sub: PushSubscription) {
  subscription = sub
  // 本番環境では、購読情報をデータベースに保存する必要があります
  // 例: await db.subscriptions.create({ data: sub })
  return { success: true }
}

export async function unsubscribeUser() {
  subscription = null
  // 本番環境では、データベースから購読情報を削除する必要があります
  // 例: await db.subscriptions.delete({ where: { ... } })
  return { success: true }
}

export async function sendNotification(message: string) {
  if (!subscription) {
    throw new Error('購読情報がありません')
  }

  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: 'テスト通知',
        body: message,
        icon: '/icon.png',
      })
    )
    return { success: true }
  } catch (error) {
    console.error('プッシュ通知の送信エラー:', error)
    return { success: false, error: '通知の送信に失敗しました' }
  }
}

通知の送信は、ステップ5で作成するサービスワーカーによって処理されます。

本番環境では、サーバーの再起動をまたいで永続化し、複数のユーザーの購読を管理するために、購読情報をデータベースに保存する必要があります。

4. VAPIDキーの生成

Web Push APIを使用するには、VAPIDキーを生成する必要があります。最も簡単な方法は、web-push CLIを直接使用することです:

まず、web-pushをグローバルにインストールします:

Terminal
npm install -g web-push

次のコマンドを実行してVAPIDキーを生成します:

Terminal
web-push generate-vapid-keys

出力をコピーして、.envファイルにキーを貼り付けます:

NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here

5. サービスワーカーの作成

サービスワーカー用にpublic/sw.jsファイルを作成します:

public/sw.js
self.addEventListener('push', function (event) {
  if (event.data) {
    const data = event.data.json()
    const options = {
      body: data.body,
      icon: data.icon || '/icon.png',
      badge: '/badge.png',
      vibrate: [100, 50, 100],
      data: {
        dateOfArrival: Date.now(),
        primaryKey: '2',
      },
    }
    event.waitUntil(self.registration.showNotification(data.title, options))
  }
})

self.addEventListener('notificationclick', function (event) {
  console.log('通知クリックを受信しました。')
  event.notification.close()
  event.waitUntil(clients.openWindow('<https://your-website.com>'))
})

このサービスワーカーは、カスタム画像と通知をサポートしています。受信したプッシュイベントと通知クリックを処理します。

  • iconbadgeプロパティを使用して通知用のカスタムアイコンを設定できます
  • vibrateパターンを調整して、サポートされているデバイスでカスタム振動アラートを作成できます
  • dataプロパティを使用して通知に追加データを添付できます

サービスワーカーがさまざまなデバイスやブラウザで期待どおりに動作することを確認するために、十分にテストしてください。また、notificationclickイベントリスナーの'https://your-website.com'リンクをアプリケーションの適切なURLに更新することを忘れないでください。

6. ホーム画面への追加

ステップ2で定義したInstallPromptコンポーネントは、iOSデバイス向けにホーム画面へのインストール方法を指示するメッセージを表示します。

モバイルホーム画面にアプリケーションをインストール可能にするためには、以下が必要です:

  1. 有効なウェブアプリマニフェスト(ステップ1で作成)
  2. HTTPSで提供されるウェブサイト

現代のブラウザはこれらの条件を満たすと、自動的にインストールプロンプトをユーザーに表示します。beforeinstallpromptを使用してカスタムインストールボタンを提供することも可能ですが、クロスブラウザ・クロスプラットフォームで動作しない(Safari iOSでは機能しない)ため推奨しません。

7. ローカルでのテスト

ローカル環境で通知を確認するには、以下を確認してください:

  • HTTPSを使用したローカル実行を行っていること
    • テストにはnext dev --experimental-httpsを使用
  • ブラウザ(Chrome、Safari、Firefox)で通知が有効になっていること
    • ローカルでプロンプトが表示されたら、通知の使用許可を与えてください
    • ブラウザ全体で通知が無効化されていないことを確認
    • それでも通知が表示されない場合は、別のブラウザでデバッグを試みてください

8. アプリケーションのセキュリティ保護

セキュリティは、特にPWAにおいて重要な側面です。Next.jsではnext.config.jsファイルを使用してセキュリティヘッダーを設定できます。例:

next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
          {
            key: 'Referrer-Policy',
            value: 'strict-origin-when-cross-origin',
          },
        ],
      },
      {
        source: '/sw.js',
        headers: [
          {
            key: 'Content-Type',
            value: 'application/javascript; charset=utf-8',
          },
          {
            key: 'Cache-Control',
            value: 'no-cache, no-store, must-revalidate',
          },
          {
            key: 'Content-Security-Policy',
            value: "default-src 'self'; script-src 'self'",
          },
        ],
      },
    ]
  },
}

各オプションの説明:

  1. グローバルヘッダー(全ルートに適用):
    1. X-Content-Type-Options: nosniff:MIMEタイプスニッフィングを防止し、悪意のあるファイルアップロードのリスクを低減
    2. X-Frame-Options: DENY:クリックジャッキング攻撃から保護するため、サイトがiframeに埋め込まれるのを防止
    3. Referrer-Policy: strict-origin-when-cross-origin:リクエストに含まれるリファラー情報を制御し、セキュリティと機能性のバランスを取る
  2. サービスワーカー固有のヘッダー:
    1. Content-Type: application/javascript; charset=utf-8:サービスワーカーがJavaScriptとして正しく解釈されることを保証
    2. Cache-Control: no-cache, no-store, must-revalidate:サービスワーカーのキャッシュを防止し、ユーザーが常に最新版を取得できるようにする
    3. Content-Security-Policy: default-src 'self'; script-src 'self':サービスワーカーに対して厳格なコンテンツセキュリティポリシーを実装し、同一オリジンのみスクリプトを許可

Next.jsでのコンテンツセキュリティポリシー定義についてさらに学びましょう。

次のステップ

  1. PWA機能の探索:PWAはさまざまなWeb APIを活用して高度な機能を提供できます。バックグラウンド同期、定期的なバックグラウンド同期、File System Access APIなどの機能を探索してアプリケーションを強化することを検討してください。最新のPWA機能についてはWhat PWA Can Do Todayなどのリソースを参照。
  2. 静的エクスポート:サーバーを実行せずに静的ファイルのエクスポートを使用する必要がある場合、Next.js設定を更新してこの変更を有効にできます。Next.js静的エクスポートドキュメントで詳細を確認。ただし、Server Actionsから外部APIの呼び出しに移行し、定義したヘッダーをプロキシに移動する必要があります。
  3. オフラインサポート:オフライン機能を提供するには、Next.jsでSerwistを使用する方法があります。Next.jsとSerwistの統合例はドキュメントで確認できます。:現在このプラグインにはwebpack設定が必要です。
  4. セキュリティ考慮事項:サービスワーカーが適切に保護されていることを確認してください。HTTPSの使用、プッシュメッセージのソース検証、適切なエラーハンドリングの実装が含まれます。
  5. ユーザーエクスペリエンス:プログレッシブエンハンスメント技術を実装し、ユーザーのブラウザが特定のPWA機能をサポートしていない場合でもアプリが適切に動作するように考慮してください。