Claude
Skills
Sign in
Back

notifications-push

Included with Lifetime
$97 forever

Web Push notifications with VAPID — subscribe devices, send targeted pushes, notification preferences, and in-app notification center. Use this skill when the user says "add push notifications", "setup notifications", "add web push", "notification bell", or "push notifications".

General

What this skill does


# Push Notifications

Web Push API notifications with VAPID authentication. Includes device subscription management, targeted push delivery, per-user notification preferences, an in-app notification center with bell icon, and a service worker for background push handling.

## Prerequisites

- Next.js app with `src/` directory and App Router
- `auth` skill installed (`withAuth` available at `@/lib/auth-guard`)
- `db` skill installed (Drizzle ORM + Postgres)
- `add-pwa` skill installed (service worker registration support)
- shadcn/ui initialized

## Installation

```bash
bun add web-push
bun add -D @types/web-push
```

## Environment Variables

Add to `.env.local`:

```env
# Web Push (VAPID)
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your-vapid-public-key
VAPID_PRIVATE_KEY=your-vapid-private-key
VAPID_SUBJECT=mailto:[email protected]
```

### Generate VAPID Keys

```bash
bunx web-push generate-vapid-keys
```

Copy the output into your `.env.local` file.

### Update `src/env.ts`

Add to the `server` object:

```typescript
server: {
  // ... existing variables
  VAPID_PRIVATE_KEY: z.string().min(1),
  VAPID_SUBJECT: z.string().startsWith("mailto:"),
},
```

Add to the `client` object:

```typescript
client: {
  // ... existing variables
  NEXT_PUBLIC_VAPID_PUBLIC_KEY: z.string().min(1),
},
```

Add to the `runtimeEnv` object:

```typescript
runtimeEnv: {
  // ... existing variables
  VAPID_PRIVATE_KEY: process.env.VAPID_PRIVATE_KEY,
  VAPID_SUBJECT: process.env.VAPID_SUBJECT,
  NEXT_PUBLIC_VAPID_PUBLIC_KEY: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
},
```

## What Gets Created

```
src/
├── lib/
│   ├── notifications/
│   │   ├── push.ts                            # Server: initWebPush, sendPushNotification, sendBulkNotifications
│   │   ├── types.ts                           # PushSubscription, NotificationPayload, NotificationPreference
│   │   └── use-push.ts                        # Client hook: requestPermission, subscribe, unsubscribe
│   └── db/
│       └── schema/
│           └── push-subscriptions.ts          # push_subscriptions, notification_preferences, notifications tables
├── components/
│   └── notifications/
│       ├── notification-bell.tsx              # Bell icon with unread count badge
│       ├── notification-center.tsx            # Dropdown notification list
│       └── permission-prompt.tsx              # One-time permission request dialog
├── app/
│   └── api/
│       └── notifications/
│           ├── subscribe/
│           │   └── route.ts                   # POST register push subscription
│           ├── preferences/
│           │   └── route.ts                   # GET/PUT notification preferences
│           └── route.ts                       # GET list, PATCH mark as read
└── public/
    └── sw-push.js                             # Service worker for push events
```

## Database

After applying this skill, push the schema:

```bash
bunx drizzle-kit push
```

## Setup Steps

### Step 1: Create `src/lib/notifications/types.ts`

```typescript
export type PushSubscriptionData = {
  endpoint: string;
  keys: {
    p256dh: string;
    auth: string;
  };
};

export type NotificationPayload = {
  title: string;
  body: string;
  icon?: string;
  url?: string;
  actions?: Array<{
    action: string;
    title: string;
  }>;
};

export type NotificationPreference = {
  roomInvites: boolean;
  mentions: boolean;
  callAlerts: boolean;
  meetingSummaries: boolean;
};

export type NotificationRecord = {
  id: string;
  userId: string;
  title: string;
  body: string;
  url: string | null;
  read: boolean;
  createdAt: string;
};
```

### Step 2: Create `src/lib/notifications/push.ts`

