Designly Blog

Push Notifications in Next.js with Web-Push: A Provider-Free Solution

Push Notifications in Next.js with Web-Push: A Provider-Free Solution

Posted in Full-Stack Development by Jay Simons
Published on October 13, 2024

Push notifications are an important way to stay engaged with your users. SMS messages are too personal (and restrictive), and email is often too slow and prone to being filtered out.

When done properly (and not abused), push notifications offer value to both the user and the app owner.

In this article, I'm going to show you how to implement web-push, a simple push notification library in a Next.js app. This process will include:

  1. Installing dependencies
  2. Generating VAPID keys
  3. Creating a push notification provider context
  4. Creating server actions for subscribing and unsubscribing
  5. Creating a service worker to handle incoming push notifications

This solution works on almost all modern mobile and desktop browsers. It also works with iOS devices in PWA mode (i.e. add to home screen).

Furthermore, a demo site and link to the code repo are in the links at the bottom of this article.

Now let's get to coding!

Installing Dependencies

Our production deps:

npm i web-push uuid js-cookie

And if you're using Typescript (which you should), you'll need:

npm i -D @types/web-push @types/uuid @types/js-cookie

Generating VAPID keys

VAPID stands for Voluntary APplication server IDentification. VAPID consists of a public/private key pair. A user subscribes to push notifications using the public key. You would then typically store that subscription in a database. Then when your application wants to send a message, the subscription is looked up, and a JWT is created using the private key. This assures that only servers that know the secret key are allowed to send push notifications to the subscribing service worker.

Thankfully, web-push has a handy little function for generating our VAPID keys:

// scripts/generateVapidKeys.js
const webpush = require('web-push');
const vapidKeys = webpush.generateVAPIDKeys();

console.log(`NEXT_PUBLIC_VAPID_PUBLIC_KEY="${vapidKeys.publicKey}"`);
console.log(`VAPID_PRIVATE_KEY="${vapidKeys.privateKey}"`);

You can then run this script in the terminal:

node scripts/generateVapidKeys.js

Then copy and paste the output into your .env file. You will also need to set the VAPID subject as well, which can either be a mailto: URL or a web URL:

NEXT_PUBLIC_VAPID_PUBLIC_KEY="BLtKReMOK8VqC7pNK1xy36KPK4xJLeY_w6VSL7dogZ9mh7isbNWzHlw3icDcN9aXQbYYFL2aWJcJfbL2RQfwnoQ"
VAPID_PRIVATE_KEY="arjM-nfI8m83-mCw8dGcZPlN9XAvcqYp0ix1brMqkJo"
NEXT_PUBLIC_VAPID_SUBJECT="mailto:[email protected]"

For more information on the VAPID specification, refer to the links below.

Creating Our Provider Context

We will use the React context API to encapsulate the functions for subscribing and unsubscribing, as well as managing registration of the service worker. It will also display a little popup at the bottom of the screen asking the user to subscribe. By creating a provider context and inserting it in our layout.tsx, we can be assured that the service worker and management functions, and state variables will be available site-wide.

'use client';

// lib/client/push-notifications/provider.tsx

import React, { createContext, useContext, useState, useEffect, FunctionComponent } from 'react';
import SubscribePrompt from '@/components/SubscribePrompt';

// utils
import { urlBase64ToUint8Array } from '../../format';
import { v4 as uuidv4 } from 'uuid';
import Cookies from 'js-cookie';

// actions
import { subscribeUser, unsubscribeUser, checkSubscription } from './actions';

// types
import type WebPush from 'web-push';

interface PushNotificationsContext {
	isSupported: boolean;
	subscribeToPush: () => void;
	unsubscribeFromPush: () => void;
	isSubscribed: boolean;
	loadingMessage: string | null;
	deviceId: string | null;
}

interface PushNotificationsProviderProps {
	children: React.ReactNode;
}

const PushNotificationsContext = createContext<PushNotificationsContext>({
	isSupported: false,
	subscribeToPush: () => {},
	unsubscribeFromPush: () => {},
	isSubscribed: false,
	loadingMessage: null,
	deviceId: null,
});

export const DEVICE_ID_KEY = 'device_id';
const DONT_ASK_KEY = 'push_dont_ask';
const SUBSCRIBE_PROMPT_DELAY = 2000; // 1 second

