はじめに
自己学習でWebアプリを開発してる途中で「携帯のアプリのように使用したい」「ブラウザを閉じていても通知が届くようにしたい」と思い、その機能を実装するまでの過程を記載しました。
使用している端末がiPhoneのため、iPhone向けの記事になります。
今回作ったアプリのソースコード
この記事では、以下の流れで「アプリを閉じていても通知が届く」仕組みを実装しています。
事前準備
- ライブラリのインストール
- Upstash Redisのアカウント作成
- VAPIDキーの生成
- 環境変数の設定
実装
- PWAの設定(iOSで通知を使うために必要)
- Service Workerの作成(通知を受け取るスクリプト)
- 通知の許可をユーザーに求める
- Push購読をサーバーに保存する
- サーバーから通知を送る
なぜアプリを閉じていても通知が届くのか
通常のJavaScript(setInterval など)はタブを閉じると動きが止まります。
Service Worker は、ページとは別に動く特殊なスクリプトです。普段は停止していますが、サーバーからPush通知が届くとブラウザが自動で起動してくれます。そのため、タブを閉じていても通知を受け取ることができます。
setInterval の場合
Service Worker + Web Push の場合
つまり、最初の設定さえすれば、あとはアプリを開いていなくても通知が届くようになります。
事前準備
1.ライブラリのインストール
npm install web-push @upstash/redis
npm install -D @types/web-push
- web-push:サーバーからPush通知を送るためのライブラリ
- @upstash/redis:Upstash RedisのJavaScriptクライアント
- @types/web-push:TypeScriptで型補完が効くようにするためのもの
2.Upstash Redisのアカウント作成
Upstashとは
Upstashは、HTTPでアクセスできるマネージドRedisサービスです。Push購読情報(どのデバイスに通知を送るか)をサーバー側に保存するために使います。サーバーレス環境(Vercelなど)でも使いやすく、無料プランで始められます。
手順
- Upstash にサインアップ(無料)
- Redis データベースを新規作成(リージョンは ap-northeast-1(東京)推奨)
- ダッシュボードの REST API タブを開く
- 表示されている
UPSTASH_REDIS_REST_URLとUPSTASH_REDIS_REST_TOKENをコピーする
3.VAPIDキーを生成する
VAPIDとは
VAPID(Voluntary Application Server Identification)は、プッシュ通知を送るサーバーが「正規の送信元である」ことを、Pushサービス(AppleやGoogleの通知を配るサーバー)に証明するための鍵ペアです。悪意のあるサーバーが勝手に通知を送れないようにするための仕組みです。公開鍵はブラウザに渡し、秘密鍵はサーバーだけが持ちます。
手順
以下のコマンドを1回だけ実行して生成します。
npx web-push generate-vapid-keys
このような出力が表示されます。
Public Key:
XXXXXXX
Private Key:
XXXXXXX
Public Key → .env.local の NEXT_PUBLIC_VAPID_PUBLIC_KEY に貼り付ける。
Private Key → .env.local の VAPID_PRIVATE_KEY に貼り付ける。
4. 環境変数の設定
.env.local に以下を追加します。
# VAPID キー
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_CONTACT_EMAIL=your@example.com
# Upstash Redis(購読情報の保存先)
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
Vercelにデプロイする場合は、Vercelダッシュボード → Settings → Environment Variables にも同じ値を設定してください。
実装
Step 1:PWAの設定をする
iOSでPush通知を使うには、アプリをホーム画面に追加(PWAとしてインストール)する必要があります。そのために manifest.ts を作成します。
iOS 16.4以降が必要です。それより古いiOSでは通知が届きません。
import type { MetadataRoute } from "next";
// PWA(ホーム画面に追加できるアプリ)の設定
// Next.jsが自動的に /manifest.webmanifest として配信してくれる
export default function manifest(): MetadataRoute.Manifest {
return {
name: "アプリ名",
short_name: "アプリ名", // ホーム画面に表示される短い名前
start_url: "/",
display: "standalone", // ブラウザのUIを隠してアプリっぽく見せる
background_color: "#ffffff",
theme_color: "#000000",
icons: [],
};
}
iOSで通知を使うには Safariのメニューから「ホーム画面に追加」でアプリをインストールしてから、ホーム画面のアイコンから起動してください。Safariでそのまま開いているだけでは通知が届きません。
Androidやデスクトップのブラウザ(Chrome等)はインストール不要でそのまま動きます。
Step 2:Service Workerを作成する
public/sw.js というファイルを作成します。public/ フォルダに置くことで、/sw.js のURLでアクセスできるようになります。
// Service Worker
// このファイルはページとは別に動く特殊なスクリプトです
// 普段は停止していて、Push通知が届いたときにブラウザが起動してくれます
// インストールされたとき:すぐにアクティブな状態にする
self.addEventListener("install", () => {
self.skipWaiting();
});
// アクティブになったとき:開いているすべてのタブを管理下に置く
self.addEventListener("activate", (event) => {
event.waitUntil(clients.claim());
});
// サーバーからPush通知が届いたとき:通知を画面に表示する
self.addEventListener("push", (event) => {
// データがなければ何もしない
if (!event.data) return;
// サーバーから送られてきたデータを取り出す
const data = event.data.json();
// 通知を表示する(showNotification が終わるまで待つ)
event.waitUntil(
self.registration.showNotification(data.title ?? "通知", {
body: data.body, // 通知の本文
icon: "/favicon.ico", // 通知に表示するアイコン
})
);
});
// 通知がタップされたとき:アプリを前面に出す
self.addEventListener("notificationclick", (event) => {
// 通知を閉じる
event.notification.close();
event.waitUntil(
// 開いているタブをすべて確認する
clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((tabList) => {
// すでにタブが開いていればそこにフォーカスする
if (tabList.length > 0) {
return tabList[0].focus();
}
// 開いていなければ新しいタブで開く
return clients.openWindow("/");
})
);
});
Step 3:通知の許可とService Workerの登録
ユーザーに通知の許可を求め、Service Workerを登録する関数を作ります。
// 通知の許可を求める関数
// 戻り値:許可されたら true、されなかったら false
export async function requestNotificationPermission(): Promise<boolean> {
// サーバー側(Next.jsのSSR)では動かさない
if (typeof window === "undefined") return false;
// ブラウザが通知機能をサポートしていない場合は終了
if (!("Notification" in window)) return false;
// すでに許可済みならそのまま true を返す
if (Notification.permission === "granted") return true;
// すでに拒否済みなら false を返す(再度ポップアップは出せない)
if (Notification.permission === "denied") return false;
// ポップアップを表示して許可を求める
const result = await Notification.requestPermission();
// "granted"(許可)なら true、それ以外は false
return result === "granted";
}
// Service Worker を登録する関数
// Service Worker = ページとは別に動く特殊なJS
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
// サーバー側では動かさない
if (typeof window === "undefined") return null;
// Service Worker に対応していないブラウザでは終了
if (!("serviceWorker" in navigator)) return null;
try {
// /sw.js を Service Worker として登録する
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
updateViaCache: "none", // キャッシュを使わず常に最新版を読み込む
});
return registration;
} catch {
// 登録に失敗した場合は null を返す
return null;
}
}
Step 4:Push購読をサーバーに保存する
Push購読(PushSubscription) とは、「このデバイスへの通知はここに送って」という宛先情報です。これをブラウザから取得して、サーバーに保存しておきます。
フロント側:購読情報を取得してサーバーに送る
// VAPID公開鍵(文字列)を Uint8Array(バイナリデータ)に変換する関数
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
// 末尾の空白や改行を取り除く(コピペミス対策)
const trimmed = base64String.trim();
// base64のパディング(=)を補完する
const padding = "=".repeat((4 - (trimmed.length % 4)) % 4);
// base64url形式(- と _)を通常のbase64形式(+ と /)に変換する
const base64 = (trimmed + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
// 文字列をバイナリデータに変換する
const rawData = window.atob(base64);
const buffer = new ArrayBuffer(rawData.length);
const uint8Array = new Uint8Array(buffer);
for (let i = 0; i < rawData.length; i++) {
uint8Array[i] = rawData.charCodeAt(i);
}
return uint8Array;
}
// Push購読をサーバーに登録する関数
export async function subscribeToPush(): Promise<boolean> {
// サーバー側では動かさない
if (typeof window === "undefined") return false;
if (!("serviceWorker" in navigator)) return false;
try {
// Service Worker の準備ができるまで待つ
const registration = await navigator.serviceWorker.ready;
// 環境変数からVAPID公開鍵を取得する
const vapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;
// このデバイスのPush購読情報を取得する
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // 通知を必ず表示する(省略不可)
applicationServerKey: urlBase64ToUint8Array(vapidKey),
});
// 購読情報をサーバーに送って保存してもらう
await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
return true;
} catch (error) {
// エラーが起きたらコンソールに出力して false を返す
console.error("Push購読の登録に失敗しました:", error);
return false;
}
}
applicationServerKey には、VAPID公開鍵を文字列のまま渡すこともできます(MDN)。この記事では Uint8Array に変換して渡しています。
変換するときに出やすい型エラーと、その解決方法は以下の記事で解説しています。
サーバー側:購読情報を保存するAPIルート
import { Redis } from "@upstash/redis";
import { NextResponse } from "next/server";
// Redisクライアントを作って返す関数
// 関数の中で作ることで、ビルド時ではなくリクエスト時に初期化される
function getRedis() {
return new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
}
// POST /api/subscribe
// フロントから購読情報が送られてきたときに呼ばれる(通知を許可したとき)
export async function POST(req: Request) {
const redis = getRedis();
// リクエストのボディ(購読情報)を取り出す
const subscription = await req.json();
// Redisに保存する
await redis.set("push-subscription", JSON.stringify(subscription));
return NextResponse.json({ ok: true });
}
// DELETE /api/subscribe
// 通知を無効にしたときに呼ばれる
export async function DELETE() {
const redis = getRedis();
// Redisから購読情報を削除する
await redis.del("push-subscription");
return NextResponse.json({ ok: true });
}
Step 5:サーバーから通知を送る
保存した購読情報を使って、web-push ライブラリで通知を送ります。
import { Redis } from "@upstash/redis";
import { NextResponse } from "next/server";
import webPush from "web-push";
// Redisクライアントを作って返す関数
function getRedis() {
return new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
}
// POST /api/send-notification
// このAPIを呼ぶと通知が送られる
export async function POST() {
const redis = getRedis();
// VAPIDキーを web-push ライブラリにセットする(送信元の証明)
webPush.setVapidDetails(
`mailto:${process.env.VAPID_CONTACT_EMAIL}`, // 連絡先メールアドレス
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, // 公開鍵
process.env.VAPID_PRIVATE_KEY!, // 秘密鍵
);
// Redisから購読情報を取り出す(Upstashが自動でオブジェクトに戻してくれる)
const subscription = await redis.get<webPush.PushSubscription>("push-subscription");
// 購読情報がなければ通知できない
if (!subscription) {
return NextResponse.json({ ok: false, reason: "購読情報がありません" });
}
try {
// 通知を送る
// 第1引数:どのデバイスに送るか(購読情報)
// 第2引数:通知の内容(JSON文字列)
await webPush.sendNotification(
subscription,
JSON.stringify({
title: "通知タイトル",
body: "通知の本文",
}),
);
return NextResponse.json({ ok: true });
} catch (error) {
// 送信に失敗した場合
console.error("通知の送信に失敗しました:", error);
return NextResponse.json({ ok: false, reason: "送信に失敗しました" });
}
}
@upstash/redis は、取り出した値を自動で JSON.parse() してくれます。自分で JSON.parse() を呼ぶとエラーになるので注意してください。詳しくは以下の記事で解説しています。
初期設定を呼び出す
通知を有効にするボタンから、以下の順番で関数を呼び出します。
"use client";
import { requestNotificationPermission, registerServiceWorker, subscribeToPush } from "@/lib/notifications";
export default function NotificationButton() {
// 通知を有効にするボタンを押したときの処理
async function handleEnableNotification() {
// ① 通知の許可をユーザーに求める
const granted = await requestNotificationPermission();
if (!granted) {
alert("通知が許可されませんでした");
return;
}
// ② Service Worker を登録する
await registerServiceWorker();
// ③ Push購読情報をサーバーに保存する
const success = await subscribeToPush();
if (success) {
alert("通知の設定が完了しました!");
}
}
return <button onClick={handleEnableNotification}>通知を有効にする</button>;
}
この3ステップはアプリを開いているときに1回だけ実行します。設定が完了したあとは、アプリを閉じていても通知が届くようになります。
動作確認
設定後、以下のコマンドで手動送信してテストできます。
curl -X POST https://your-app.vercel.app/api/send-notification
通知が届けば実装完了です。
まとめ
上記手順でアプリを閉じていても通知が届くようになります。
| ステップ | やること |
|---|---|
| Step 1 | src/app/manifest.ts を作成してPWAの設定をする |
| Step 2 | public/sw.js にService Workerを作成する |
| Step 3 | 通知許可・Service Worker登録の関数を実装する |
| Step 4 | Push購読を取得してサーバーに保存する |
| Step 5 | web-push でサーバーから通知を送る |
終わりに
AIの発展でAIとの会話ですぐに実装できると思いますが、一度何をやり、自分で理解し、言語化するために記載しました。
これから技術的な記事はAIにより少なくなると思いますが、私は引き続き、学んだことを記事にしていきたいと思います。
「毎日18時・20時・22時」のように、1日に複数回、決まった時刻に通知を送る方法は次の記事で解説しています。
関連記事
参考記事
