Nextjs + Notificaciones Push

Las Push Notifications son una herramienta poderosa que permite a las aplicaciones web notificar a sus usuarios cuando suceda algún evento espacial.

Estas notificaciones aparecen en el dispositivo, lo cual es sumamente util para mantener una comunicación cercana con el usuario y puede mejorar la retención y seguimiento.

Componentes necesarios para a utilizar

  1. service-worker:
    Un service worker es un script que corre de manera independiente en tu web.
  2. Browser APIs:
    Es la herramienta que nos va a ayudar a integrar las funciones de Notification API y Permission API para habilitar las notificaciones push.
  3. VAPID Keys (Voluntary Application Server Identification):
    Consiste en una llave pública y otra privada que son usadas para firmar y verificar la identidad del remitente.

Suscripción de service worker

La suscripción de un service worker consiste en registrar el navegador del usuario para recibir notificaciones push. Este proceso genera un endpoint único para cada usuario, permitiendo que el servidor envía los mensajes a un destino concreto.

¿Cómo funciona?

  1. Se registra el service worker utilizando navigator.serviceWorker.register()
  2. Proceso de suscripción
    • Se solicita el permiso del usuario con Notification.requestPermission().
    • Si es permitido se realiza las suscripción push con registration.pushManager.subscribe()
    • Lo anterior devuelve un Objeto PushSubscription que contiene el endpoint único del navegador y las llaver de encriptación.
  3. Guardar Objeto PushSubscription, está información es la que vas a usar para enviar los mensaje push a tus usuarios

Lo que necesitas

  • Framework Nextjs npm create next-app@latest push-notifications --yes
  • Instala web push npm i -web-push
  • Instala los typos web-dev npm i --save-dev @types/web-push
  • VAPID Keys

Genera VAPID Keys

Para generar la llave pública y privada que usará tu web app para push messages puedes usar la siguientes alternativas

  • Opción 1: Instala el paquete web-push globalmente npm install -g web-push y luego web-push generate-vapid-keys
  • Opición 2: Ve a https://vapidkeys.com/ ingresa tu email y te llegará un mail con las llaves

Luego copia y pega las llave en tus variables de entorno (.env)

NEXT_PUBLIC_VAPID_PUBLIC_KEY=llave_publica
VAPID_PRIVATE_KEY=llave_privada

Estructura del proyecto

Te comparto el repositorio en git para que puedas analizarlo y adaptarlo a tu solución aquí

Paso a paso

1. Agrega soporte para PWA. Crea el archivo manifest.ts en la carpeta app.

import type { MetadataRoute } from 'next'
 
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'Notificaciones Push',
    short_name: 'push-notifications',
    description: 'Notificaciones Push by Jorge Castrillo',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#000000',
    icons: [
      {
        src: '/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
      },
    ],
  }
}

Cambia el nombre, la descripción y el nombre corto a los de tu app. Tambien te comparto una herramienta muy útil para generar todos los tamaños para tus favicon https://realfavicongenerator.net/

2. Crear los Server Actions

Crea el archivo actions.ts en la carpeta app. Para este ejemplo particular usaremos server actions de nextjs. Tendremos unicamente 3 funciones.

  • subscribeUser: Acá es donde debes almacenar el objeto de suscripción que contiene el endpoint qye utilizar web-push y las key para enviar los mensajes push
  • unsubscribeUser: Eliminar la suscripción de tu sistema de almacenamiento
  • sendNotification: Función para enviar las notificaciones. En este ejemplo esta función recibe 2 parámetros (mensaje y suscripción). Cuando apliques a tu proyecto, lo ideal es que la suscripción la obtengas desde tu sistema de almacenamiento, de momento la optenemos del navegador.

Importamos webpush, lo inicializamos con las variables de entorno y lo usamos para enviar notificaciones desde la función sendNotification

'use server'
 
import webpush from 'web-push'
import type { PushSubscription } from 'web-push'

type SerializedSubscription = {
  endpoint: string
  keys: {
    p256dh: string
    auth: string
  } | null
}

type PushMessage = {
    title: string
    body: string
    url: string
}
 
webpush.setVapidDetails(
  'mailto:castrillodev@gmail.com',
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
)
 
let subscription: SerializedSubscription | null = null
 
export async function subscribeUser(sub: SerializedSubscription) {
  subscription = sub
  // En un entorno de producción, se almacenaría la suscripción en una base de datos
  // Por ejemplo: await db.subscriptions.create({ data: sub })
  return { success: true }
}
 
export async function unsubscribeUser() {
  subscription = null
  // En un entorno de producción, se eliminaría la suscripción de la base de datos
  // Por ejemplo: await db.subscriptions.delete({ where: { ... } })
  return { success: true }
}
 
export async function sendNotification(message: PushMessage, sub: SerializedSubscription) {
  if (!sub || !sub.keys) {
    throw new Error('No hay suscripción disponible')
  }
 
  // Convertir el objeto serializado al formato que espera web-push
  const pushSubscription: PushSubscription = {
    endpoint: sub.endpoint,
    keys: {
      p256dh: sub.keys.p256dh,
      auth: sub.keys.auth,
    },
  }

  try {
    await webpush.sendNotification(
      pushSubscription,
      JSON.stringify({
        title: message.title,
        body: message.body,
        icon: '/icon.png',
        url: message.url,
      })
    )
    return { success: true }
  } catch (error) {
    console.error('Error al enviar la notificación:', error)
    return { success: false, error: 'Error al enviar la notificación' }
  }
}

