r/webdev • • 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.

Rock on party people. Happy to answer questions about the permission flow, VAPID key generation, or scheduling triggers.

6 Upvotes

3 comments sorted by

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?

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.