1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.jsでWeb Push通知を実装する ─ アプリを閉じていても届く仕組みと設定手順

1
Last updated at Posted at 2026-10-25

はじめに

自己学習でWebアプリを開発してる途中で「携帯のアプリのように使用したい」「ブラウザを閉じていても通知が届くようにしたい」と思い、その機能を実装するまでの過程を記載しました。
使用している端末がiPhoneのため、iPhone向けの記事になります。

今回作ったアプリのソースコード

この記事では、以下の流れで「アプリを閉じていても通知が届く」仕組みを実装しています。

事前準備

  1. ライブラリのインストール
  2. Upstash Redisのアカウント作成
  3. VAPIDキーの生成
  4. 環境変数の設定

実装

  1. PWAの設定(iOSで通知を使うために必要)
  2. Service Workerの作成(通知を受け取るスクリプト)
  3. 通知の許可をユーザーに求める
  4. Push購読をサーバーに保存する
  5. サーバーから通知を送る

なぜアプリを閉じていても通知が届くのか

通常の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など)でも使いやすく、無料プランで始められます。

手順

  1. Upstash にサインアップ(無料)
  2. Redis データベースを新規作成(リージョンは ap-northeast-1(東京)推奨)
  3. ダッシュボードの REST API タブを開く
  4. 表示されている 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 に以下を追加します。

.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では通知が届きません。

src/app/manifest.ts
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でアクセスできるようになります。

public/sw.js
// 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を登録する関数を作ります。

src/lib/notifications.ts
// 通知の許可を求める関数
// 戻り値:許可されたら 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) とは、「このデバイスへの通知はここに送って」という宛先情報です。これをブラウザから取得して、サーバーに保存しておきます。

フロント側:購読情報を取得してサーバーに送る

src/lib/notifications.ts
// 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ルート

src/app/api/subscribe/route.ts
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 ライブラリで通知を送ります。

src/app/api/send-notification/route.ts
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() を呼ぶとエラーになるので注意してください。詳しくは以下の記事で解説しています。

初期設定を呼び出す

通知を有効にするボタンから、以下の順番で関数を呼び出します。

src/components/NotificationButton.tsx
"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日に複数回、決まった時刻に通知を送る方法は次の記事で解説しています。

関連記事

参考記事

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?