npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@piro0919/next-push

v0.6.0

Published

Web Push notifications library with VAPID support — framework-agnostic server (Node / Vercel / Cloudflare Workers / Deno / Bun), with React hooks, Service Worker helpers, and a Next.js App Router scaffold

Downloads

1,218

Readme

next-push

Web Push notifications library with VAPID support — framework-agnostic server, React client hooks, and Service Worker helpers. Ships a Next.js App Router scaffold out of the box.

npm license

🔗 Live Demo — subscribe in Chrome/Edge, tweak the payload (title, body, icon, image, tag, click URL), hit Send notification, and get a real push.

Runs anywhere

The server (@piro0919/next-push/server) is pure Fetch API — only fetch + crypto.subtle — so it runs on any modern runtime:

  • ✅ Vercel Functions / Next.js / Remix / SvelteKit
  • ✅ Cloudflare Workers & Pages
  • ✅ Netlify Functions, AWS Lambda (Node 18+)
  • ✅ Deno Deploy, Bun
  • ✅ Plain Node.js with Express / Hono / Fastify / etc.

The CLI (npx next-push init) scaffolds a Next.js App Router setup. If you use another framework, skip the CLI and wire createPushHandler / sendPush yourself — see Non-Next.js usage.

Why

  • web-push is Node-only, weakly typed, and requires manual wiring into client / React / Service Worker
  • OneSignal and FCM are overkill for many apps and lock you into a vendor
  • This package does all three sides (client / server / SW) with a framework-agnostic core and a TypeScript-first API — the Next.js App Router integration is a convenience layer on top, not a requirement

Install

pnpm add @piro0919/next-push
npx next-push init

That's it — a working push demo is scaffolded at /push-demo.

Quick Start

// 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 <p>Blocked</p>;
  return subscription
    ? <button onClick={unsubscribe}>Turn off</button>
    : <button onClick={subscribe}>Turn on</button>;
}
// 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,
});
// 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);

Non-Next.js usage

createPushHandler accepts a Fetch Request and returns a Response, so any runtime with the Fetch API can use it with a thin adapter.

Hono

import { Hono } from "hono";
import { createPushHandler, sendPush } from "@piro0919/next-push/server";

const push = createPushHandler({
  onSubscribe: async (sub) => { /* save to DB */ },
  onUnsubscribe: async (endpoint) => { /* delete from DB */ },
});

const app = new Hono();
app.post("/api/push", (c) => push.POST(c.req.raw));
app.delete("/api/push", (c) => push.DELETE(c.req.raw));

Cloudflare Workers

import { createPushHandler, sendPush } from "@piro0919/next-push/server";

const push = createPushHandler({
  onSubscribe: async (sub) => { /* KV / D1 write */ },
  onUnsubscribe: async (endpoint) => { /* KV / D1 delete */ },
});

export default {
  async fetch(req: Request): Promise<Response> {
    const { pathname } = new URL(req.url);
    if (pathname === "/api/push") {
      if (req.method === "POST") return push.POST(req);
      if (req.method === "DELETE") return push.DELETE(req);
    }
    return new Response("Not found", { status: 404 });
  },
};

Express (Node 18+)

import express from "express";
import { createPushHandler, sendPush } from "@piro0919/next-push/server";

const push = createPushHandler({ onSubscribe: ..., onUnsubscribe: ... });
const app = express();

// Bridge Express req → Fetch Request via the Web Fetch API.
async function toFetchRequest(req: express.Request): Promise<Request> {
  const body = ["GET", "HEAD"].includes(req.method) ? undefined : JSON.stringify(req.body);
  return new Request(`http://localhost${req.url}`, {
    method: req.method,
    headers: req.headers as HeadersInit,
    body,
  });
}

app.post("/api/push", express.json(), async (req, res) => {
  const response = await push.POST(await toFetchRequest(req));
  res.status(response.status).send(await response.text());
});

The client (usePush) and Service Worker (registerAll) helpers are runtime-agnostic — they only touch browser APIs. React 18+ is the only hard requirement there.

Partial Install

npx next-push init --send-only     # server-side only
npx next-push init --receive-only  # client + SW only

API

usePush(options?)

| Option | Type | Default | Notes | |---|---|---|---| | vapidPublicKey | string | process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY | VAPID public key (base64url) | | apiPath | string | /api/push | Same-origin path for subscribe POST / unsubscribe DELETE | | apiBase | string | — | Full URL (or absolute path) used verbatim. Takes precedence over apiPath. Use this to point at a hosted Push SaaS endpoint, e.g. https://nesh.example.com/api/v1/projects/<projectId> | | swPath | string | /sw.js | Service Worker script URL | | swScope | string | — | SW registration scope override |

| Return | Type | Notes | |---|---|---| | isSupported | boolean | false during SSR and on unsupported browsers | | permission | 'default' \| 'granted' \| 'denied' | | | subscription | PushSubscriptionJSON \| null | | | subscribe() | () => Promise<PushSubscriptionJSON> | Requests permission and subscribes | | unsubscribe() | () => Promise<void> | | | 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

Deeper guides for Vercel Marketplace-provisioned storage:

  • Upstash Redis — minimal-ops hash-based store, per-user indexing via sets
  • Neon Postgres — relational store with Neon's serverless HTTP driver

Prisma

model PushSubscription {
  endpoint  String   @id @unique
  p256dh    String
  auth      String
  userId    String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
// 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

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 to detect and prompt installation:

"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 <p>Install this app to enable notifications on iOS.</p>;
  if (!isSupported) return null;
  return <button onClick={subscribe}>Enable notifications</button>;
}

Rich notification UI (icons, badges, actions)

Send payloads can carry full Notification options:

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:

// 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:

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:

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`);

Observability hooks

Plug metrics, logging, and DB cleanup into every sendPush / sendPushBatch call without wrapping the call site. Exactly one of the three fires per subscription; thrown errors and rejected promises from hooks are swallowed (and logged to console.warn) so observability never breaks the push flow.

import { sendPushBatch } from "@piro0919/next-push/server";
import { metrics, logger } from "@/lib/observability";
import { prisma } from "@/lib/prisma";

await sendPushBatch(subs, payload, {
  concurrency: 20,
  onSuccess: (sub, statusCode) => {
    metrics.increment("push.success", { statusCode });
  },
  onGone: async (sub) => {
    // Subscription is dead — clean up immediately.
    await prisma.pushSubscription.delete({
      where: { endpoint: sub.endpoint },
    }).catch(() => {});
  },
  onFailure: (sub, error, { statusCode, retryable, retryAfter }) => {
    logger.error("push failed", { endpoint: sub.endpoint, statusCode, retryable, retryAfter, error });
    metrics.increment("push.failure", { statusCode, retryable });
  },
});

Hooks work identically on the single-call sendPush. For long-running bookkeeping you can return a Promise; it will be awaited in the background without blocking the return.

Handling retryable failures

sendPush flags transient failures so you can retry with backoff:

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

Server / runtime

| | | |---|---| | Any runtime with fetch + crypto.subtle | ✓ (see Runs anywhere) | | Node.js | 18+ | | Next.js (for the CLI scaffold) | 15+, App Router | | React (for usePush) | 18+ |

Browsers (client / SW)

| | | |---|---| | 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 (current) — batched sending, Playwright E2E (Chromium + Firefox), WebKit smoke, customizable demo, GitHub Actions CI
  • v0.3 — persistence adapter recipes (Upstash Redis, Neon Postgres), observability hooks, verified iOS PWA / Android Chrome E2E
  • v1.0 — stable API with semver

See CHANGELOG.md for release notes.

License

MIT © piro0919