Push notifications are supported across major browsers, and with their recent addition to iOS last year, they are a compelling integration for the web. We recently built an installable web application with push notifications called Robert’s App (read the iOS install instructions to test it out).

We built the application with remix.run and implemented notifications with service workers, the web-push npm package that wraps the web push API, and the service worker registration showNotification api.

This web push implementation was pieced together from some 2015 google chrome devrel blog posts, and MDN.

Update: Since the time of this writing, a new version of the remix-pwa project has been published, which offers a great set of APIs for doing web push in a Remix app. This post will still be helpful as a walk through of how to build a web push system from scratch, even if you’re using Remix, as well as if you are not.

When I was implementing, I did not find much by way of documentation on how to implement a push server other than “your backend logic goes here”, so I want to document a full stack implementation. Backend subscription management and architecture was an especially confusing part of the process, so I’ll take some time to discuss the subscription architecture at the top of this post.

If you are coming to this post as confused about web push as I was when I started, one key thing to understand is that your web push implementation will be pinging a third party server maintained by Apple, Google, Microsoft, Mozilla, or the browser vendor where your service worker is installed.

As much of the web push documentation points out, it is very important to implement unsubscribe functionality when you implement subscribe functionality. This provides a better user experience, but as I found out through the implementation process, this also prevents bugs by making sure your application doesn’t try to push to ghost subscriptions. It’s not just a nice to have, it’s really necessary. Subscription management, including unsubscribe functionality is covered here on both the front end and the backend.

This walk through provides a complete implementation of push notifications in a remix.run application. If you follow the walk through to the end, you’ll have working push notifications.

A note about privacy

Push notifications are encrypted during transport but the centralized architecture of push notification services makes it possible for the intermediary server operated by the browser company to read the contents of your push notifications. Keep this in mind when deciding what kind of data to send via push. The only way to prevent this is to add an additional end-to-end encryption layer into your application that encrypts the push notifications before sending and decrypts them after receiving. This is beyond the scope of this article.

General Architecture

This web push implementations functions with two routes, a service worker, a push server, some utility functions, and a database that stores subscriptions.

  • Settings Route – a route that contains the UI for subscribing and unsubscribing.
  • Subscription Route – a route that contains an endpoint you can post to from the settings route.
  • Push Server – a server utility that wraps the web push library up with details about your app (like who to send pushes to).
  • Service Worker – a JavaScript worker that gets registered on first load and contains event bindings to the various install and push lifecycle events.
  • Client Utilities – a collection of utilities for the client side of service worker registration and push subscription.

Here that is in an architecture diagram, laid out:

A flow diagram of the web push architecture described below. Key features include a client, a local server, a third party browser push server, and a service worker.

Our implementation contains a little more than this, for integrating into a remix, but this architecture is reusable across other frameworks and stacks since it is web platform code.

Remix Setup

This walk through uses the web-push npm package to add push notifications to a remix.run application created from the remix indie stack. This will be great for anyone adding push to a remix app, though the concepts will continue to be transferable to other full stack app environments. There is very little “remix code” in this walk through. The main thing remix handles here is routing and building.

If this is your first remix experience, you can read a little more about what we like about Remix.run at Bocoup.

To follow along with this walk through, create a remix app using create-remix.

npx npx create-remix@latest --template remix-run/indie-stack

