Next.js(PWA)とFCMによるWeb通知の実装ー通知テストを受け取るまでー

:::note 
この記事は[DeNA 25 新卒 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/dena-25-newgrad)の7日目の記事です！他の記事もぜひチェックしてみてください！
:::

# はじめに
初めまして、立命館大学4回生の藤堂ゆうかです。

サークルの後輩との開発で Web アプリケーションを作っていたのですが、通知に苦戦したため、Web アプリの通知についてまとめようと思いこの記事を書きました。

通知実装奮闘記として、**ローカルネットワーク内の端末で通知テストが受信できるようになるまで**の実装を紹介したいと思います。

## 実装環境
- Next.js `v15.1.3` (**App Router**)
  - React: `v19.0.0`
  - TypeScript: `v15.1.3`
- Firebase Cloud Messaging(FCM)
- PWA
- iPhone16
    - iOS `18.0.1`

## この記事のゴール
Mac のローカル環境でアプリケーションを起動し、以下を確認できる状態を目指します。

- localhost での通知テストの受信
- iPhone16 (外部デバイス)での PWA を使用した通知テストの受信

# 実装
実装をするにあたって、全般的な流れは次の公式サイトを、

https://firebase.google.com/docs/cloud-messaging/js/client?hl=ja

Next.js　におけるフォルダ構成やコードの記述方法は次のリポジトリを参考にしました。

https://github.com/devaryakjha/fcm_notifications_nextjs


## 1. Next.js のアプリケーションを立ち上げる
下記コードをターミナルで実行する。
``` 
npx create-next-app@latest
```

:::note
<details>
  <summary>各オプションは下記の通り</summary>

```
✔ What is your project named? … notification-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
Creating a new Next.js app in /Users/yuuka1120/Documents/notification-app.
```
</details>

:::

