Web Push needs five pieces, and every tutorial covers four of them. The missing one is usually iOS, which grants push only to a PWA the user has installed to their Home Screen, so code that works on the author's desktop does nothing at all on an iPhone and reports no error while doing it.
Worth knowing before you write anything. In an iPhone browser tab, PushManager is not in window. Standard feature detection therefore takes the unsupported branch on every iPhone that has not installed your app, and whatever you put in that branch is the entire iOS experience. We got this wrong in our own template and shipped a fake notification there for weeks.
In dependency order. All five are required, and nothing reports which one you are missing.
VAPID is how a push service knows the message came from your server. Generate the pair once and keep the private half out of the client bundle. In Next.js that means the public key may be NEXT_PUBLIC_, and the private key must never be, because anything prefixed that way is compiled into JavaScript your users download.
npx web-push generate-vapid-keys
# .env.local
NEXT_PUBLIC_VAPID_PUBLIC_KEY="B..." # safe in the bundle
VAPID_PRIVATE_KEY="..." # never NEXT_PUBLIC_
VAPID_SUBJECT="mailto:you@example.com"Not decoration. The display mode is what makes the app installable, and on iOS installability is the precondition for push existing at all. Next.js generates this from app/manifest.ts.
// app/manifest.ts
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Your App',
short_name: 'Your App',
start_url: '/',
display: 'standalone', // required for iOS push
icons: [{ src: '/app-icons/icon-192.png', sizes: '192x192', type: 'image/png' }],
};
}This file cannot be bundled by Next.js; it has to be a static file at the origin root so its scope covers the whole site. Put it in public/. Both handlers matter: without notificationclick a tapped notification does nothing, which users read as the notification being broken.
// public/sw.js
self.addEventListener('push', (e) => {
let payload = { title: 'App', body: 'You have a notification.', url: '/' };
try {
if (e.data) payload = { ...payload, ...e.data.json() };
} catch {
if (e.data) payload.body = e.data.text(); // not every sender sends JSON
}
e.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
icon: '/app-icons/icon-192.png',
data: { url: payload.url },
})
);
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
const url = e.notification.data?.url || '/';
e.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const c of clients) if (c.url.includes(url) && 'focus' in c) return c.focus();
return self.clients.openWindow(url);
})
);
});Requesting permission on page load is the second most common mistake. Browsers auto-deny a permission prompt that was not triggered by a click, and a denial is sticky: the user has to go into site settings to undo it, so one bad prompt can cost you that device permanently. Put it behind a button.
const reg =
(await navigator.serviceWorker.getRegistration()) ??
(await navigator.serviceWorker.register('/sw.js'));
await navigator.serviceWorker.ready;
const sub =
(await reg.pushManager.getSubscription()) ??
(await reg.pushManager.subscribe({
userVisibleOnly: true, // required; Chrome refuses silent push
applicationServerKey: urlBase64ToUint8Array(publicKey),
}));
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(sub),
});The step guides skip, and the one that quietly rots. A subscription dies when the user clears site data, uninstalls, or the browser rotates its endpoint, and the push service answers 410 Gone or 404. If you do not delete those rows your send loop grows slower and noisier every month while reporting success.
import webpush from 'web-push';
webpush.setVapidDetails(subject, publicKey, privateKey);
for (const sub of await allSubscriptions()) {
try {
await webpush.sendNotification(sub, JSON.stringify(payload));
} catch (err) {
// 410 Gone or 404: the subscription is dead. Drop it, do not retry.
if (err.statusCode === 410 || err.statusCode === 404) {
await removeSubscription(sub.endpoint);
}
}
}Ordered by how long each one costs before you find it.
Safari 16.4+ supports Web Push, but only once the user has added the site to their Home Screen. In an ordinary iPhone tab PushManager is simply not in window, so feature-detection code takes the unsupported branch and whatever you wrote there is what an iPhone user gets. There is no error and no console warning. Detect it, and say "add this to your Home Screen" rather than "not supported", because the second one is false and the user can act on the first.
This is the trap we shipped ourselves. A boolean cannot tell "this browser needs the app installed" from "this project has no keys yet", so a single false branch has to serve both. If that branch shows a demo or simulated notification, an iPhone user sees what reads as a working push and nothing was ever subscribed. Return a reason, not a boolean.
And the denial persists until the user digs into site settings, so the cost is not one lost prompt, it is that device. Always behind a click, and ideally behind a click on something that explains what the notifications will be about.
It has to be served from the origin root as a static asset, because a worker's scope is limited to its own path. Put it in public/sw.js. A worker at /_next/static/… can only control pages under that prefix, which is no pages.
Service workers are allowed on localhost without TLS as a convenience, and nowhere else. If push works in dev and vanishes on a staging domain, check the certificate before you touch the code.
Chrome rejects a subscription without it. It is a promise that every push you send results in a visible notification, which also means a silent background sync over push is not a thing you can build this way.
The fix is to stop asking can this browser do push and start asking why not. Three inputs, three outcomes, and the one that matters is the middle row:
function isInstalledApp() {
if (window.matchMedia('(display-mode: standalone)').matches) return true;
return navigator.standalone === true; // older iOS reports it here
}
function pushCapability() {
if (!('serviceWorker' in navigator)) return 'unsupported';
if ('PushManager' in window) return 'supported';
// No PushManager in a TAB is iOS's install requirement. Installed and
// still missing it means the browser genuinely cannot do push.
return isInstalledApp() ? 'unsupported' : 'needs-install';
}Then needs-install gets a message the user can act on, and crucially it does not share a branch with your unconfigured or demo path. If a reader takes one thing from this page, that is the one.
Everything above is on this page in full and needs no kit. Spark Stack ships it assembled, which is worth exactly the afternoon it takes to write, and the cleanup step most implementations discover six months later:
| Piece | What it does |
|---|---|
lib/push/webpush.ts | VAPID setup, saveSubscription, removeSubscription, and a broadcast that drops 410/404 rows. |
public/sw.js | push and notificationclick handlers, plus an offline fallback cache. |
app/api/push/* | subscribe, send, and a route exposing the public key so the client never embeds it. |
push_subscriptions | The table, in the schema, with the endpoint as the key the cleanup deletes by. |
pushCapability() | The branch that tells an uninstalled iOS tab to install, rather than lying to it. |
Yes, from Safari 16.4 onwards, but only after the user adds your site to their Home Screen. Until they do, the Push API is absent from the page entirely. This is the single biggest difference between push on iOS and push on Android or desktop, and it is why a tutorial verified in Chrome can ship code that does nothing on an iPhone.
No. Web Push with VAPID talks to the browser vendors' own push services directly, and the `web-push` package is about fifteen lines of setup. A third party is worth paying for when you want delivery analytics, scheduling, segmentation or native iOS and Android alongside web, and is otherwise an extra dependency between you and your users.
Any table keyed on the subscription endpoint, which is the unique string the browser gives you. Store the endpoint and the two keys, and if you want to push to a specific person rather than broadcast, the user id alongside them. The endpoint is the key you will delete by when the push service reports the subscription dead.
Usually scope: a worker only controls pages at or below its own path, so it has to be served from the root. After that, check that the worker you registered is the one that activated, because an old worker stays in control until it is replaced. `self.skipWaiting()` in install plus `self.clients.claim()` in activate makes a new worker take over immediately.
Delete on 410 Gone and 404 from the send call. Those two status codes mean the subscription is permanently gone, not that the send failed temporarily, so retrying them is wasted work. Without this step the list only ever grows.
On its SparkPay pricing page, which is the page checkout charges from and the only place the figure is maintained. Quoting it here would mean a second number that goes stale the day a plan changes.