DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on • Originally published at devya.dev

Web Push in the Next.js App Router: Field Notes on Service Workers, VAPID, and the iOS Rule That Silently Blocks Everything

Headline: Web Push is a browser API that delivers a server-sent notification to a device while your site is closed, and in a Next.js App Router project it needs exactly three parts: a service worker served from the origin root, a VAPID key pair, and a Node.js-runtime route handler that sends. On iOS Safari it needs a fourth thing nobody documents loudly enough — the user must install the site to the Home Screen first.

I wired Web Push into a Next.js 16 App Router project this month. The happy path took an afternoon. The rest of the week went to iOS, to subscriptions that had quietly died months earlier, and to a service worker the browser refused to replace. These are the notes I wish I had on day one.

Key takeaways

  • Web Push needs a service worker at the origin root (/sw.js), because a service worker's scope can never be broader than the path it is served from. In Next.js that means public/sw.js.
  • VAPID (Voluntary Application Server Identification) is a public/private key pair that authenticates your server to the browser's push service. Generate it once with npx web-push generate-vapid-keys and never rotate it casually.
  • iOS Safari 16.4 and later support Web Push, but only after the user adds the site to the Home Screen and the app runs in display: standalone mode. In a normal iOS tab, window.PushManager is undefined.
  • A push service returning HTTP 404 or 410 means the subscription is permanently dead. Delete the row immediately.
  • Send pushes from the Node.js runtime, not the Edge runtime: the web-push package uses Node's crypto module for aes128gcm payload encryption.

What does Web Push actually require in a Next.js app?

Web Push requires three moving parts and nothing else: a registered service worker, a PushSubscription obtained from registration.pushManager.subscribe(), and a server that signs its requests with VAPID keys. There is no vendor SDK in the critical path — Firebase Cloud Messaging is one browser's push endpoint, not a requirement of the protocol.

The service worker file has to live in public/sw.js so Next.js serves it at /sw.js. A service worker's scope — the set of pages it is allowed to control — defaults to the directory it was served from, so a worker served from /_next/static/sw.js can only control pages under /_next/static/, which is no pages at all. Files in public/ are copied verbatim and never bundled, so you cannot import npm packages there.

// components/EnablePush.tsx (client component)
async function enablePush(vapidPublicKey: string) {
  const reg = await navigator.serviceWorker.register('/sw.js');

  // Must be called inside a user gesture — click handler, not useEffect.
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;

  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: vapidPublicKey,
  });

  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(sub),
  });

  return sub;
}
Enter fullscreen mode Exit fullscreen mode

Chrome and Firefox accept a base64url string for applicationServerKey. Older Safari builds and some Android WebViews still want a Uint8Array, which is why most production code keeps a small urlBase64ToUint8Array() helper around. userVisibleOnly: true is not optional anywhere — you are promising to display a notification for every push you receive, and Chrome will eventually revoke a subscription that repeatedly breaks that promise.

Why does my push prompt do nothing on iOS Safari?

iOS Safari only exposes the Push API to sites installed on the Home Screen. In a normal Safari tab on iOS, window.PushManager is undefined and the permission prompt never appears — no error, no rejected promise, nothing to debug. Web Push landed in iOS 16.4 in March 2023 with exactly this constraint, and it still holds.

const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
const canPrompt = 'PushManager' in window && (!isIOS || isStandalone);
Enter fullscreen mode Exit fullscreen mode

When an iOS user is browsing in a tab, show install instructions instead of a button that does nothing. You also need a manifest declaring "display": "standalone", or the installed shortcut opens back into Safari chrome and never qualifies.

Safari 18.4, shipped in March 2025, added Declarative Web Push: the server sends a JSON payload containing a web_push key set to 8030 plus a notification object, and the browser renders the notification without running your service worker's push handler at all. It is additive — the classic service worker path is still required for Chrome and Firefox.

How do I send a push from a Next.js route handler?

Send pushes from a route handler on the Node.js runtime using the web-push package, which handles VAPID signing and aes128gcm payload encryption. Do not put export const runtime = 'edge' on this route — the encryption path depends on Node's crypto module. On Vercel the Node.js runtime is the default and runs on Fluid Compute.

// app/api/push/send/route.ts
import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:alerts@example.com',
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!,
);

