How to add PWA push notifications to a Next.js app

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.

The five pieces

In dependency order. All five are required, and nothing reports which one you are missing.

1. A VAPID key pair

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"

2. A manifest, with display: standalone

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' }],
  };
}

3. A service worker that handles push and notificationclick

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

4. The subscribe call, from a user gesture

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),
});

5. A server send that deletes dead subscriptions

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

Six traps, none of which print an error

Ordered by how long each one costs before you find it.

iOS only delivers push to an installed PWA

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.

A missing PushManager looks identical to missing VAPID keys

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.

requestPermission outside a user gesture is auto-denied

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.

The service worker cannot live in app/

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.

localhost works and production does not

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.

userVisibleOnly is not optional

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.

Detecting the iOS case properly

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.

The same chain, pre-wired, in Spark Stack

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:

The push pieces Spark Stack ships and what each one does
PieceWhat it does
lib/push/webpush.tsVAPID setup, saveSubscription, removeSubscription, and a broadcast that drops 410/404 rows.
public/sw.jspush 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_subscriptionsThe 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.

Questions

Do push notifications work on iPhone in 2026?

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.

Do I need a third-party service like Firebase or OneSignal?

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.

Where do I store push subscriptions?

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.

Why does my service worker not receive the push event?

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.

How do I stop my subscription list filling up with dead endpoints?

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.

Where do I see what Spark Stack costs?

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.