Article cover image

Add Notifications to your iOS PWA

  • Julien
    Julien
    Full-Stack Developer

Thanks to a mix of anti-monopoly legislation and a commitment to open standards, the popularity of Progressive Web Apps (PWAs) has been steadily climbing. In a significant leap forward, Apple has recently enabled the delivery of native iOS notifications for PWAs. This development is a game-changer, removing one of the key barriers that often push developers towards publishing apps via the Apple App Store rather than the web.

This article will guide you through the process of integrating iOS push notifications into your PWA. We're diving into this using Typescript, Next.js + Server Actions, and Vercel for hosting & db provider, but the concepts are easily transferable to any javascript framework.

Demo | GitHub

Image for Add Notifications to your iOS PWA

Setup

For the purpose of this demo, we’ll be starting from a blank next.js template.

First, install dependencies. The web-push npm library comes in handy for enabling server-side web push notifications.

pnpm i web-push

For those of you coding in TypeScript, don't forget to grab the type definitions as well:

pnpm i -D @types/web-push

Next on our agenda is generating a VAPID key pair, crucial for authenticating our push notifications. The web-push package simplifies this with a handy script:

pnpx web-push generate-vapid-keys

Getting Started

Time to introduce some environment variables. Feel free to share your public key and contact email with the client side, but keep your private key under wraps. The contact email is there to provide a direct line in case the push service needs to reach out about any issues.

NEXT_PUBLIC_VAPID_PUBLIC_KEY="YOUR_PUBLIC_KEY_HERE"
VAPID_PRIVATE_KEY="YOUR_PRIVATE_KEY_HERE"
VAPID_MAILTO="your-email@example.com"

A manifest file is a must-have for any Progressive Web App (PWA). Here's a basic one to start with, located at public/site.webmanifest. Feel free to jazz it up with icons and theme colors as you like. More on what you can include can be found here

{
  "name": "My Cool PWA",
  "short_name": "My Cool PWA",
  "icons": [],
  "theme_color": "#ffffff",
  "background_color": "#ffffff",
  "display": "standalone"
}

Don't forget to link to your manifest file in the <head> of your HTML. If you're using Next.js like we are, you can leverage its built-in metadata features by adding this to your root layout in app/layout.tsx:

export const metadata: Metadata = {
  title: "My Cool PWA",
  appleWebApp: { capable: true },
  manifest: "/site.webmanifest",
};

Registering Service Worker and Notifications

For simplicity, we'll use short-lived browser cookies to store our subscription info client-side. Imagine this data flowing from your user database instead.

Let's set up our service worker in public/service-worker.js to listen for push notifications:

self.addEventListener("push", async (event) => {
  const { title, body } = await event.data.json();
  self.registration.showNotification(title, { body });
});

Our app features a page for subscribing to notifications and another to trigger them. Here's how our home server component looks:

import Register from "@/components/register";
import { cookies } from "next/headers";
import Registered from "@/components/registered";

export default async function Home() {
  const subId = cookies().get("subId")?.value;

  return (
    <main>
      {subId ? <Registered subId={subId} /> : <Register />}
    </main>
  );
}

Now, let's dive into creating the <Register> component, which will be a client-side component to interact with the browser APIs.

First, we register our service worker on page load and keep it in our state:

const [swRegistration, setSwRegistration] =
    useState<ServiceWorkerRegistration | null>(null);

  useEffect(() => {
    (async function registerServiceWorker() {
      if (!("serviceWorker" in navigator)) {
        return;
      }

      const sw = await navigator.serviceWorker.register("/service-worker.js");
      setSwRegistration(sw);
    })();
  }, []);

In the same client component, we’ll wire up a button that creates a subscription when it receives an onClick event. We’ll then call a Next.js server action to save that subscription server-side (replace with an API call if needed).

const onClick = async () => {
    const result = await window.Notification.requestPermission();
    console.log(result);

    if (result !== "granted" || swRegistration === null) {
      return alert("You must grant permission to send notifications.");
    }

    const subscription = await swRegistration.pushManager.subscribe(
      pushSubscriptionOpts
    );

    await saveSubscription(subscription);
  };

  return (
    <button onClick={onClick}>
      Subscribe
    </button>
  );

pushSubscriptionOpts helper is declared in our utils.ts file:

function urlBase64ToUint8Array(b64String: string) {
  const base64 = b64String
    .padEnd(b64String.length + ((4 - (b64String.length % 4)) % 4), "=")
    .replace(/-/g, "+")
    .replace(/_/g, "/");
  const rawData = Buffer.from(base64, "base64").toString("binary");
  return Uint8Array.from(rawData, (char) => char.charCodeAt(0));
}

if (!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) {
  throw new Error("NEXT_PUBLIC_VAPID_PUBLIC_KEY not set");
}

export const pushSubscriptionOpts: PushSubscriptionOptionsInit = {
  applicationServerKey: urlBase64ToUint8Array(
    process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
  ),
  userVisibleOnly: true,
};

Server actions

We’re using a single actions.ts file. We’ll start by adding this to the start of the file. We’re using Vercel KV to store subscription info but feel free to swap this part out with any database provider.

"use server";

import { cookies } from "next/headers";
import { kv } from "@vercel/kv";
import webpush from "web-push";

if (
  !process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ||
  !process.env.VAPID_PRIVATE_KEY ||
  !process.env.VAPID_MAILTO
) {
  throw new Error("VAPID keys not set");
}

webpush.setVapidDetails(
  `mailto:${process.env.VAPID_MAILTO}`,
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

Now we can add our saveSubscription action that we called from the UI earlier. This simply saves the subscription info as JSON and sets the response cookies for the client to use.

export async function saveSubscription(subscription: PushSubscription) {
  const subId = crypto.randomUUID();

  await kv.hset("subscriptions", { [subId]: subscription });

  cookies().set("subId", subId);
}

At this point, <Register> should be rendering since we have subId from the response cookie. We can now trigger notifications for our subscription. Add the following code for your <Register> server component.

import { sendNotification } from "@/actions";

type Props = { subId: string };

export default async function Registered({ subId }: Props) {
  return (
    <>
      <form action={sendNotification.bind(null, subId)}>
        <button type="submit">
          Send Notification
        </button>
      </form>
      <code>{subId}</code>
    </>
  );
}

Our sendNotification server action looks like this:

export async function sendNotification(subId: string) {
  const subscription = await kv.hget("subscriptions", subId);
  if (!subscription) return;

  const { statusCode, body, headers } = await webpush.sendNotification(
    subscription as webpush.PushSubscription,
    JSON.stringify({
      title: "Hello, from Monogram!",
      body: "👋 Lorem ipsum dolor sit amet, consectetur adipiscing elit",
    })
  );

  console.log({ statusCode, headers, body });
}

Device testing

If you’ve been following on the same tech stack, you should be able to push to Vercel and add the app as a PWA on your iOS device. Remember to create your KV database (if using it) and set your environment variables in your hosting environment.

Since iOS PWA notifications are still in beta, notifications are turned off in Safari by default. Enable them by going to Settings > Safari > Advanced > Experimental Features. Make sure “Notifications” is toggled on.

Resources