Web Push
Send push notifications to browsers via VAPID
The web push channel is provided by @betternotify/webpush. It defines slots for title, body, icon, badge, image, tag, data payload, and action buttons. Notifications are delivered through the Push API using the VAPID protocol (RFC 8291 + RFC 8292).
When to use web push
Web push is the right channel when you need to reach users in the browser without a native app. Notifications appear at the OS level even when the tab is closed, as long as the service worker is registered. Use it for time-sensitive alerts, new messages, price drops, or activity updates where the value is in the immediate interruption.
For native mobile push (FCM, APNs), use the push channel instead.
npm install @betternotify/webpushSetup
import { createNotify } from '@betternotify/core';
import { webPushChannel } from '@betternotify/webpush';
const webpush = webPushChannel();
const rpc = createNotify({ channels: { webpush } });Slots
Prop
Type
Send arguments
Prop
Type
Generating VAPID keys
The VAPID protocol requires a P-256 ECDSA key pair to sign requests to push services. Generate one with generateVapidKeys() and store both values as environment variables:
import { generateVapidKeys } from '@betternotify/webpush';
const { publicKey, privateKey } = await generateVapidKeys();
// publicKey → 65-byte uncompressed point, base64url-encoded
// privateKey → 32-byte d parameter, base64url-encodedThe public key is also needed on the client side when calling PushManager.subscribe() as the applicationServerKey. Regenerating keys invalidates all existing subscriptions.
Full example
import { createNotify, createClient } from '@betternotify/core';
import { webPushChannel } from '@betternotify/webpush';
import { vapidTransport } from '@betternotify/webpush/transports';
import { z } from 'zod';
const webpush = webPushChannel();
const rpc = createNotify({ channels: { webpush } });
const catalog = rpc.catalog({
newMessage: rpc
.webpush()
.input(z.object({ from: z.string(), preview: z.string() }))
.title(({ input }) => `New message from ${input.from}`)
.body(({ input }) => input.preview)
.icon('/icons/message.png')
.tag('new-message'),
});
const notify = createClient({
catalog,
transportsByChannel: {
webpush: vapidTransport({
publicKey: process.env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY,
subject: 'mailto:hello@example.com',
}),
},
});
await notify.newMessage.send({
to: {
endpoint: 'https://push.example.com/v1/...',
keys: { p256dh: '...', auth: '...' },
},
input: { from: 'Alice', preview: 'See you tomorrow' },
});For a working Hono server with subscription management and a browser client, see the web-push example.
Transport
The web push channel produces a RenderedWebPush with all resolved slots plus the to field. Use vapidTransport for direct delivery via the VAPID protocol, or build a custom transport with createTransport:
import { createTransport } from '@betternotify/webpush/transports';
import type { RenderedWebPush } from '@betternotify/webpush';
const myTransport = createTransport({
name: 'my-push-provider',
send: async (rendered) => {
// rendered.title, rendered.body, rendered.to, etc.
return { ok: true, data: { results: [] } };
},
});