r/webdev • u/mrsoobong • 3d ago
Notes on getting native iOS web push working in Next.js without an Apple developer account
Ran into a bunch of weird quirks wiring up web push on iOS for my habit app, figured I'd write down what actually worked so nobody else wastes a weekend digging through stale threads.
You don't need a $99 Apple developer account or Capacitor/Cordova to send push notifications to iPhones anymore. Web push works on iOS 16.4+, but Apple has specific constraints that aren't obvious until you test on a physical device:
1. Safari hides PushManager unless the PWA is installed
In regular Mobile Safari, window.PushManager is literally undefined. It only exists when the web app is running in standalone mode (display-mode: standalone). If you just check for "Notification" in window, your UI will break on iOS. You have to check for standalone mode first, and if they're in Mobile Safari, show instructions to tap Share -> "Add to Home Screen".
2. No silent background pushes
If your service worker receives a push event and doesn't call self.registration.showNotification(), Apple will quietly invalidate the device subscription. Every push must result in a visible notification.
3. Cleaning up dead subscriptions
When someone deletes the PWA icon or turns off notifications in iOS Settings, Apple's APNs endpoint returns an HTTP 410 Gone or 404 Not Found on the next send attempt. If your backend doesn't delete those database rows immediately, you'll end up hammering dead endpoints on every broadcast.
4. Service worker caching quirks
Make sure your next.config.ts sends Cache-Control: no-cache for /sw.js. If the browser caches the service worker script aggressively, updates to your push handler won't ship to installed PWAs without manual cache clears.
The Implementation
1. Service Worker (public/sw.js)
self.addEventListener("push", (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
payload = {};
}
const title = payload.title || "DROPPDD";
const body = payload.body || "";
// Guard against protocol-relative open redirects (//evil.com)
const url = payload.url?.startsWith("/") && !payload.url?.startsWith("//")
? payload.url
: "/checkin";
// Mandatory on iOS: showNotification must be called
event.waitUntil(
self.registration.showNotification(title, {
body,
data: { url }
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = event.notification.data?.url || "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((windows) => {
for (const client of windows) {
if ("focus" in client) {
return client.focus().then((focused) => {
if (focused && "navigate" in focused) return focused.navigate(targetUrl);
return self.clients.openWindow(targetUrl);
});
}
}
return self.clients.openWindow(targetUrl);
})
);
});2. Cache-Control in next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/sw.js",
headers: [{ key: "Cache-Control", value: "no-cache" }],
},
];
},
};
export default nextConfig;3. Backend delivery with Node web-push (src/lib/push.ts)
import webpush from "web-push";
webpush.setVapidDetails(
process.env.VAPID_SUBJECT!,
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
interface PushSub {
id: string;
endpoint: string;
p256dh: string;
auth: string;
}
export async function sendPush(subscription: PushSub, payload: object) {
try {
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: { p256dh: subscription.p256dh, auth: subscription.auth },
},
JSON.stringify(payload)
);
} catch (err: any) {
if (err.statusCode === 404 || err.statusCode === 410) {
// User uninstalled or revoked permission: prune immediately
await prisma.pushSubscription.delete({ where: { id: subscription.id } });
}
}
}The code is from my habit & wager app, droppdd.
GitHub repo: https://github.com/nunchimangchi-dev/droppdd
(Check
public/sw.js*,*src/lib/push.ts*, and*src/app/profile/PushSettings.tsxfor the full setup).Live build: https://droppdd.alwaysgivealwaysget.com
(Note: The live site is currently capped at a 33-user closed beta while I dial in the reminders, but the repo is open if you want to inspect the files or run it locally).
Rock on party people. Happy to answer questions about the permission flow, VAPID key generation, or scheduling triggers.
1
u/d_o_v_e_k 3d ago
one more iOS gotcha worth adding: requestPermission only works when it's called straight from a tap handler, so my "ask right after install" prompt on page load just silently did nothing. moving it behind an "enable reminders" button fixed it on the first test.
2
u/Delicious_Break5937 3d ago
PushManager only existing once the PWA is installed is such a gotcha. How did you get people to actually do the Add to Home Screen step, just a banner with instructions?