export const PushNotificationsProvider: FunctionComponent<PushNotificationsProviderProps> = ({ children }) => {
	const [subscription, setSubscription] = useState<PushSubscription | null>(null);
	const [subscriptionLoaded, setSubscriptionLoaded] = useState<boolean>(false);
	const [isSupported, setIsSupported] = useState<boolean>(false);
	const [showPrompt, setShowPrompt] = useState<boolean>(false);
	const [deviceId, setDeviceId] = useState<string | null>(null);
	const [loadingMessage, setLoadingMessage] = useState<string | null>(null);

	const isSubscribed = !!subscription;

	/**
	 * Check if browser supports service workers and push notifications
	 * and then register the service worker
	 */
	useEffect(() => {
		if ('serviceWorker' in navigator && 'PushManager' in window) {
			setIsSupported(true);
			(async () => {
				const registration = await navigator.serviceWorker.register('/sw.js', {
					scope: '/',
					updateViaCache: 'none',
				});
				const sub = await registration.pushManager.getSubscription();
				setSubscription(sub);
				setSubscriptionLoaded(true);
			})();
		}
	}, []);

	/**
	 * Check if there is a device id in cookies
	 * If not, generate a new one and store it
	 */
	useEffect(() => {
		const storedDeviceId = Cookies.get(DEVICE_ID_KEY);
		if (!storedDeviceId) {
			const newDeviceId = uuidv4();
			Cookies.set(DEVICE_ID_KEY, newDeviceId, { expires: 365 });
			setDeviceId(newDeviceId);
		} else {
			setDeviceId(storedDeviceId);
		}
	}, []);

	/**
	 * Check if current client subscription is still valid
	 * in the database
	 */
	useEffect(() => {
		if (deviceId && subscription) {
			(async () => {
				const res = await checkSubscription({ deviceId });
				if (!res.success) {
					subscription?.unsubscribe();
					setSubscription(null);
				}
			})();
		}
	}, [deviceId, subscription]);

	/**
	 * Determine if the user should be prompted to subscribe
	 */
	useEffect(() => {
		if (!subscription && subscriptionLoaded && deviceId) {
			// Delay the prompt to give time for the user to interact with the page
			setTimeout(async () => {
				// Check if the user has requested not to be asked again
				const dontAsk = Cookies.get(DONT_ASK_KEY);
				if (!dontAsk) {
					setShowPrompt(true);
				}
			}, SUBSCRIBE_PROMPT_DELAY);
		}
	}, [subscription, subscriptionLoaded, deviceId]);

	async function subscribeToPush() {
		if (!deviceId) return;

		const registration = await navigator.serviceWorker.ready;
		const sub = await registration.pushManager.subscribe({
			userVisibleOnly: true,
			applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
		});
		setSubscription(sub);
		setLoadingMessage('Subscribing...');
		await subscribeUser({ sub: sub as unknown as WebPush.PushSubscription, deviceId });
		setLoadingMessage(null);

		Cookies.remove(DONT_ASK_KEY);
	}

	async function unsubscribeFromPush() {
		if (!deviceId) return;

		setLoadingMessage('Unsubscribing...');
		await subscription?.unsubscribe();
		setSubscription(null);
		await unsubscribeUser({ deviceId });
		setLoadingMessage(null);
	}

	async function dontAsk() {
		Cookies.set(DONT_ASK_KEY, 'true', { expires: 365 });
		setShowPrompt(false);
	}

	return (
		<PushNotificationsContext.Provider
			value={{ isSupported, subscribeToPush, unsubscribeFromPush, isSubscribed, loadingMessage, deviceId }}
		>
			{children}
			{showPrompt && isSupported && !subscription ? (
				<SubscribePrompt
					onSubscribe={() => {
						subscribeToPush();
						setShowPrompt(false);
					}}
					onCancel={() => {
						setShowPrompt(false);
					}}
					onDontAsk={dontAsk}
				/>
			) : null}
		</PushNotificationsContext.Provider>
	);
};

export const usePushNotifications = (): PushNotificationsContext => {
	const context = useContext(PushNotificationsContext);
	if (!context) {
		throw new Error('usePushNotifications must be used within PushNotificationsProvider');
	}
	return context;
};

Here's how this works:

  1. We check the cookies to see if a device ID is set
  2. If no device ID, we create on using UUIDv4
  3. We check to see if serviceWorker and PushManager are supported by the client
  4. We register the service worker
  5. We check for an existing subscription, if exists, we set it to the state variable
  6. We create a usePushNotifications hook to expose certain state variables and methods

Creating Our Server Actions

We will be relying on server actions to handle subscribing and unsubscribing from push notifications. The purpose of these server actions is to handle storing the subscription object in a database for later retrieval.

'use server';

// lib/client/push-notifications/actions.ts

