NextjsでWebプッシュ通知を実装してみた🔔
Nextjs14(app router)で作成したプロジェクトをPWA化してWebプッシュ通知を実装した実装記録です。
以下、今回作成したプロジェクトのリポジトリです。
Webプッシュ通知の仕組み
Web プッシュ通知は、サーバーからブラウザに直接通知を送信できる仕組みです。これにより、アプリが開いていない状態でもユーザーに情報を届けることができます。
Web プッシュ通知の流れ
- ユーザーの許可を取得
・ブラウザの通知許可ダイアログを表示し、ユーザーの同意を得ます。 - プッシュ通知の購読 (Subscription) を登録
・PushManager.subscribe() を使用し、VAPID 鍵を設定して通知の購読情報を取得します。
・取得した購読情報をサーバーに保存します。(今回こちらの実装は、省いてます。) - サーバーからプッシュ通知を送信
・サーバー側で Web Push プロトコルを使用し、購読者に対して通知データを送信します。 - ブラウザで通知を受信し表示
・受信したデータを Service Worker が処理し、通知をユーザーに表示します。
PWAに対応する
iOSでWebプッシュ通知を配信するには、サイトをPWA化する必要があります。
Nextjsで作成したプロジェクトをPWAに対応させるためにappディレクトリ配下にmanifest.tsまたはmanifest.jsonを作成します。
私は以下のサイトからmanifest.jsonを作成してtsファイルに変換してみました。
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に関しては、以下のドキュメントを参照
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プッシュ通知を実現するための以下の役割があります。
- バックグラウンド処理:サービスワーカーはブラウザのメインスレッドとは別に動作するJavaScriptで、ウェブページがアクティブでない時でも通知を処理できます。
- プッシュ通知の受信:サーバーから送信されるプッシュ通知を受け取り、ユーザーに表示するための橋渡し役をします。
- 通知のカスタマイズ:タイトル、本文、アイコン、アクション(ボタン)などをカスタマイズして、より魅力的な通知を作成できます。
- ユーザーインタラクションの処理:通知がクリックされたときの動作を定義し、特定のページへの誘導やデータの更新などのアクションを実行します。
これで実装は完了です。
ディレクトリ構造
/
├── app/
│ ├── api/
│ │ └── send-notification/
│ │ └── route.ts
│ │
│ ├── page.tsx
│ │
│ └── manifest.ts
│
├── hooks/
│ └── use-notification-manager.ts
│
├── public/
│ └── sw.js
│
└── .env
最後に
今回、作成したWebアプリは、こちらです。
IOSの場合は、Webアプリをホーム画面に追加してテストしてみてください。
プッシュ通知がうまく受信できない場合は、以下のサイトを参考にしてみるといいかもしれません。
私は、おやすみモードがオンになっていたことに気づかず時間を溶かしました。
今回、フリーランスになって初めて記事を書いてみました。
次の知識をインプットする為に今、自分ができることを少しずつアウトプットしていこうと思います。
Discussion