CancelNavi にリマインダー機能を Claude Code で実装したとき、通知手段としてメールと Web プッシュ通知の両方に対応しました。「解約するの忘れてた」を防ぐには、メールだけでは不十分だと考えたからです。
この記事では、Next.js で Web プッシュ通知を実装する方法を解説します。VAPID キーの生成から Service Worker の設定、通知送信まで、一連の流れを紹介します。
なぜ Web プッシュ通知を実装したのか
2025年12月25日の開発日記にはこう書いています:
- メールだけでなく、プッシュ通知もあると便利(メールは見逃しやすい)
サブスク解約のリマインダーは、タイミングが重要です。「トライアル終了の前日」に通知が届かないと、意味がありません。
メール通知は確実ですが、受信トレイに埋もれがちです。プッシュ通知なら、スマホやデスクトップに直接届く。見逃す可能性が低くなります。
Web プッシュ通知の仕組み
Web プッシュ通知は、以下の要素で構成されます:
- VAPID キー: サーバーを識別するための公開鍵・秘密鍵ペア
- Service Worker: バックグラウンドで通知を受け取るスクリプト
- Push Subscription: ユーザーのブラウザを識別する情報
- Push Service: ブラウザベンダーが提供するプッシュ配信サービス
処理の流れ:
[あなたのサーバー]
│
│ 1. VAPID キーで署名した通知を送信
▼
[Push Service (Google/Mozilla/Apple)]
│
│ 2. Push Subscription に基づいて配信
▼
[ユーザーのブラウザ / Service Worker]
│
│ 3. 通知を表示
▼
[ユーザーの画面]
Step 1: VAPID キーの生成
VAPID(Voluntary Application Server Identification)キーを生成します。web-push ライブラリを使うと簡単です:
npm install web-push
// scripts/generate-vapid-keys.ts
import webpush from 'web-push';
const vapidKeys = webpush.generateVAPIDKeys();
console.log('Public Key:', vapidKeys.publicKey);
console.log('Private Key:', vapidKeys.privateKey);
生成されたキーを環境変数に保存:
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BLa1b2c3d4...
VAPID_PRIVATE_KEY=abc123def456...
公開鍵はクライアントで使うので NEXT_PUBLIC_ プレフィックスをつけます。秘密鍵はサーバーサイドでのみ使用。
Step 2: Service Worker の登録
Service Worker は、ブラウザがバックグラウンドで実行するスクリプトです。Next.js の public ディレクトリに配置します:
// public/sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
const options = {
body: data.body || '通知があります',
icon: '/icon-192.png',
badge: '/badge-72.png',
data: {
url: data.url || '/',
},
};
event.waitUntil(
self.registration.showNotification(
data.title || 'CancelNavi',
options
)
);
});
// 通知クリック時の処理
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.openWindow(url)
);
});
クライアント側で Service Worker を登録:
// lib/push-notification.ts
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
throw new Error('Service Worker not supported');
}
const registration = await navigator.serviceWorker.register('/sw.js');
return registration;
}
Step 3: プッシュ通知の購読
ユーザーの許可を得て、Push Subscription を取得します:
// lib/push-notification.ts
export async function subscribeToPush() {
const registration = await registerServiceWorker();
// 通知の許可を要求
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('Notification permission denied');
}
// Push Subscription を取得
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
});
return subscription;
}
// VAPID 公開鍵を Uint8Array に変換
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
取得した subscription をサーバーに保存:
// 購読情報をサーバーに送信
async function saveSubscription(subscription: PushSubscription) {
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
}
Step 4: 購読情報の保存
サーバー側で購読情報を保存する API を作成:
// app/api/push/subscribe/route.ts
import { createClient } from '@/lib/supabase/server';
export async function POST(request: Request) {
const subscription = await request.json();
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// 購読情報を保存
const { error } = await supabase
.from('push_subscriptions')
.upsert({
user_id: user.id,
endpoint: subscription.endpoint,
keys: subscription.keys,
created_at: new Date().toISOString(),
});
if (error) {
return Response.json({ error: error.message }, { status: 500 });
}
return Response.json({ success: true });
}
データベーススキーマ:
create table push_subscriptions (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade,
endpoint text not null,
keys jsonb not null,
created_at timestamptz default now(),
unique(user_id, endpoint)
);
Step 5: プッシュ通知の送信
web-push ライブラリを使って通知を送信:
// lib/send-push.ts
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:your-email@example.com',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
interface PushPayload {
title: string;
body: string;
url?: string;
}
export async function sendPushNotification(
subscription: PushSubscriptionJSON,
payload: PushPayload
) {
try {
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: subscription.keys as webpush.PushSubscription['keys'],
},
JSON.stringify(payload)
);
return { success: true };
} catch (error: any) {
// 購読が無効になっている場合
if (error.statusCode === 410) {
return { success: false, expired: true };
}
throw error;
}
}
リマインダー送信のバッチ処理:
// cron/send-reminders.ts
export async function sendReminders() {
const supabase = createClient();
// 今日が通知日のリマインダーを取得
const { data: reminders } = await supabase
.from('reminders')
.select(`
*,
push_subscriptions(*)
`)
.eq('notify_date', today());
for (const reminder of reminders ?? []) {
for (const subscription of reminder.push_subscriptions) {
const result = await sendPushNotification(subscription, {
title: `${reminder.service_name}のトライアルが明日終了`,
body: '解約を忘れないようにしましょう',
url: `/services/${reminder.service_id}`,
});
// 購読が無効なら削除
if (result.expired) {
await supabase
.from('push_subscriptions')
.delete()
.eq('id', subscription.id);
}
}
}
}
iOS での制限
開発日記にはこう書いています:
- iOSでのプッシュ通知
- iOS 16.4以降でWeb Pushに対応したが、まだ制限が多い
iOS Safari での Web プッシュ通知は、以下の制限があります:
- PWA としてホーム画面に追加が必須: 通常のブラウザでは動作しない
- ユーザーの操作が必要: ページ読み込み時に自動で購読要求できない
- 通知のカスタマイズが限定的: アクションボタンなど一部機能が使えない
対処法として、Claude Code に依頼して PWA のインストール促進と、メール通知のフォールバックを用意しました:
// iOS でプッシュ通知が使えない場合はメールにフォールバック
if (!isPushSupported() || isIOSBrowser()) {
return <EmailNotificationForm />;
}
return <PushNotificationToggle />;
通知のタイミング設定
開発日記から:
- 通知のタイミング
- 何日前に通知するのがベストか? → ユーザーが選べるようにした(デフォルト1日前)
リマインダーのタイミングは人によって好みが異なります。「1日前だと忘れる」という人もいれば、「3日前は早すぎる」という人もいます。
そこで、ユーザーが選択できるようにしました:
const REMINDER_OPTIONS = [
{ value: 1, label: '1日前' },
{ value: 3, label: '3日前' },
{ value: 7, label: '1週間前' },
];
// デフォルトは1日前
const defaultDaysBefore = 1;
学んだこと
Web プッシュ通知の実装を通じて学んだこと:
1. VAPID キーの管理は慎重に
VAPID 秘密鍵が漏洩すると、なりすまし通知が可能になります。環境変数で管理し、Git にコミットしないよう注意が必要です。
2. 購読の無効化をハンドリングする
ユーザーがブラウザの設定で通知を無効化すると、送信時に 410 エラーが返ります。これをキャッチして、無効な購読情報を削除する処理が必要です。
3. フォールバックを用意する
Web プッシュ通知はブラウザによって対応状況が異なります。非対応の場合やユーザーが許可しない場合に備えて、メール通知などのフォールバックを用意すべきです。
4. ユーザーの操作をトリガーにする
通知の許可ダイアログは、ユーザーのクリックなど明示的な操作をトリガーにして表示すべきです。ページ読み込み時にいきなり表示すると、拒否される確率が高くなります。
// NG: ページ読み込み時に表示
useEffect(() => {
Notification.requestPermission();
}, []);
// OK: ボタンクリック時に表示
<button onClick={() => subscribeToPush()}>
通知を有効にする
</button>
まとめ
Next.js で Web プッシュ通知を実装する手順:
- VAPID キーを生成: web-push ライブラリを使用
- Service Worker を作成: プッシュ受信と通知表示を処理
- 購読を取得: ユーザーの許可を得て PushSubscription を取得
- 購読情報を保存: データベースにエンドポイントとキーを保存
- 通知を送信: web-push でペイロードを送信
Web プッシュ通知は、メール通知よりもリアルタイム性が高く、ユーザーに届きやすい通知手段です。ただし、iOS の制限やユーザーの許可が必要など、メール通知にはない課題もあります。
重要なリマインダー機能では、Web プッシュとメールの両方に対応し、ユーザーが選べるようにするのがベストプラクティスだと感じました。
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。