コンテンツにスキップ
アプリケーションの構築設定プログレッシブ ウェブ アプリケーション (PWA)

プログレッシブ Web アプリケーション (PWA)

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

PWA を使用すると、次のことが可能になります。

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

Next.js で PWA を作成する

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

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

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

app/manifest.ts
import type { MetadataRoute } from 'next'
 
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: '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: '/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
      },
    ],
  }
}

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

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

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

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

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

これにより、PWAはネイティブアプリの実行可能な代替手段となります。特に、オフラインサポートを必要とせずにインストールプロンプトをトリガーできます。

Webプッシュ通知を使用すると、ユーザーがアプリをアクティブに使用していないときでも、再エンゲージメントすることができます。ここでは、Next.jsアプリケーションでそれらを実装する方法について説明します。

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

'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
}

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

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)
    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>Push notifications are not supported in this browser.</p>
  }
 
  return (
    <div>
      <h3>Push Notifications</h3>
      {subscription ? (
        <>
          <p>You are subscribed to push notifications.</p>
          <button onClick={unsubscribeFromPush}>Unsubscribe</button>
          <input
            type="text"
            placeholder="Enter notification message"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
          />
          <button onClick={sendTestNotification}>Send Test</button>
        </>
      ) : (
        <>
          <p>You are not subscribed to push notifications.</p>
          <button onClick={subscribeToPush}>Subscribe</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 // Don't show install button if already installed
  }
 
  return (
    <div>
      <h3>Install App</h3>
      <button>Add to Home Screen</button>
      {isIOS && (
        <p>
          To install this app on your iOS device, tap the share button
          <span role="img" aria-label="share icon">
            {' '}
            ⎋{' '}
          </span>
          and then "Add to Home Screen"
          <span role="img" aria-label="plus icon">
            {' '}
            ➕{' '}
          </span>.
        </p>
      )}
    </div>
  )
}
 
export default function Page() {
  return (
    <div>
      <PushNotificationManager />
      <InstallPrompt />
    </div>
  )
}

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

3. Server Actionsの実装

app/actions.tsにアクションを格納する新しいファイルを作成します。このファイルは、サブスクリプションの作成、サブスクリプションの削除、および通知の送信を処理します。

app/actions.ts
'use server'
 
import webpush from 'web-push'
 
webpush.setVapidDetails(
  '<mailto:your-email@example.com>',
  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
  // In a production environment, you would want to store the subscription in a database
  // For example: await db.subscriptions.create({ data: sub })
  return { success: true }
}
 
export async function unsubscribeUser() {
  subscription = null
  // In a production environment, you would want to remove the subscription from the database
  // For example: await db.subscriptions.delete({ where: { ... } })
  return { success: true }
}
 
export async function sendNotification(message: string) {
  if (!subscription) {
    throw new Error('No subscription available')
  }
 
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: 'Test Notification',
        body: message,
        icon: '/icon.png',
      })
    )
    return { success: true }
  } catch (error) {
    console.error('Error sending push notification:', error)
    return { success: false, error: 'Failed to send notification' }
  }
}

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

本番環境では、サーバーの再起動にわたって永続化し、複数のユーザーのサブスクリプションを管理するために、サブスクリプションをデータベースに保存する必要があります。

4. VAPIDキーの生成

Web Push APIを使用するには、VAPIDキーを生成する必要があります。

スクリプトファイル(例:generate-vapid-keys.js)を作成します。

./generate-vapid-keys.js
const webpush = require('web-push')
const vapidKeys = webpush.generateVAPIDKeys()
 
console.log('Paste the following keys in your .env file:')
console.log('-------------------')
console.log('NEXT_PUBLIC_VAPID_PUBLIC_KEY=', vapidKeys.publicKey)
console.log('VAPID_PRIVATE_KEY=', vapidKeys.privateKey)

Node.jsでこのスクリプトを実行して、VAPIDキーを生成します。

ターミナル
node generate-vapid-keys.js

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

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('Notification click received.')
  event.notification.close()
  event.waitUntil(clients.openWindow('<https://your-website.com>'))
})

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

  • iconおよびbadgeプロパティを使用して、通知のカスタムアイコンを設定できます。
  • vibrateパターンを調整して、サポートされているデバイスでカスタムバイブレーションアラートを作成できます。
  • dataプロパティを使用して、追加のデータを通知に添付できます。

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

6. ホーム画面への追加

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

アプリケーションをモバイルホーム画面にインストールできるようにするには、以下が必要です。

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

これらの条件が満たされると、最新のブラウザは自動的にユーザーにインストールプロンプトを表示します。beforeinstallpromptを使用してカスタムインストールボタンを提供できますが、これはクロスブラウザおよびプラットフォーム(Safari iOSでは動作しません)ではないため、お勧めしません。

7. ローカルでのテスト

ローカルで通知を表示できるようにするには、以下を確認してください。

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

8. アプリケーションの保護

セキュリティは、特にPWAにとって、Webアプリケーションの重要な側面です。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を活用して高度な機能を提供できます。バックグラウンド同期、定期的なバックグラウンド同期、またはファイルシステムアクセスAPIなどの機能を検討して、アプリケーションを強化してください。PWA機能に関するインスピレーションと最新情報については、What PWA Can Do Todayなどのリソースを参照できます。
  2. 静的エクスポート:アプリケーションでサーバーを実行する必要がなく、代わりにファイルの静的エクスポートを使用する場合は、Next.js構成を更新してこの変更を有効にできます。Next.js静的エクスポートドキュメントで詳細をご覧ください。ただし、サーバーアクションから外部APIの呼び出しに移行し、定義されたヘッダーをプロキシに移動する必要があります。
  3. オフラインサポート:オフライン機能を提供するための1つのオプションは、Next.jsでのSerwistです。Next.jsとSerwistを統合する方法の例は、ドキュメントにあります。注:このプラグインには現在、webpack構成が必要です。
  4. セキュリティに関する考慮事項:サービスワーカーが適切に保護されていることを確認してください。これには、HTTPSの使用、プッシュメッセージのソースの検証、および適切なエラー処理の実装が含まれます。
  5. ユーザーエクスペリエンス:プログレッシブエンハンスメント手法を実装して、ユーザーのブラウザで特定のPWA機能がサポートされていない場合でも、アプリが適切に動作するように検討してください。