```typescript
import webPush from "web-push";
import { db } from "@/lib/db";
import { pushSubscriptions, notifications } from "@/lib/db/schema/push-subscriptions";
import { eq } from "drizzle-orm";
import type { NotificationPayload } from "./types";

let initialized = false;

export function initWebPush() {
  if (initialized) return;

  const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
  const privateKey = process.env.VAPID_PRIVATE_KEY;
  const subject = process.env.VAPID_SUBJECT;

  if (!publicKey || !privateKey || !subject) {
    throw new Error(
      "VAPID keys not configured. Set NEXT_PUBLIC_VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT."
    );
  }

  webPush.setVapidDetails(subject, publicKey, privateKey);
  initialized = true;
}

/**
 * Send a push notification to a specific user.
 * Sends to all subscribed devices for that user.
 * Also stores the notification in the database for the in-app notification center.
 */
export async function sendPushNotification(
  userId: string,
  payload: NotificationPayload
): Promise<{ sent: number; failed: number }> {
  initWebPush();

  // Store in-app notification
  await db.insert(notifications).values({
    userId,
    title: payload.title,
    body: payload.body,
    url: payload.url ?? null,
  });

  // Get all subscriptions for this user
  const subscriptions = await db
    .select()
    .from(pushSubscriptions)
    .where(eq(pushSubscriptions.userId, userId));

  let sent = 0;
  let failed = 0;

  const pushPayload = JSON.stringify({
    title: payload.title,
    body: payload.body,
    icon: payload.icon ?? "/icon-192x192.png",
    url: payload.url ?? "/",
    actions: payload.actions ?? [],
  });

  for (const sub of subscriptions) {
    try {
      await webPush.sendNotification(
        {
          endpoint: sub.endpoint,
          keys: {
            p256dh: sub.p256dh,
            auth: sub.auth,
          },
        },
        pushPayload
      );
      sent++;
    } catch (error) {
      failed++;

      // Remove expired/invalid subscriptions (410 Gone)
      if (
        error instanceof webPush.WebPushError &&
        error.statusCode === 410
      ) {
        await db
          .delete(pushSubscriptions)
          .where(eq(pushSubscriptions.id, sub.id));
      }
    }
  }

  return { sent, failed };
}

/**
 * Send a push notification to multiple users.
 */
export async function sendBulkNotifications(
  userIds: string[],
  payload: NotificationPayload
): Promise<{ totalSent: number; totalFailed: number }> {
  let totalSent = 0;
  let totalFailed = 0;

  const results = await Promise.allSettled(
    userIds.map((userId) => sendPushNotification(userId, payload))
  );

  for (const result of results) {
    if (result.status === "fulfilled") {
      totalSent += result.value.sent;
      totalFailed += result.value.failed;
    } else {
      totalFailed++;
    }
  }

  return { totalSent, totalFailed };
}
```

### Step 3: Create `src/lib/notifications/use-push.ts`

```tsx
"use client";

import { useState, useEffect, useCallback } from "react";

function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, "+")
    .replace(/_/g, "/");

  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }

  return outputArray;
}

type UsePushReturn = {
  isSupported: boolean;
  isSubscribed: boolean;
  isLoading: boolean;
  permission: NotificationPermission | "unsupported";
  requestPermission: () => Promise<NotificationPermission>;
  subscribe: () => Promise<void>;
  unsubscribe: () => Promise<void>;
};

export function usePush(): UsePushReturn {
  const [isSupported, setIsSupported] = useState(false);
  const [isSubscribed, setIsSubscribed] = useState(false);
  const [isLoading, setIsLoading] = useState(true);
  const [permission, setPermission] = useState<NotificationPermission | "unsupported">("unsupported");

  // Check support and current subscription status
  useEffect(() => {
    const checkSupport = async () => {
      const supported =
        typeof window !== "undefined" &&
        "serviceWorker" in navigator &&
      
Files: 1
Size: 39.3 KB
Complexity: 40/100
Category: General

Related in General