Web プッシュ通知の実装手順(VAPID + Service Worker)

2026.02.05
Share:

CancelNavi にリマインダー機能を Claude Code で実装したとき、通知手段としてメールと Web プッシュ通知の両方に対応しました。「解約するの忘れてた」を防ぐには、メールだけでは不十分だと考えたからです。

この記事では、Next.js で Web プッシュ通知を実装する方法を解説します。VAPID キーの生成から Service Worker の設定、通知送信まで、一連の流れを紹介します。

なぜ Web プッシュ通知を実装したのか

2025年12月25日の開発日記にはこう書いています:

  • メールだけでなく、プッシュ通知もあると便利(メールは見逃しやすい)

サブスク解約のリマインダーは、タイミングが重要です。「トライアル終了の前日」に通知が届かないと、意味がありません。

メール通知は確実ですが、受信トレイに埋もれがちです。プッシュ通知なら、スマホやデスクトップに直接届く。見逃す可能性が低くなります。

Web プッシュ通知の仕組み

Web プッシュ通知は、以下の要素で構成されます:

  1. VAPID キー: サーバーを識別するための公開鍵・秘密鍵ペア
  2. Service Worker: バックグラウンドで通知を受け取るスクリプト
  3. Push Subscription: ユーザーのブラウザを識別する情報
  4. 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 プッシュ通知は、以下の制限があります:

  1. PWA としてホーム画面に追加が必須: 通常のブラウザでは動作しない
  2. ユーザーの操作が必要: ページ読み込み時に自動で購読要求できない
  3. 通知のカスタマイズが限定的: アクションボタンなど一部機能が使えない

対処法として、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 プッシュ通知を実装する手順:

  1. VAPID キーを生成: web-push ライブラリを使用
  2. Service Worker を作成: プッシュ受信と通知表示を処理
  3. 購読を取得: ユーザーの許可を得て PushSubscription を取得
  4. 購読情報を保存: データベースにエンドポイントとキーを保存
  5. 通知を送信: web-push でペイロードを送信

Web プッシュ通知は、メール通知よりもリアルタイム性が高く、ユーザーに届きやすい通知手段です。ただし、iOS の制限やユーザーの許可が必要など、メール通知にはない課題もあります。

重要なリマインダー機能では、Web プッシュとメールの両方に対応し、ユーザーが選べるようにするのがベストプラクティスだと感じました。

Zeronova avatar

Zeronovaゼロノバ

Product Manager / AI-Native Builder

Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。

関連プロダクト

CancelNavi(キャンセルナビ)

サブスク解約の最短ルート