3. Crear el service worker

Creas el archivo sw.js en la carpeta public. Habilitamos 2 listeners, uno para el evento push y el otro para notificationClick. En Cada evento podemos obtener los datos de la notificación.

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

self.addEventListener('notificationclick', function (event) {
    console.log('Notificación clicada.')
    event.notification.close()

    // Los datos están en event.notification.data, no en event.data
    const data = event.notification.data
    const url = data?.url || 'https://jorgecastrillo.blog'
    event.waitUntil(clients.openWindow(url))
})

5. Componente de Notificaciones

Una vez que tenemos los actions y el service worker ya podemos crear nuestro componente de pruebas para notificaciones push. Crea el archivo push.tsx en app

El componente tiene las siguiente funciones

  1. Revisa si el navegador soporta notificaciones push.
  2. Obtenemos la suscripción push del navegador
  3. Luego tenemos tres acciones
    • subscribeToPush acá cargamos el service worker del navegador, luego registramos la suscripción push usando la llave pública y luego asignamos el valor al estado subscription
    • unsubscribeFromPush Elimina la suscripción con await subscription?.unsubscribe()
    • sendTestNotification Envía el mensaje
  4. Imprimimos la interfaz y listo
'use client'

import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'

const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!

function urlBase64ToUint8Array(base64String: string) {
    const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
    const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')

    const rawData = window.atob(base64)
    const outputArray = new Uint8Array(rawData.length)

    for (let i = 0; i < rawData.length; ++i) {
        outputArray[i] = rawData.charCodeAt(i)
    }
    return outputArray
}

export default function PushNotificationManager() {
    const [isSupported, setIsSupported] = useState(false)
    const [subscription, setSubscription] = useState<PushSubscription | null>(
        null
    )
    const [title, setTitle] = useState('')
    const [message, setMessage] = useState('')
    const [url, setUrl] = useState('')

    useEffect(() => {
        if ('serviceWorker' in navigator && 'PushManager' in window) {
            setIsSupported(true)
            registerServiceWorker()
        }
    }, [])

    async function registerServiceWorker() {
        const registration = await navigator.serviceWorker.register('/sw.js', {
            scope: '/',
            updateViaCache: 'none',
        })
        const sub = await registration.pushManager.getSubscription()
        setSubscription(sub)
    }

    async function subscribeToPush() {
        if (!VAPID_PUBLIC_KEY) {
            alert('Error: La clave VAPID pública no está configurada. Por favor, configura NEXT_PUBLIC_VAPID_PUBLIC_KEY en tu archivo .env.local')
            return
        }
        const registration = await navigator.serviceWorker.ready
        const sub = await registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
        })
        setSubscription(sub)
        const serializedSub = JSON.parse(JSON.stringify(sub))
        await subscribeUser(serializedSub)
    }

    async function unsubscribeFromPush() {
        await subscription?.unsubscribe()
        setSubscription(null)
        await unsubscribeUser()
    }

    async function sendTestNotification() {
        if (subscription) {
            console.log('Enviando notificación de prueba:', message)
            const serializedSub = JSON.parse(JSON.stringify(subscription))
            await sendNotification({ title, body: message, url }, serializedSub)
            setMessage('')
        }
    }

    if (!isSupported) {
        return <p>Push notifications are not supported in this browser.</p>
    }

    return (
        <div className="flex flex-col gap-4 items-start justify-start w-full">
            {subscription ? (
                <>
                    <p>Estas suscrito a notificaciones push.</p>
                    <button
                        onClick={unsubscribeFromPush}
                        className='bg-red-500 text-white py-2 px-4 rounded-md cursor-pointer'
                    >
                        Cancelar suscripción
                    </button>
                    <div className='border-t border-gray-300 w-full'></div>
                    <h2 className='text-lg font-bold'>Enviar notificación de prueba</h2>
                    <input
                        type="text"
                        placeholder="Ingrese el titulo de la notificación"
                        className='w-full max-w-xs p-2 rounded-md border border-gray-300 bg-white placeholder:text-gray-500 text-black'
                        value={title}
                        onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
                    />
                    <textarea
                        placeholder="Ingrese el mensaje de la notificación"
                        className='w-full p-2 rounded-md border border-gray-300 bg-white placeholder:text-gray-500 text-black'
                        value={message}
                        onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setMessage(e.target.value)}
                    >{message}</textarea>
                    <input
                        type="text"
                        placeholder="Ingrese la url de la notificación"
                        className='w-full p-2 rounded-md border border-gray-300 bg-white placeholder:text-gray-500 text-black'
                        value={url}
                        onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
                    />
                    <button
                        onClick={sendTestNotification}
                        className='bg-blue-500 text-white py-2 px-4 rounded-md cursor-pointer'
                    >
                        Enviar prueba
                    </button>
                </>
            ) : (
                <>
                    <p>No estás suscrito a notificaciones push.</p>
                    <button
                        onClick={subscribeToPush}
                        className='bg-blue-500 text-white py-2 px-4 rounded-md cursor-pointer'
                    >
                        Suscribirme
                    </button>
                </>
            )}
        </div>
    )
}


Conclusión

Con esta guia tienes un punto de partida completo para poder implementar notificaciones push en tu proyecto de nextjs. Si prestas atención y entiendes bien el flujo te darás cuenta que esta misma lógica la podrías implementar en otros frameworks de programación.

Espero les sea de mucha utilidad, al menos para mi, lo es y lo estaré consultando cada que lo necesite.

Gracias Jorge del pasado.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *