Zenn
🔔

NextjsでWebプッシュ通知を実装してみた🔔

2025/03/18に公開
4

Nextjs14(app router)で作成したプロジェクトをPWA化してWebプッシュ通知を実装した実装記録です。

以下、今回作成したプロジェクトのリポジトリです。

https://github.com/kotaro1574/next-web-push

Webプッシュ通知の仕組み

Web プッシュ通知は、サーバーからブラウザに直接通知を送信できる仕組みです。これにより、アプリが開いていない状態でもユーザーに情報を届けることができます。

Web プッシュ通知の流れ

  1. ユーザーの許可を取得
    ・ブラウザの通知許可ダイアログを表示し、ユーザーの同意を得ます。
  2. プッシュ通知の購読 (Subscription) を登録
    ・PushManager.subscribe() を使用し、VAPID 鍵を設定して通知の購読情報を取得します。
    ・取得した購読情報をサーバーに保存します。(今回こちらの実装は、省いてます。)
  3. サーバーからプッシュ通知を送信
    ・サーバー側で Web Push プロトコルを使用し、購読者に対して通知データを送信します。
  4. ブラウザで通知を受信し表示
    ・受信したデータを Service Worker が処理し、通知をユーザーに表示します。

PWAに対応する

iOSでWebプッシュ通知を配信するには、サイトをPWA化する必要があります。

Nextjsで作成したプロジェクトをPWAに対応させるためにappディレクトリ配下にmanifest.tsまたはmanifest.jsonを作成します。

私は以下のサイトからmanifest.jsonを作成してtsファイルに変換してみました。
https://progressier.com/pwa-manifest-generator

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: [
      {
        purpose: "maskable",
        sizes: "512x512",
        src: "icon512_maskable.png",
        type: "image/png",
      },
      {
        purpose: "any",
        sizes: "512x512",
        src: "icon512_rounded.png",
        type: "image/png",
      },
    ],
  }
}

manifestに関しては、以下のドキュメントを参照

https://developer.mozilla.org/ja/docs/Web/Progressive_web_apps/Manifest

Webプッシュ通知に必要なライブラリ

通知とサブスクリプションを処理するためのライブラリをインストールします。

npm i web-push
npm i --save-dev @types/web-push

そして以下のコマンドで VAPID キーを生成します。

npx web-push generate-vapid-keys

生成されたVAPID キーは、.envファイルに保存しておます。

.env

NEXT_PUBLIC_VAPID_PUBLIC_KEY="<生成されたPublec Key>"
VAPID_PRIVATE_KEY="<生成されたPrivate Key>"

Webプッシュ通知用のカスタムフック作成

Webプッシュ通知の機能を管理するためのカスタムフックを作成していきます。

hooks/use-notification-manager.ts

import { useEffect, useState } from "react"

import { sendNotification } from "@/app/actions"

export function useNotificationManager() {
  const [isSupported, setIsSupported] = useState(false)
  const [subscription, setSubscription] = useState<PushSubscription | null>(
    null
  )
  const [error, setError] = useState<string | null>(null)

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

  // Service Workerの登録
  const registerServiceWorker = async () => {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js", {
        scope: "/",
        updateViaCache: "none",
      })
      const sub = await registration.pushManager.getSubscription()
      setSubscription(sub)
    } catch (error) {
      if (error instanceof Error) {
        setError(error.message)
      }
    }
  }

  // Base64文字列をUint8Arrayに変換
  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
  }

  // 通知の購読
  const subscribeToPush = async () => {
    try {
      // 通知許可を要求
      const permission = await Notification.requestPermission()
      if (permission !== "granted") {
        throw new Error("通知の許可が得られませんでした")
      }

      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)
    } catch (error) {
      if (error instanceof Error) {
        setError(error.message)
      }
    }
  }

  // 通知の購読解除
  const unsubscribeFromPush = async () => {
    try {
      if (!subscription) return
      await subscription.unsubscribe()
      setSubscription(null)
    } catch (error) {
      if (error instanceof Error) {
        setError(error.message)
      }
    }
  }

  // 通知の送信
  const sendNotification = async (message: string) => {
    try {
      if (!subscription) {
        return false
      }

      const response = await fetch("/api/send-notification", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          message,
          subscription,
        }),
      })

      const result = await response.json()

      if (!response.ok) {
        throw new Error(result.error || "通知の送信に失敗しました")
      }

      return true
    } catch (error) {
      if (error instanceof Error) {
        setError(error.message)
      }
      return false
    }
  }

  return {
    isSupported,
    subscription,
    error,
    subscribeToPush,
    unsubscribeFromPush,
    sendNotification,
  }
}

このフックは以下の主要な機能を提供します。

  • Webプッシュ通知のサポート確認
  • Service Workerの登録
  • 通知の購読/解除
  • 通知の送信

あっあと、VAPID 公開鍵は Base64 形式で提供されますが、Web プッシュ API で使用するには Uint8Array に変換する必要があります。そのため、urlBase64ToUint8Array() を作成し、通知の購読処理 (subscribeToPush) で applicationServerKey に渡しています。

Webプッシュ通知送信ページの作成

先ほど作成したhooksを使用してUIを構築します。

app/page.tsx

"use client"

import { FormEvent, useState } from "react"

import { useNotificationManager } from "@/hooks/use-notification-manager"

export default function Page() {
  const [message, setMessage] = useState("")
  const {
    isSupported,
    subscription,
    error,
    subscribeToPush,
    unsubscribeFromPush,
    sendNotification,
  } = useNotificationManager()

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

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()
    if (!message.trim()) return

    const success = await sendNotification(message)
    if (success) {
      setMessage("")
    }
  }

  return (
    <div className="container py-8">
      <div className="rounded border p-4">
        <h1 className="mb-4 text-xl font-bold">プッシュ通知</h1>

        {subscription ? (
          <>
            <p>プッシュ通知を購読しています。</p>
            <form onSubmit={handleSubmit}>
              <div className="mt-4">
                <input
                  type="text"
                  placeholder="通知メッセージを入力する"
                  value={message}
                  onChange={(e) => setMessage(e.target.value)}
                  className="mr-2 w-60 rounded border p-2"
                />
              </div>
              <div className="mt-4">
                <button type="submit" className="rounded bg-blue-500 px-4 py-2">
                  送信テスト
                </button>
              </div>
            </form>
            <div className="mt-4">
              <button
                onClick={unsubscribeFromPush}
                className="rounded bg-red-500 px-4 py-2"
              >
                登録解除
              </button>
            </div>
          </>
        ) : (
          <div>
            <p>プッシュ通知に登録されていません。</p>
            <div className="mt-4">
              <button
                onClick={subscribeToPush}
                className="rounded bg-green-500 px-4 py-2"
              >
                登録
              </button>
            </div>
          </div>
        )}

        {error && (
          <p className="mt-4 rounded bg-red-50 p-2 text-red-500">{error}</p>
        )}
      </div>
    </div>
  )
}

Webプッシュ通知送信APIの実装

先ほどインストールした、web-pushライブラリを使用して通知送信を実装していきます。

app/api/send-notification/route.ts

import { NextResponse } from "next/server"
import webpush from "web-push"

// VAPIDの設定
webpush.setVapidDetails(
  "mailto:your-email@example.com",
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
)

export async function POST(request: Request) {
  try {
    const body = await request.json()
    const { message, subscription } = body

    if (!message || !subscription) {
      return NextResponse.json(
        { error: "message と subscription は必須です" },
        { status: 400 }
      )
    }

    // 通知の送信
    await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: "テスト通知",
        body: message,
      })
    )

    return NextResponse.json({ success: true })
  } catch (error) {
    console.error("プッシュ通知の送信エラー:", error)

    const status =
      error instanceof webpush.WebPushError ? error.statusCode : 500

    return NextResponse.json(
      {
        success: false,
        error: "通知の送信に失敗しました",
      },
      { status }
    )
  }
}

サービスワーカーファイルを作成

サーバーから送信された通知データを受けとり処理するためのサービスワーカーファイルを作成します。

publicディレクトリ配下にsw.jsを作成します。

public/sw.js

// サービスワーカーが「push」イベントを受け取ったときの処理
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],
    };

    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>")
  );
});

サービスワーカーにはWebプッシュ通知を実現するための以下の役割があります。

  1. バックグラウンド処理:サービスワーカーはブラウザのメインスレッドとは別に動作するJavaScriptで、ウェブページがアクティブでない時でも通知を処理できます。
  2. プッシュ通知の受信:サーバーから送信されるプッシュ通知を受け取り、ユーザーに表示するための橋渡し役をします。
  3. 通知のカスタマイズ:タイトル、本文、アイコン、アクション(ボタン)などをカスタマイズして、より魅力的な通知を作成できます。
  4. ユーザーインタラクションの処理:通知がクリックされたときの動作を定義し、特定のページへの誘導やデータの更新などのアクションを実行します。

これで実装は完了です。

ディレクトリ構造

/
├── app/
│   ├── api/ 
│   │   └── send-notification/
│   │        └── route.ts
│   │
│   ├── page.tsx
│   │
│   └── manifest.ts
│
├── hooks/
│   └── use-notification-manager.ts
│
├── public/
│   └── sw.js
│
└── .env

最後に

今回、作成したWebアプリは、こちらです。

https://next-web-push-delta.vercel.app/

IOSの場合は、Webアプリをホーム画面に追加してテストしてみてください。

https://developer.mozilla.org/ja/docs/Web/Progressive_web_apps/Guides/Installing

プッシュ通知がうまく受信できない場合は、以下のサイトを参考にしてみるといいかもしれません。
私は、おやすみモードがオンになっていたことに気づかず時間を溶かしました。

https://www.pushcode.jp/blog/webpush-notification-settings-os-browser

今回、フリーランスになって初めて記事を書いてみました。
次の知識をインプットする為に今、自分ができることを少しずつアウトプットしていこうと思います。

4

Discussion

ログインするとコメントできます