https://github.com/piro0919/next-push
Web Push notifications for Next.js App Router — React hooks, VAPID sender, Service Worker helpers, and CLI
https://github.com/piro0919/next-push
hook hooks nextjs npm-package push-notifications pwa react service-worker typescript vapid web-push
Last synced: 2 months ago
JSON representation
Web Push notifications for Next.js App Router — React hooks, VAPID sender, Service Worker helpers, and CLI
- Host: GitHub
- URL: https://github.com/piro0919/next-push
- Owner: piro0919
- License: mit
- Created: 2026-04-21T02:06:31.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-04-21T04:18:17.000Z (2 months ago)
- Last Synced: 2026-04-21T04:39:30.660Z (2 months ago)
- Topics: hook, hooks, nextjs, npm-package, push-notifications, pwa, react, service-worker, typescript, vapid, web-push
- Language: TypeScript
- Homepage: https://next-push.kkweb.io
- Size: 717 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# next-push
Web Push notifications for Next.js — client hooks, server sender, and Service Worker helpers with full VAPID support.
[](https://www.npmjs.com/package/@piro0919/next-push)
[](./LICENSE)
**🔗 [Live Demo](https://next-push.kkweb.io/)** — subscribe in Chrome/Edge, press "Send test notification", get a real notification.
## Why
- `web-push` is Node-only, weakly typed, and requires manual wiring into React and Next.js
- OneSignal and FCM are overkill for many apps and lock you into a vendor
- This package does all three sides (client / server / SW) for Next.js App Router with TypeScript-first APIs
## Install
```bash
pnpm add @piro0919/next-push
npx next-push init
```
That's it — a working push demo is scaffolded at `/push-demo`.
## Quick Start
```tsx
// app/push-toggle/page.tsx
"use client";
import { usePush } from "@piro0919/next-push";
export default function PushToggle() {
const { subscription, subscribe, unsubscribe, permission } = usePush();
if (permission === "denied") return
Blocked
;
return subscription
? Turn off
: Turn on;
}
```
```ts
// app/api/push/route.ts
import { createPushHandler } from "@piro0919/next-push/server";
import { saveSubscription, deleteSubscription } from "@/lib/db";
export const { POST, DELETE } = createPushHandler({
onSubscribe: saveSubscription,
onUnsubscribe: deleteSubscription,
});
```
```ts
// wherever you want to send a push
import { sendPush } from "@piro0919/next-push/server";
const result = await sendPush(subscription, { title: "Hello", body: "World" });
if (!result.ok && result.gone) await deleteSubscription(subscription.endpoint);
```
## Partial Install
```bash
npx next-push init --send-only # server-side only
npx next-push init --receive-only # client + SW only
```
## API
### `usePush(options?)`
| Return | Type | Notes |
|---|---|---|
| `isSupported` | `boolean` | `false` during SSR and on unsupported browsers |
| `permission` | `'default' \| 'granted' \| 'denied'` | |
| `subscription` | `PushSubscriptionJSON \| null` | |
| `subscribe()` | `() => Promise` | Requests permission and subscribes |
| `unsubscribe()` | `() => Promise` | |
| `isSubscribing` | `boolean` | |
| `error` | `Error \| null` | |
### `sendPush(subscription, payload, options?)`
Returns a discriminated `SendResult`:
- `{ ok: true, statusCode }` — delivered
- `{ ok: false, gone: true, statusCode: 404 | 410 }` — subscription is dead, delete it
- `{ ok: false, gone: false, error, statusCode? }` — other failure (transient or misconfig)
### `createPushHandler({ onSubscribe, onUnsubscribe })`
Returns `{ POST, DELETE }` ready to re-export from `app/api/push/route.ts`.
### Service Worker helpers
See `@piro0919/next-push/sw`. `registerAll({ vapidPublicKey })` wires up `push`, `notificationclick`, `notificationclose`, and `pushsubscriptionchange`.
## Recipes
### Prisma
```prisma
model PushSubscription {
endpoint String @id @unique
p256dh String
auth String
userId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
```ts
// app/api/push/route.ts
import { createPushHandler } from "@piro0919/next-push/server";
import { prisma } from "@/lib/prisma";
export const { POST, DELETE } = createPushHandler({
onSubscribe: async (sub) => {
await prisma.pushSubscription.upsert({
where: { endpoint: sub.endpoint },
create: { endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth },
update: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
});
},
onUnsubscribe: async (endpoint) => {
await prisma.pushSubscription.delete({ where: { endpoint } }).catch(() => {});
},
});
```
### Drizzle
```ts
export const pushSubscriptions = sqliteTable("push_subscriptions", {
endpoint: text("endpoint").primaryKey(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
userId: text("user_id"),
});
```
### iOS: combine with use-pwa
iOS Safari only delivers push notifications when the site is installed as a PWA. Use [use-pwa](https://github.com/piro0919/use-pwa) to detect and prompt installation:
```tsx
"use client";
import { usePwa } from "use-pwa";
import { usePush } from "@piro0919/next-push";
export function NotifyButton() {
const { isPwa } = usePwa();
const { subscribe, isSupported } = usePush();
if (!isPwa) return
Install this app to enable notifications on iOS.
;
if (!isSupported) return null;
return Enable notifications;
}
```
### Rich notification UI (icons, badges, actions)
Send payloads can carry full `Notification` options:
```ts
await sendPush(subscription, {
title: "New message from Alice",
body: "Hi! When are you free?",
icon: "/icons/icon-192.png", // Main notification icon (shown next to the title)
badge: "/icons/badge-72.png", // Monochrome icon for Android status bar
image: "/preview/message.jpg", // Large preview image (Chrome Android only)
tag: "chat-123", // Replaces any notification with the same tag
url: "/chat/123", // Where to go when the notification is clicked
actions: [
{ action: "reply", title: "Reply", icon: "/icons/reply.png" },
{ action: "mark-read", title: "Mark as read" },
],
data: { messageId: 456, userId: "alice" },
});
```
### Default icon / badge at the SW level
If you don't want every sender to repeat the same icon, set defaults at SW registration:
```ts
// src/app/sw.ts (or public/sw.js — use --default-icon with the CLI)
import { registerAll } from "@piro0919/next-push/sw";
registerAll({
vapidPublicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
defaultNotification: {
icon: "/icons/icon-192.png",
badge: "/icons/badge-72.png",
},
});
```
The CLI can inline defaults into the generated `public/sw.js`:
```bash
npx next-push init --default-icon /icons/icon-192.png --default-badge /icons/badge-72.png
```
### Batch sending
Send the same payload to many subscriptions with bounded concurrency:
```ts
import { sendPushBatch } from "@piro0919/next-push/server";
import { prisma } from "@/lib/prisma";
const subs = await prisma.pushSubscription.findMany();
const result = await sendPushBatch(subs, {
title: "Daily digest",
body: "You have 3 new messages.",
}, {
concurrency: 20,
onProgress: (done, total) => console.log(`${done}/${total}`),
});
// Prune dead subscriptions
await prisma.pushSubscription.deleteMany({
where: { endpoint: { in: result.goneEndpoints } },
});
console.log(`${result.sent}/${result.total} delivered, ${result.failed} failures`);
```
### Handling retryable failures
`sendPush` flags transient failures so you can retry with backoff:
```ts
const result = await sendPush(subscription, payload);
if (result.ok) return; // delivered
if (result.gone) {
await db.subscription.delete({ where: { endpoint: subscription.endpoint } });
return;
}
if (result.retryable) {
const delay = (result.retryAfter ?? 60) * 1000;
setTimeout(() => sendPush(subscription, payload), delay);
return;
}
// Permanent failure — log and investigate
console.error("Push failed permanently", result.statusCode, result.error);
```
## Supported environments
| | |
|---|---|
| Next.js | 15+ (App Router only) |
| React | 18+ |
| Node.js | 20+ |
| Chrome / Edge / Firefox (desktop + Android) | Latest 2 versions |
| Safari macOS | 16+ |
| **Safari iOS** | **16.4+ and installed as a PWA only** |
| iOS Chrome / Firefox / Edge | ❌ Not supported (all use WebKit + PWA restriction) |
| In-app browsers (LINE, Twitter, etc.) | ❌ Not supported |
## Roadmap
- v0.2: batched sending, Playwright E2E, richer A2HS recipes, Electron
- v1.0: stable API, semver
## License
MIT © piro0919