Skip to main content
Web Push in the Next.js App Router: How We Ship Service Workers, VAPID, and iOS-Safe Permission Flows

Web Push in the Next.js App Router: How We Ship Service Workers, VAPID, and iOS-Safe Permission Flows

August 11, 2026
Full-Stack Engineering
9 min read

Key takeaways

Web Push needs a service worker served from the origin root, because a service worker's scope can never be broader than the path it is served from. In a Next.js App Router project that means public/sw.js, which Next.js serves at /sw.js.

VAPID (Voluntary Application Server Identification) is the public/private key pair that authenticates your server to the browser's push service. Rotating VAPID keys invalidates every existing subscription.

iOS Safari 16.4 and later support Web Push, but only for sites the user has added to the Home Screen and that run in standalone display mode. In a normal iOS tab, window.PushManager is undefined.

A push service that answers HTTP 404 or 410 is telling you the subscription is permanently dead. Delete it on the spot rather than retrying it forever.

Send pushes from the Node.js runtime, never the Edge runtime, because the web-push package relies on 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.

We always place the service worker at public/sw.js so Next.js serves it at /sw.js. A service worker's scope, meaning the set of pages it is allowed to control, defaults to the directory it was served from. A worker served from a nested build path such as /_next/static/sw.js can only control pages under that path, which in practice is no pages at all.

Files in the public directory are copied verbatim and never bundled by Next.js. That means you cannot import npm packages inside sw.js. It has to be plain browser JavaScript.

On the client we register the worker, call Notification.requestPermission() from inside a click handler, then call pushManager.subscribe with userVisibleOnly set to true and applicationServerKey set to the VAPID public key. The resulting PushSubscription object serialises to JSON directly, so it can be POSTed to a subscribe route without any transformation.

Chrome and Firefox accept a base64url string for applicationServerKey. Older Safari builds and some Android WebViews still expect a Uint8Array, which is why production code usually keeps a small urlBase64ToUint8Array helper. The userVisibleOnly flag is mandatory everywhere: you are promising to display a notification for every push received, and Chrome revokes subscriptions that repeatedly break that promise.

Why does the push prompt do nothing on iOS Safari?

iOS Safari only exposes the Push API to sites installed on the Home Screen. In an ordinary Safari tab on iOS, window.PushManager is undefined and no permission prompt ever appears — there is no error and no rejected promise to debug. Web Push arrived in iOS 16.4 in March 2023 with exactly this constraint, and the constraint still applies.

The practical consequence is that permission UI needs an iOS branch. We detect standalone mode with window.matchMedia('(display-mode: standalone)').matches and, when an iOS user is browsing in a tab, we render install instructions instead of a button that silently does nothing. The web app manifest must also declare display as standalone, or the installed shortcut opens back into Safari chrome and never qualifies for push.

Safari 18.4, released 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 the service worker's push handler at all. It is a genuine reliability improvement on Apple platforms, but it is additive: the classic service worker path is still required for Chrome and Firefox.

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

We send pushes from a route handler on the Node.js runtime using the web-push package, which handles VAPID signing and aes128gcm payload encryption. Setting runtime to edge on that route breaks it, because the encryption path depends on Node's crypto module. On Vercel the Node.js runtime is the default and runs on Fluid Compute, so no configuration is needed.

The handler calls webpush.setVapidDetails once with a mailto contact, the public key, and the private key, then loops the user's stored subscriptions through webpush.sendNotification inside Promise.allSettled. Using allSettled rather than Promise.all matters: one dead endpoint should never abort delivery to everyone else in the batch.

Payloads must stay small. Push services guarantee only about 4 KB of encrypted payload, and encryption overhead consumes part of that budget. The pattern that survives production is to send an identifier and a short title, then fetch the full record when the user clicks. Payload contents also sit on a third-party push server until delivery, which is a second reason to keep them thin.

In the service worker, the push event handler calls event.waitUntil around self.registration.showNotification, and the notificationclick handler uses clients.matchAll with type set to window and includeUncontrolled set to true so an already-open tab is focused instead of a duplicate being opened.

When should a push subscription be deleted?

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. This is the highest-value hygiene step in any push system, because subscriptions die constantly and silently when users clear site data, reinstall a browser, or revoke permission — and nothing notifies your server when they do.

Other status codes need different handling. HTTP 413 means the payload exceeded the push service's size limit. HTTP 429 means you are rate limited and should honour the Retry-After header instead of looping. HTTP 401 or 403 almost always means the VAPID keys no longer match the ones used at subscribe time, which is why rotating VAPID keys forces every user in the database to re-subscribe.

Browsers can also rotate an endpoint themselves and fire a pushsubscriptionchange event in the service worker. Chrome fires it reliably; support elsewhere is uneven. We treat it as a bonus path and rely on 410-pruning plus a re-subscribe on the user's next visit as the real recovery mechanism.

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

Web Push is the only one of the three that works when the site is closed. Server-Sent Events and WebSockets both require an open page, so the deciding question is whether the user is present.

Web Push: works with the tab closed, server-to-device only, best for re-engagement and alerts the user must not miss.

Server-Sent Events (SSE): requires an open page, server-to-page only, best for AI token streaming, progress indicators, and live feeds.

WebSockets: requires an open page, bidirectional, best for collaborative editing, chat, and presence.

These transports compose well. In the systems we ship, 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 one rule prevents the same event being delivered twice.

What breaks in production that local development never shows?

The most expensive failure is 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 the install event and clients.claim() in activate shortens that window, and logging a version string from the worker tells you which build actually handled a push.

Permission UX is the second. 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. Prompting on page load spends the only chance you get, so we gate it behind an explicit toggle shown after the user has done something that makes notifications obviously useful.

Fan-out is the third. Sending to a few hundred subscriptions inside a request handler is fine; sending to tens of thousands is not, because every sendNotification call is a separate HTTPS round trip to a third-party service. That work belongs in a queue or background job, batched with Promise.allSettled.

Localhost is the fourth. Service workers and the Push API are permitted on localhost as a secure-context exception, so everything works locally and then fails on a staging host served over plain HTTP. Push has to be tested on a real HTTPS origin and a real phone before it can be trusted.

FAQ

Q: Do we 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 returns. The web-push npm package speaks that protocol to Chrome, Firefox, and Safari endpoints alike.

Q: Can a silent push be sent without showing a notification?

A: Not on the open web. userVisibleOnly set to 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 every subscription 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 must re-subscribe.

Q: How can Web Push be tested without waiting for a real event?

A: Chrome DevTools has a Push field under Application, Service Workers that dispatches a payload straight into the worker's push handler. That tests rendering but skips VAPID and encryption, so the send route should also be called against your own subscription to exercise the server path.

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, provided the manifest sets display to standalone and the permission request comes from a user gesture inside the installed app.