Skip to content

Client Integration

Sails Flare handles the server side — but you also need client-side code to request permission, subscribe with the browser's push service, and send the subscription to your server.

This page shows a Vue 3 composable that handles all of that. Adapt the pattern for React, Svelte, or vanilla JS as needed.

Vue composable

js
// assets/js/composables/push-notifications.js
import { ref, readonly } from 'vue'

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
  const raw = atob(base64)
  const array = new Uint8Array(raw.length)
  for (let i = 0; i < raw.length; i++) {
    array[i] = raw.charCodeAt(i)
  }
  return array
}

const isSupported = ref(
  'serviceWorker' in navigator &&
    'PushManager' in window &&
    'Notification' in window
)
const permission = ref(isSupported.value ? Notification.permission : 'denied')
const isSubscribed = ref(false)
const isLoading = ref(false)

if (isSupported.value) {
  navigator.serviceWorker.ready.then(async (registration) => {
    const sub = await registration.pushManager.getSubscription()
    isSubscribed.value = !!sub
  })
}

export function usePushNotifications() {
  function getVapidKey() {
    const meta = document.querySelector('meta[name="vapid-public-key"]')
    return meta?.content || ''
  }

  async function requestPermission() {
    if (!isSupported.value) return
    isLoading.value = true
    try {
      const result = await Notification.requestPermission()
      permission.value = result
      if (result !== 'granted') return

      const vapidKey = getVapidKey()
      if (!vapidKey) return

      const registration = await navigator.serviceWorker.ready
      let subscription = await registration.pushManager.getSubscription()
      if (!subscription) {
        subscription = await registration.pushManager.subscribe({
          userVisibleOnly: true,
          applicationServerKey: urlBase64ToUint8Array(vapidKey)
        })
      }

      // Send to your server (adapt the HTTP method to your stack)
      await fetch('/api/push/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ subscription: subscription.toJSON() })
      })

      isSubscribed.value = true
    } finally {
      isLoading.value = false
    }
  }

  async function unsubscribe() {
    if (!isSupported.value) return
    isLoading.value = true
    try {
      const registration = await navigator.serviceWorker.ready
      const subscription = await registration.pushManager.getSubscription()
      if (subscription) {
        const endpoint = subscription.endpoint
        await subscription.unsubscribe()
        await fetch('/api/push/subscribe', {
          method: 'DELETE',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ endpoint })
        })
      }
      isSubscribed.value = false
    } finally {
      isLoading.value = false
    }
  }

  return {
    isSupported: readonly(isSupported),
    permission: readonly(permission),
    isSubscribed: readonly(isSubscribed),
    isLoading: readonly(isLoading),
    requestPermission,
    unsubscribe
  }
}

Usage in a component

vue
<script setup>
import { usePushNotifications } from '@/composables/push-notifications'

const {
  isSupported,
  permission,
  isSubscribed,
  isLoading,
  requestPermission,
  unsubscribe
} = usePushNotifications()
</script>

<template>
  <button
    v-if="isSupported && permission !== 'denied'"
    @click="isSubscribed ? unsubscribe() : requestPermission()"
    :disabled="isLoading"
  >
    {{ isSubscribed ? 'Disable notifications' : 'Enable notifications' }}
  </button>
</template>

Key behaviors

Never auto-prompt

The composable never calls Notification.requestPermission() on its own. The UI must call requestPermission() from a deliberate user gesture (button click). This respects browser UX best practices and avoids permission fatigue.

Feature detection

isSupported checks for serviceWorker, PushManager, and Notification — the three browser APIs required for web push. If any is missing (e.g. older browsers, some in-app webviews), the toggle is hidden entirely.

Permission states

StateMeaningUI behavior
'default'User hasn't been asked yetShow "Enable notifications" button
'granted'User allowed notificationsShow active state or "Disable" option
'denied'User blocked notificationsHide the button entirely (can't re-prompt)

Singleton state

The composable uses module-level refs so state is shared across all components that import it. Calling requestPermission() in a header component updates isSubscribed everywhere.

VAPID key delivery

The composable reads the VAPID public key from a <meta> tag:

html
<meta name="vapid-public-key" content="BHHptnie8S3lypy..." />

This is rendered server-side from your config/notifications.js. See the Web Push Adapter page for the EJS template.

All open source projects are released under the MIT License.