This tutorial was written with create-remix@2.7.2. If you hit bugs, try starting over with *npx create-remix@2.7.2 –template remix-run/indie-stack`. Remix is great at backwards compatibility, so this walk through should continue to work at this version for a long time. I implemented everything in this walkthrough using node v20.5.0 on my machine, in case you’re following along hunting for bugs in your implementation.

Once you’ve run create-remix you’ll have a new project to work in. Open that project up in your favorite text editor. Going forward from here we are going to add or change the following files:

├── app/
│   ├── models/
│   │   ├── subscription.server.ts // new file
│   │   ├── note.server.ts
│   │   └── user.server.ts
│   ├── routes/
│   │   ├── manifest[.]webmanifest.ts // new file
│   │   ├── push.ts // new file
│   │   ├── settings.tsx // new file
│   │   └── push.server.ts // new file
│   ├── entry.client.tsx
│   ├── entry.worker.ts // new file
│   ├── root.tsx
│   └── utils.ts
├── .env
├── package.json
└── prisma/
    ├── schema.prisma
    └── seed.ts

We have two packages to install for this walkthrough:

npm install –save web-push heroicons

Web-push will generate our one-time secrets as a cli tool, and also function as our runtime library for interacting with the push api in browsers. Heroicons will give us icons to use in the UI to convey to the person using our software what is going on. You can pick a different icon library if you’d like.

Next we’ll generate our public/private keys with the web-push package and store those in our env file. This is a one time operation, though we’ll use web-push to consume and operate with these keys in our application runtime later. The web push protocol uses something called Voluntary Application Server Identification (VAPID) keys for this.

npx web-push generate-vapid-keys

Store the output of that command in your .env file along with a DEFAULT_URL variable that we’ll use to determine what open link to go to when a user clicks on a notification:

DATABASE_URL="file:./data.db?connection_limit=1"
SESSION_SECRET="super-duper-s3cret"
DEFAULT_URL=”http://example.com”
VAPID_PUBLIC_KEY="paste-your-vapid-public-key-here"
VAPID_PRIVATE_KEY="paste-your-vapid-private-key-here"

Backend

Let’s start with the backend, since this is the most underwritten about part of web push.

prisma/schema.prisma

First, we need some schema for storing a user’s subscriptions. The remix template we’re using comes with prisma integrated, so we’ll be writing a prisma schema.

We need to add a Subscription model to the schema.prisma file that comes with the remix template.

A subscription is a signed endpoint returned from service worker registration pushManager.subscribe method in the browser where the user subscribed from. It looks like this:

{
  "endpoint":"https://web.push.apple.com/a-secret",
  "keys":{
    "p256dh":"a-secret",
    "auth":"a-secret"
  }
}

You can ping that endpoint with the corresponding vapid keys you set up earlier using the web-push npm package. Pinging this endpoint will send a push event to the service worker (that we’ll talk about later), which we’ll bind to in the service worker and then send a system notification with the web platform’s showNotification api.

But I’m getting ahead of myself. Let’s start with adding a subscription model to our schema that can store. You’ll notice that we’ve separated out the endpoint property from our subscription object. We’ll use this later as a key for deleting subscriptions when a person using our software unsubscribes, or when we find out the subscription no longer works.

model User {
  …
  subscriptions Subscription[]
}

model Subscription {
  id           String   @id @default(cuid())
  createdAt    DateTime @default(now())
  updatedAt    DateTime @default(now()) @updatedAt
  endpoint     String
  subscription String
  userId       String
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([userId, endpoint])
}

prisma/seed.ts

Next I recommend adding a second user to your seed script so you can test this all out across two accounts when we’re done.

  await prisma.user.create({
    data: {
      email: "boaz@bocoup.com",
      password: {
        create: {
          hash: hashedPassword,
        },
      },
    },
  });

app/models/subscription.server.ts

Now let’s make a subscription model create, read, and delete a subscriptions. We’ll use this to manage the subscription lifecycle across our app.

import { prisma } from "~/db.server";

export function getSubscription({ id }: { id: string }) {
  return prisma.subscription.findUnique({
    where: {
      id,
    },
  });
}

export async function createSubscription({
  userId,
  subscription,
}: {
  userId: string;
  subscription: PushSubscription;
}) {
  return await prisma.subscription.create({
    data: {
      endpoint: subscription.endpoint,
      userId,
      subscription: JSON.stringify(subscription),
    },
  });
}

export function deleteSubscription({
  userId,
  endpoint,
}: {
  userId: string;
  endpoint: string;
}) {
  return prisma.subscription.deleteMany({
    where: {
      userId,
      endpoint,
    },
  });
}

app/models/note.server.ts

And lets modify our note model to fire off a push with a utility API we’llimplement in a later step.

export async function createNote({ name, body, userId }: entityProps) {
  const note = await prisma.note.create({
    data: {
      name,
      body,
      userId,
    },
  });

  if (note) {
    sendPush({ note });
  }

  Return note;
}

app/models/user.server.ts

Then let’s update our user model with a function that gets us all the other users who aren’t the person making a note so that we can notify everyone else in the app when a given person using the software creates a note.

export async export async function getOtherUsers(id: string) {
  return prisma.user.findMany({
    where: {
      id: {
        not: id
      }
    },
    include: {
      subscriptions: true,
    },
  });
}

app/push.server.ts

And lastly on the ~server~side~ lets make our push sending api, which we call in the code above when a note is made. Any part of your app can use this to send a push from the server runtime.

This utility contains takes a note that was just made, gets every other user in the system besides the author of that note, formats a notification the from note title and body, tries to push that notification to all the subscriptions of all the other users in the system, and then deletes all the subscriptions that failed. That last step is key. You don’t want ghost subscriptions hanging around in your database. It’s a waste of your compute resources, and of browser vendor compute resources.

This is a bit of a simplistic implementation. In your app, you probably should not notify all users everytime something happens. It would be better to have granular settings, and only notify people about specific activity they subscribe to for specific reasons.

import { Note, User } from "@prisma/client";
import webpush from "web-push";

import { deleteSubscription } from "./models/subscription.server";
import { getUserById, getOtherUsers } from "./models/user.server";
import { getDomain } from "./utils";

export async function sendPush({
  note,
}: {
  note: Note;
}) {
  webpush.setVapidDetails(
    "https://example.com",
    "BDXxzSsmZeHrm4_sqzQcX2lGBscHAiIP4rO0E1vPmkZbuZBnmCDhwSjJETCpz8Zu4FAkWVndaaqGkfyQQhvuHoQ",
    "ElEUz38EFtDmUXcb1fWK9Tb1deRZJH3rJRqVnkosI6I",
  );

  const actor = (await getUserById(note.userId)) as User;
  const otherUsers = await getOtherUsers(actor.id);
  otherUsers.forEach(async (personToNotify) => {
    if (personToNotify.id === actor.id) {
      return;
    }

    personToNotify.subscriptions.forEach((subscription) => {
      const payload = JSON.stringify({
        title: `New note: ${note.title}`,
        body: `${note.body.substr(0, 36)}...`,
        url: `${getDomain()}/note/${note.id}`,
      });

      webpush
        .sendNotification(JSON.parse(subscription.subscription), payload, {
          vapidDetails: {
            subject: "https://example.com",
            publicKey: "BDXxzSsmZeHrm4_sqzQcX2lGBscHAiIP4rO0E1vPmkZbuZBnmCDhwSjJETCpz8Zu4FAkWVndaaqGkfyQQhvuHoQ",
            privateKey: "ElEUz38EFtDmUXcb1fWK9Tb1deRZJH3rJRqVnkosI6I",
          },
        })
        .catch(async function () {
          // if the subscription didn't let us send a notification
          // delete it so we don't try pinging it again.
          await deleteSubscription({
            note.userId,
            endpoint: subscription.endpoint,
          });
        });
    });
  });
}

Front end

app/entry.worker

It’s finally time to add our service worker! This is the file that will be installed in the browser of a person using your app, and run in the background. The service worker will bind to the install, activate, push, and notificationClick events, and perform the work we need to be done. The push event in particular, will show our notification using the web platform’s built in notifications API, and the notification click event will open the browser and take it to the link we specified in the above sendPush utility.

// entry.worker.ts
/// <reference lib="WebWorker" />

export type {};
declare let self: ServiceWorkerGlobalScope;

self.addEventListener("install", (e: ExtendableEvent) => {
  e.waitUntil(self.skipWaiting());
});

self.addEventListener("activate", (e: ExtendableEvent) => {
  e.waitUntil(self.clients.claim());
});

self.addEventListener("push", (e) => {
  const message = e.data?.json();
  self.registration.showNotification("Bocoup Example App", {
    body: message.body,
    icon: "/apple-touch-icon.png",
    image: "/apple-touch-icon.png",
    badge: "/apple-touch-icon.png",
    data: {
      url: message.url,
    },
  });
});

self.addEventListener("notificationclick", (e) => {
  const urlToOpen = new URL(e.notification.data.url, self.location.origin).href;

  const promiseChain = self.clients
    .matchAll({
      type: "window",
      includeUncontrolled: true,
    })
    .then((windowClients) => {
      let matchingClient;

      for (const windowClient of windowClients) {
        if (windowClient.url === urlToOpen) {
          matchingClient = windowClient;
          break;
        }
      }

      if (matchingClient) {
        return matchingClient.focus();
      } else {
        return self.clients.openWindow(urlToOpen);
      }
    });

  e.waitUntil(promiseChain);
});

package.json

Once you add the service worker, you’ll need to update your package.json with two additional build scripts:

  "scripts": {
    …
    "build:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
    "dev:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
    …
  },

Then start your worker watcher with npm run dev:worker and continue on. Exciting! Our app now has a worker to install when you visit the page. We’ll get to registering it soon 😀.

app/utils.ts

Now let’s add some utility functions to utils.ts that will help us handle service worker and push notification registration from the client.

getDomain will help us out with setting localhost:3000 as our opener url when we’re developing locally. This makes testing our software possible.

export function getDomain() {
  return process.env.NODE_ENV === "development"
    ? "http://localhost:3000"
    : process.env.DEFAULT_URL;
}

This is the function we’ll call from our entry.client.tsx to register our service worker.

export function registerServiceWorker() {
  return navigator.serviceWorker
    .register("/entry.worker.js")
    .then(function (registration) {
      return registration;
    });
}

askPermission is the function we’ll call from our settings UI. You can use it anywhere in your app that you want to offer someone the ability to subscribe to notifications.

export function askPermission() {
  return new Promise((resolve, reject) => {
    const permissionResult = Notification.requestPermission(function (result) {
      resolve(result);
    });

    if (permissionResult) {
      permissionResult.then(resolve, reject);
    }
  }).then((permissionResult) => {
    if (permissionResult !== "granted") {
      throw new Error("We weren't granted permission.");
    }
  });
}

subscribeUserToPush is another function we’ll call from our settings UI, right after we ask for notification permission. It will hit our subscription route with a subscription post. I’ve copied this wholesale from some other documentation. I think MDN.

export async function subscribeUserToPush() {
  const registration =
    await navigator.serviceWorker.register("/entry.worker.js");
  const subscribeOptions = {
    userVisibleOnly: true,
    endpoint: "/subscription",
    applicationServerKey:
      "BDXxzSsmZeHrm4_sqzQcX2lGBscHAiIP4rO0E1vPmkZbuZBnmCDhwSjJETCpz8Zu4FAkWVndaaqGkfyQQhvuHoQ",
  };
  const subscription =
    await registration.pushManager.subscribe(subscribeOptions);
  await fetch("/subscription/", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "subscribe",
      subscription,
    }),
  });
  return subscription;
}

And finally, our unsung hero, unsubscribeUserFromPush, which also hits our subscription endpoint, but with an unsubscribe post. You’ll also include this in your settings UI, and anywhere in your app you’d like to offer someone the ability to unsubscribe from push.

export async function unsubscribeUserFromPush() {
  return navigator.serviceWorker.ready.then(async function (registration) {
    const subscription = await registration.pushManager.getSubscription();
    await subscription?.unsubscribe();

    return fetch("/push/", {
      method: "post",
      headers: {
        "Content-type": "application/json",
      },
      body: JSON.stringify({
        type: "unsubscribe",
        subscription: subscription,
      }),
    });
  });
}

app/routes/manifest[.]webmanifest.ts

While you’re diving into service workers, this is also a great time to add a manifest, which will improve the experience of someone installing your web app to their home screen (a requirement in order to use web push on iPhones). I’ve included the basics here, but be sure to check out all the options on MDN. You can do cool things like add screenshots of your app which will be used as previews, shortcuts that can be opened from the app icon, and more.

import { json } from "@remix-run/node";

export const loader = async () => {
  return json(
    {
      short_name: "Example",
      name: "Bocoup Example app",
      description: "An installable app to teach you about installable apps.",
      categories: ["web development", "learning"],
      display: "standalone",
      background_color: "#f1f5f9",
      orientation: "portrait",
      theme_color: "#8b5cf6",
      start_url: "/f",
      screenshots: [], // recommend adding some of thes
      shortcuts: [] // and these
      icons: [] // and thes
  },
    {
      headers: {
        "Cache-Control": "public, max-age=600",
        "Content-Type": "application/manifest+json",
      },
    },
  );
};

app/routes/subscription.ts

Next let’s create our resource route for receiving subscribe and unsubscribe requests from the UI. Resource routes are remix-ese for routes that don’t export any UI.

This has to be in a separate file from our settings UI so that our web push code doesn’t get bundled to the client.

import { json, type ActionFunction } from "@remix-run/node";
import webpush from "web-push";

import {
  createSubscription,
  deleteSubscription,
} from "~/models/subscription.server";
import { requireUserId } from "~/session.server";

webpush.setVapidDetails(
  "mailto:example@example.com", // change this for your contact information
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY,
);

export const action: ActionFunction = async ({ request }) => {
  const userId = await requireUserId(request);
  const body = await request.json();
  const { type, endpoint } = body;

  switch (type) {
    case "subscribe":
      await createSubscription({
        userId,
        subscription: body.subscription,
      });
      return json(body.subscription, {
        status: 201,
      });
    case "unsubscribe":
      await deleteSubscription({
        userId,
        endpoint,
      });
      return json(true, {
        status: 200,
      });
  }

  return null;
};

app/routes/settings.tsx

And finally let’s build our setting page with the UI for telling the user what subscription state they are in, and changing that state.

This is a long file, and you’ll probably want to modify it a lot to suit your application, so I won’t step through it. But if you’re following this walk through using the remix template and would like to copy/paste a UI into your implementation, here you go:

import {
  CheckCircleIcon,
  ExclamationTriangleIcon,
  InformationCircleIcon,
} from "@heroicons/react/20/solid";
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { useEffect, useState } from "react";

import { getSubscriptions } from "~/models/subscription.server";
import { requireUserId } from "~/session.server";
import {
  askPermission,
  subscribeUserToPush,
  unsubscribeUserFromPush,
} from "~/utils";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const userId = await requireUserId(request);
  const subscriptions = await getSubscriptions({ userId });

  return json({ subscriptions });
};

export default function SettingsPage() {
  const [subscription, setSubscription] = useState<PushSubscription | null>(
    null,
  );
  const [notifications, setNotications] = useState(false);

  // Check if notifications and service worker registrations
  // are supported so we can tell the UI if they are not
  useEffect(() => {
    if (typeof window !== "undefined" && "Notification" in window) {
      setNotications(true);
    }
    if (navigator) {
      navigator.serviceWorker.ready.then(async function (registration) {
        registration.pushManager.getSubscription().then((subscription) => {
          setSubscription(subscription);
        });
      });
    }
  }, [notifications]);

  return (
    <div className="bg-white shadow p-4 space-y-8 w-full max-w-[700px] md:mx-auto">
      {notifications ? (
        <div>
          {subscription ? (
            <div className="rounded-md bg-green-50 p-4">
              <div className="flex">
                <div className="flex-shrink-0">
                  <CheckCircleIcon
                    className="h-5 w-5 text-green-400"
                    aria-hidden="true"
                  />
                </div>
                <div className="ml-3">
                  <h3 className="text-sm font-medium text-green-800">
                    Notifications
                  </h3>
                  <div className="mt-2 text-sm text-green-700">
                    <p>This device has notifications turned on.</p>
                    <button
                      className="rounded-md bg-green-200 mt-2 px-2 py-1.5 text-sm font-medium text-green-800 hover:bg-green-100 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2 focus:ring-offset-green-50"
                      onClick={async () => {
                        await unsubscribeUserFromPush();
                        setSubscription(null);
                      }}
                    >
                      Turn off Push Notifications
                    </button>
                  </div>
                </div>
              </div>
            </div>
          ) : (
            <div>
              <div className="rounded-md bg-yellow-50 p-4">
                <div className="flex">
                  <div className="flex-shrink-0">
                    <ExclamationTriangleIcon
                      className="h-5 w-5 text-yellow-400"
                      aria-hidden="true"
                    />
                  </div>
                  <div className="ml-3">
                    <h3 className="text-sm font-medium text-yellow-800">
                      Notifications
                    </h3>
                    <div className="mt-2 text-sm text-yellow-700">
                      <p>This device does not have notifications turned on.</p>
                    </div>
                    <div className="mt-6">
                      <button
                        className="rounded-md bg-yellow-200 px-2 py-1.5 text-sm font-medium text-yellow-800 hover:bg-yellow-100 focus:outline-none focus:ring-2 focus:ring-yellow-600 focus:ring-offset-2 focus:ring-offset-yellow-50"
                        onClick={async () => {
                          await askPermission();
                          const subscription = await subscribeUserToPush();
                          setSubscription(subscription);
                        }}
                      >
                        Turn on Push Notifications
                      </button>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          )}
        </div>
      ) : (
        <div className="rounded-md bg-blue-50 p-4">
          <div className="flex gap-2">
            <div className="flex-shrink-0">
              <InformationCircleIcon
                className="h-5 w-5 text-blue-400"
                aria-hidden="true"
              />
            </div>

            <p className="text-sm text-blue-700">
              Notifications are not supported in this browser. You can enable
              them if you add this website to your home screen. You can also try
              a different browser.
            </p>
          </div>
        </div>
      )}

    </div>
  );
}

Thanks

Thanks for reading!

And thank you to Stalgia Grigg for the technical review and great points about privacy and encryption surrounding web push architecture.