export async function POST(req: Request) {
  const { userId, title, body, url } = await req.json();
  const subs = await db.pushSubscription.findMany({ where: { userId } });

  const results = await Promise.allSettled(
    subs.map((s) =>
      webpush.sendNotification(
        { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
        JSON.stringify({ title, body, url }),
      ),
    ),
  );

  // 404 / 410 = gone forever. Prune now, not "later".
  await Promise.all(
    results.map((r, i) =>
      r.status === 'rejected' && [404, 410].includes(r.reason?.statusCode)
        ? db.pushSubscription.delete({ where: { endpoint: subs[i].endpoint } })
        : null,
    ),
  );

  return Response.json({
    sent: results.filter((r) => r.status === 'fulfilled').length,
  });
}
Enter fullscreen mode Exit fullscreen mode

Keep the payload small. Push services guarantee only about 4 KB of encrypted payload, and encryption overhead eats into that budget. Send an identifier plus a short title, then fetch the full record on click. Payload contents also sit on a third-party push server until delivery, which is a second reason to keep them thin.

// public/sw.js
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};
  event.waitUntil(
    self.registration.showNotification(data.title ?? 'Update', {
      body: data.body,
      data: { url: data.url ?? '/' },
      tag: data.tag, // same tag replaces instead of stacking
    }),
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const target = new URL(event.notification.data.url, self.location.origin);
  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((wins) => {
      const open = wins.find((w) => new URL(w.url).origin === target.origin);
      return open ? open.focus() : clients.openWindow(target.href);
    }),
  );
});
Enter fullscreen mode Exit fullscreen mode

When should I delete a push subscription?

Delete a subscription the moment the push service answers 404 or 410 Gone — those two status codes mean the endpoint will never be valid again. Subscriptions die constantly and silently: users clear site data, reinstall the browser, or revoke permission, and nothing notifies your server.

The other status codes need different handling. A 413 means your payload exceeded the size limit. A 429 means you are rate limited and should honour the Retry-After header instead of looping. A 401 or 403 almost always means your VAPID keys do not match the ones used at subscribe time — which is why rotating VAPID keys forces a re-subscribe across your whole user base.

Browsers can also rotate an endpoint themselves, firing a pushsubscriptionchange event in the service worker. Chrome fires it reliably; support elsewhere is uneven. Treat it as a bonus path and let 410-pruning plus a re-subscribe on the next visit be your real recovery mechanism.

Web Push vs SSE vs WebSockets: which should I use?

Web Push is the only one of the three that works when your site is closed. The deciding question is whether the user is present.

Transport Works with the tab closed Direction Best for
Web Push Yes Server to device Re-engagement, alerts the user must not miss
Server-Sent Events (SSE) No Server to open page AI token streaming, progress, live feeds
WebSockets No Bidirectional Collaborative editing, chat, presence

They compose. In the project I shipped, SSE carries live updates while the tab is open, and a background job fires a Web Push only when the user has had no active session for a few minutes. That single rule stopped the same event arriving twice.

What breaks in production that local dev never shows?

The failure that cost me most was a stale service worker. A browser keeps the old sw.js until the file's bytes change and the new worker finishes installing, so a fixed push handler can sit unused on real devices for a day. Calling self.skipWaiting() in install and clients.claim() in activate shortens that window, and logging a version string from the worker tells you which build actually handled a push.

The second was permission UX. Notification.requestPermission() can only be called from a user gesture, and once a user chooses Block you cannot prompt again from JavaScript on that origin — ever. Prompting on page load burns the one chance you get.

The third was fan-out. Every sendNotification() call is a separate HTTPS round trip to a third-party service, so tens of thousands of them do not belong in a request handler. Move that to a queue or background job, batched with Promise.allSettled.

The fourth was localhost lying to me. Service workers and the Push API are permitted on http://localhost as a secure-context exception, so everything works locally and then fails on a staging host served over plain HTTP. Test push on a real HTTPS origin, on a real phone.

FAQ

Q: Do I need Firebase Cloud Messaging to send Web Push?
A: No. FCM is Chrome's push endpoint, but the Web Push protocol with VAPID lets your own server post directly to whatever endpoint the browser hands you. The web-push npm package speaks that protocol to Chrome, Firefox, and Safari endpoints alike.

Q: Can I send a silent push that does not show a notification?
A: Not on the open web. userVisibleOnly: true is mandatory in Chrome, Firefox, and Safari, and repeatedly receiving a push without displaying a notification can get the subscription revoked.

Q: Why did all my subscriptions stop working after a deploy?
A: Almost always rotated VAPID keys. The public key is baked into every existing PushSubscription, so a new key pair makes stored subscriptions fail authentication and every user has to re-subscribe.

Q: How do I test Web Push without waiting for a real event?
A: Chrome DevTools has a Push field under Application → Service Workers that dispatches a payload straight into your worker's push handler. That tests rendering but skips VAPID and encryption, so also call your send route against your own subscription.

Q: Does Web Push work in a Next.js app installed to the iOS Home Screen?
A: Yes. iOS treats an installed PWA as eligible regardless of framework, as long as the manifest sets "display": "standalone" and the permission request comes from a user gesture inside the installed app.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)