import webpush, { PushSubscription } from 'web-push';

type T_KvListKeyItem = {
	name: string;
	expiration: number;
};

interface I_KvListResponse {
	success: boolean;
	keys: {
		list_complete: boolean;
		keys: T_KvListKeyItem[];
		cacheStatus: string | null;
	};
}

interface I_SubscribeUser {
	sub: PushSubscription;
	deviceId: string;
}

export async function subscribeUser(props: I_SubscribeUser): Promise<ApiResponse> {
	const { sub, deviceId } = props;

	try {
		// logic to store subscription in database

		return { success: true };
	} catch (err) {
		console.error(err);
		return { success: false, message: 'Failed to subscribe user' };
	}
}

interface I_UnsubscribeUser {
	deviceId: string;
}

export async function unsubscribeUser(props: I_UnsubscribeUser): Promise<ApiResponse> {
	const { deviceId } = props;

	try {
		// logic to remove subscription from database

		return { success: true };
	} catch (err) {
		console.error(err);
		return { success: false, message: 'Failed to unsubscribe user' };
	}
}

interface I_CheckSubscription {
	deviceId: string;
}

export async function checkSubscription(props: I_CheckSubscription): Promise<ApiResponse> {
	const { deviceId } = props;
	const prefix = `${KV_PREFIX}${deviceId}`;

	try {
		// logic to retrieve subscription from database

		if (sub) {
			return { success: true };
		}

		return { success: false, message: 'Subscription does not exist' };
	} catch (err) {
		console.error(err);
		return { success: false, message: 'Failed to check subscription' };
	}
}

export interface I_SendPushNotification {
	deviceId: string;
	title: string;
	body: string;
	url: string;
	badge?: string;
}

export async function sendPushNotification(props: I_SendPushNotification): Promise<ApiResponse> {
	const { deviceId, title, body, url, badge } = props;

	try {
		if (!deviceId || !title || !body || !url) {
			throw new Error('Invalid parameters');
		}

		// logic to retrieve subscriptions from database
		
		webpush.setVapidDetails(
			process.env.NEXT_PUBLIC_VAPID_SUBJECT!,
			process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
			process.env.VAPID_PRIVATE_KEY!,
		);

		const payload = JSON.stringify({
			title,
			body,
			url,
			badge,
		});

		await Promise.all(
			subs.map(async sub => {
				await webpush.sendNotification(sub, payload);
			}),
		);

		return { success: true };
	} catch (err) {
		console.error(err);
		return { success: false, message: 'Failed to send push notification' };
	}
}

In a production app, you would likely want to tie a device ID as well as a user ID to the subscription stored in the database. Then when you want to send a notification to all of the user's registered devices, the Promise.all() example will make more sense.

Creating the Service Worker

A service worker is required to handle receiving push notifications from the server, as well as handling the click response from the user. Having a service worker allows your PWA to receive push notification even when the user is not actively using it.

self.addEventListener('push', function (event) {
	if (event.data) {
		const data = event.data.json();
		const { title, body, primaryKey, badge, url } = data;
		const options = {
			body,
			icon: '/android-chrome-192x192.png',
			badge: badge || '/push-badge.png',
			vibrate: [100, 50, 100],
			data: {
				dateOfArrival: Date.now(),
				primaryKey,
				url,
			},
		};
		event.waitUntil(self.registration.showNotification(title, options));
	}
});

self.addEventListener('notificationclick', function (event) {
	const data = event.notification.data;
	const { url } = data;
	event.notification.close();

	if (url) {
		event.waitUntil(clients.openWindow(url));
	}
});

Here we have two event handlers, push and notificationclick. In the push handler we receive the payload from the server. The payload can contain any data you would like to send along with the notification, such as a custom ID, url or badge URL.

FYI: the difference between the icon and the badge is the icon is typically your logo icon, while the badge a small (32x32) monochrome icon that is typically used to indicate the purpose of the notification. A badge might be an icon of a lock for a security notification, for example.

When a user clicks or taps on a notification, the notificationclick handler is triggered. The data from our payload becomes available via the event data. In this example we open the provided URL in a new window (or tab).

Resources

  1. GitHub Repo
  2. Demo Site
  3. web-push
  4. VAPID spec

Thank You!

Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify!

Current Projects

  • Snoozle.io- An AI app that generates bedtime stories for kids ❤️
  • react-poptart - A React Notification / Alerts Library (under 20kB)
  • Spectravert - A cross-platform video converter (ffmpeg GUI)
  • Smartname.app - An AI name generator for a variety of purposes

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.


Loading comments...