## 2. Firebaseのプロジェクトを作成し、設定を行う
### 1. Firebaseのプロジェクトを作成
[Firebaseのコンソール](https://console.firebase.google.com/u/0/?hl=ja)からFirebaseのプロジェクトを作成する。

<details>
  <summary>プロジェクトの作成方法</summary>

1. 「プロジェクトを作成する」ボタンを押す
![プロジェクトを作成ボタン](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/1529e42c-5ead-c4e1-9c55-e497e14fd9d2.png)

1. プロジェクト名を記入して「続行」
![プロジェクト作成画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/999e602e-6040-5124-010a-60a3dbe66c7c.png)

1. 「続行」※
![Googleアナリティクス設定画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/35babe29-f6bd-cfca-3db1-52a269269720.png)

1. Default Account for Firebase  を選択して「プロジェクトを作成」
![スクリーンショット 2025-01-06 15.58.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/0e1b45ce-3102-a7ca-b19d-3f625b7693de.png)

:::note
※Googleアナリティクスの設定は任意です。必要に応じてしてください。
:::
</details>

### 2. プロジェクトに Web アプリを追加

<details>
  <summary>Web アプリの追加方法</summary>
  
1. Web アプリを選択する
![プロジェクトのダッシュボード画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/cfc98f48-83b6-23d8-5c76-dc45af694d55.png)

1. アプリ名を記入する
![アプリ名を記入画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/c49ccc0a-dbc9-1708-7cb6-071a9f7ab52b.png)

1. apiKey などを記録しておく
![firebaseの初期化コード](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/bc3a2ac8-57fd-d395-3bf9-3ef9daa40a19.png)

</details>

### 3. FCM の鍵ペアを生成
[公式サイトの生成方法](https://firebase.google.com/docs/cloud-messaging/js/client?hl=ja#generate_a_new_key_pair)

![鍵ペアの生成方法](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/160888de-283c-cf65-6175-2c9555a65ba3.png)


1. 設定(⚙️)から「プロジェクトの設定」を開く
1. 「Cloud Messaging」タブを押下
1. 「Generate key pair」で鍵ペアを生成

![生成された秘密鍵の確認](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/95812093-f0ea-193e-428e-1f44ab09280a.png)

生成できると、生成した鍵ペアが確認できます。これを、次の繋ぎ込みで使用します。

### 4. FCM の秘匿情報を env ファイルに記入

[2.プロジェクトにWebアプリを追加](#2-プロジェクトに-web-アプリを追加)で生成した `apiKey` や、[3.FCMの鍵ペアを生成](#3-fcm-の鍵ペアを生成)で生成した鍵ペアを、`env` ファイルに書き込みます。

``` .env.local
# Webアプリ作成時にアプリ
NEXT_PUBLIC_FIREBASE_APP_ID=*****
NEXT_PUBLIC_FIREBASE_API_KEY=*****
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=*****
NEXT_PUBLIC_FIREBASE_PROJECT_ID=*****
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=*****
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=*****
# 作成した鍵ペア
NEXT_PUBLIC_FIREBASE_VAPID_KEY=*****
```

### 5. Firebase のインストール
ターミナルで、npm を使用して Firebase をインストールします。
```
npm install firebase
```

### 6. firebase.ts の作成

Firebase の初期化を行います。加えて、`firebase/messaging` から `getMessaging` をインポートし、初期化した `firebaseApp` を引数に渡して `messaging` としてエクスポートします。この `messaging` は、後の通知設定で使用します。

```app/utils/firebase/index.ts
"use client";
import { type FirebaseOptions, initializeApp } from "firebase/app";
import { getMessaging } from "firebase/messaging";

const firebaseConfig: FirebaseOptions = {
  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,
};

const firebaseApp = initializeApp(firebaseConfig);
export const messaging = () => getMessaging(firebaseApp);
export default firebaseApp;
```

### 7. 通知関連のカスタムフック実装

3つのカスタムフック、`useFCM`・`useFCMToken`・`useNotificationPermissionStatus` を定義し、`page.tsx` で呼び出します。 

`page.tsx` は、テンプレートの部分を削除し、`useFCM` を呼び出します。
```app/page.tsx
"use client";
import useFCM from "@/app/utils/hooks/useFCM";

export default function Home() {
  const { messages, fcmToken } = useFCM();
  console.log(`messages:`, messages);
  return (
    <div>
      <p>fcmToken: {fcmToken}</p>
      <p>messages: {JSON.stringify(messages)}</p>
    </div>
  );
}
```
`useFCM` では、FCM メッセージの受信を管理します。メッセージが受信されると、`messages` ステートが更新されます。
```app/utils/hooks/useFCM.tsx
import { useEffect, useState } from "react";
import useFCMToken from "@/app/utils/hooks/useFCMToken";
import { messaging } from "@/app/utils/firebase";
import { MessagePayload, onMessage } from "firebase/messaging";

const useFCM = () => {
  const fcmToken = useFCMToken();
  const [messages, setMessages] = useState<MessagePayload[]>([]);
  useEffect(() => {
    if ("serviceWorker" in navigator) {
      const fcmMessaging = messaging();
      const unsubscribe = onMessage(fcmMessaging, (payload) => {
        setMessages((messages) => [...messages, payload]);
      });
      return () => unsubscribe();
    }
  }, [fcmToken]);
  return { fcmToken, messages };
};

export default useFCM;
```
`useFCMToken` では、FCM トークンを取得し、管理します。通知の許可状態が `granted` (付与)のときのみ、FCM トークンが取得されます。
```app/utils/hooks/useFCMToken.tsx
"use client";
import { useEffect, useState } from "react";
import { getToken, isSupported } from "firebase/messaging";
import { messaging } from "@/app/utils/firebase";
import useNotificationPermission from "@/app/utils/hooks/useNotificationPermission";

const useFCMToken = () => {
  const permission = useNotificationPermission();
  const [fcmToken, setFcmToken] = useState<string | null>(null);
  useEffect(() => {
    const retrieveToken = async () => {
      if (typeof window !== "undefined" && "serviceWorker" in navigator) {
        if (permission === "granted") {
          const isFCMSupported = await isSupported();
          if (!isFCMSupported) return;
          const fcmToken = await getToken(messaging(), {
            vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY,
          });
          setFcmToken(fcmToken);
        }
      }
    };
    retrieveToken();
  }, [permission]);
  return fcmToken;
};

export default useFCMToken;
```
`useNotificationPermissionStatus`では、通知の許可状態を管理します。`Notification.requestPermission()` を使用してユーザーに通知の許可を求め、許可された場合に通知を送れるようになります。FCM トークンの取得などにもこの許可が必要になるので、必ずユーザに通知の許可を求めるようにしましょう。
```app/utils/hooks/useNotificationPermissionStatus.tsx
"use client";
import { useEffect, useState } from "react";

const useNotificationPermissionStatus = () => {
  const [permission, setPermission] =
    useState<NotificationPermission>("default");
  useEffect(() => {
    const handler = () => setPermission(Notification.permission);
    handler();
    Notification.requestPermission().then(handler);
    navigator.permissions
      .query({ name: "notifications" })
      .then((notificationPerm) => {
        notificationPerm.onchange = handler;
      });
  }, []);
  return permission;
};

export default useNotificationPermissionStatus;
```

### 8. firebase-messaging-sw.js の作成
publicフォルダ下に `firebase-messaging-sw.js` を作成します。

```public/firebase-messaging-sw.js
// eslint-disable-next-line no-undef
importScripts("https://www.gstatic.com/firebasejs/8.8.0/firebase-app.js");
// eslint-disable-next-line no-undef
importScripts("https://www.gstatic.com/firebasejs/8.8.0/firebase-messaging.js");
const firebaseConfig = {
  apiKey: "*****",
  authDomain: "*****",
  projectId: "*****",
  storageBucket: "*****",
  messagingSenderId: "*****",
  appId: "*****",
  measurementId: "*****",
};
// eslint-disable-next-line no-undef
firebase.initializeApp(firebaseConfig);
// eslint-disable-next-line no-undef
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
  console.log(
    "[firebase-messaging-sw.js] Received background message ",
    payload
  );
  const notificationTitle = payload.notification.title;
  const notificationOptions = {
    body: payload.notification.body,
    icon: "./logo.png",
  };
  self.registration.showNotification(notificationTitle, notificationOptions);
});
```

:::note warn
ここには **`apiKey` などを直接書き込みます。** GitHubに上げてはいけない部分なので、そのままプッシュすべきではないのですが、今のところ最適解が見つかっていないので、今後見つけて [完全版] として記事にする予定です。
:::

### 9. PWA の設定
作成中のアプリケーションを PWA 化します。
下記記事を参考に実装しました。

https://qiita.com/Coa3/items/c81c8c7984bd5fc4ec69

manifestファイルの作成だけは記事内で紹介されているジェネレーターではなく、下記サイトで作成しました。

https://progressier.com/pwa-manifest-generator

### 9. ローカル環境のアプリ起動時に HTTPS を指定
ここまでの設定で localhost の動作確認は取れたのですが、iPhone からでは FCM トークンがうまく取得できませんでした。( `permission` が強制的に `"denied"` になっていた)

その原因は、「アプリが `HTTP` で起動していたから」というものでした。よって、`HTTPS` で起動するように `npm run dev` コマンドにオプションを追加して、ローカル環境で `HTTPS` を使用できるように設定しました。

```diff
  "scripts": {
-    "dev": "next dev",
+    "dev": "next dev --experimental-https",
  }
```

参考にしたのは下記サイトです。

https://zenn.dev/kazu31/articles/nextjs-local-ssh

# 動作確認

## テストの送信方法
1. ダッシュボードの Messaging から「最初のキャンペーンを作成」
![プロジェクトのダッシュボード画面(Messagingタブ選択時)](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/787cbd53-f5be-3e69-ebeb-914304498826.png)


1. 「Firebase Notification メッセージ」を選択して「作成」
![通知の種類選択画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/96815773-bb3e-8438-604c-1e66ceebb393.png)

1. 通知のタイトル・通知テキストを入力して「テスト メッセージを送信」
![通知の作成画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/8778b6d7-7438-a382-7b41-9f898a3ac1f1.png)

1. 取得した FCM トークンを入力(または選択)し「テスト」
![FCMトークン選択画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/9ab78d2d-02a5-5d06-53c9-31f7d337160d.png)

1. 入力した FCM トークンの端末に通知が届いたら動作確認完了

## PC ( Arc ) による動作確認
`npm run dev` した状態で https://localhost:3000/ にアクセスすると、画面に FCM トークンが表示されます。それを Firebase の通知作成画面で打ち込んでテストを送信すると、通知が届いたことが確認できました。
テストボタン押下から実際の受信まではラグもなく一瞬でした。

![Arcに届いた通知](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/2af7a554-b9b4-68c5-59dc-b9acd755b20f.png)

## iPhone16 による動作確認
同じ Wi-Fi に接続した PC でアプリケーションを起動し、起動時にターミナルに表示されるリンクに iPhone でアクセスします。「ホーム画面に追加」してからアプリとして起動します。

通知の許可が自動でうまくできなかったので、「通知の許可をとるボタン」を追加してそれを押下することで通知の許可に成功しました。

```app/page.tsx
      <button
        onClick={() => {
          Notification.requestPermission().then((permission) => {
            alert(permission);
          });
        }}
      >
        allow notification
      </button>
```


あとは PC と同様にトークンを取得して Firebase のダッシュボードで打ち込めば、テストが届きました。
![スマホに届いた通知](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/85335869-c7b0-f960-505e-56bf40173c5c.jpeg)


:::note alert
<details>
    <summary>警告「接続はプライベートではありません」について</summary>
    接続はプライベートではありませんという警告が出ますが、「詳細を表示」→「このWebサイトを閲覧」で確認できました。
    
| 警告画面 | 詳細画面 |
|:-:|:-:|
|   ![接続はプライベートではありませんという警告画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/789bdfa8-7bac-0323-e54d-5eb48fd66a7e.jpeg)|![接続はプライベートではありませんのエラー詳細画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984887/4b344342-5386-ed17-6fce-46c94253aaee.jpeg)   |

</details>

:::

:::note warn
Safari 内だと、常に「ReferenceError: Can't find variable: Notification」というエラーが出ますが、PWA の方を確認したら動作確認できたので今回は対応していません。
:::

# おわりに
通知のテストを飛ばしたはずなのにずっと届かなくて苦労したのですが、最終的な原因が PC 自体の設定でバナー表示させないようになっていただけということがありました。
様々なエラーも対処できていないので、デプロイ先の確認等も終わらせて [完全版]としてまた記事を出せたらいいなと思います。

また、「通知を許可することで、より効果的にアプリを活用できる」ことをユーザーに認識してもらうために、まず通知許可のモーダルを表示し、その後に本来の通知許可ポップアップに繋げることで、設定をよりスムーズに行ってもらいやすくなる、という工夫も見かけました。
サービスで扱う場合にはこちらにも対応したいです。