How to add push notifications in Vue

Web Push Notifications enable real-time engagement with users even when your Vue app isn’t open in the browser. As the creator of CoreUI with 12 years of Vue development experience, I’ve implemented push notification systems for Vue PWAs that re-engage millions of users with timely updates.

The most effective approach combines the Push API with service workers and a backend subscription management system.

Setup Service Worker

First, ensure you have PWA support (see how to add PWA support in Vue).

Configure service worker in vite.config.js:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    vue(),
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}']
      },
      manifest: {
        name: 'My Vue App',
        short_name: 'VueApp',
        theme_color: '#4DBA87',
        icons: [
          {
            src: 'icon-192.png',
            sizes: '192x192',
            type: 'image/png'
          },
          {
            src: 'icon-512.png',
            sizes: '512x512',
            type: 'image/png'
          }
        ]
      }
    })
  ]
})

Create Push Service

Create src/services/pushNotifications.js:

class PushNotificationService {
  constructor() {
    this.registration = null
    this.subscription = null
  }

  async initialize() {
    if (!('serviceWorker' in navigator)) {
      throw new Error('Service Workers not supported')
    }

    if (!('PushManager' in window)) {
      throw new Error('Push API not supported')
    }

    this.registration = await navigator.serviceWorker.ready
  }

  async requestPermission() {
    const permission = await Notification.requestPermission()

    if (permission !== 'granted') {
      throw new Error('Notification permission denied')
    }

    return permission
  }

  async subscribe(vapidPublicKey) {
    await this.initialize()
    await this.requestPermission()

    this.subscription = await this.registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: this.urlBase64ToUint8Array(vapidPublicKey)
    })

    return this.subscription
  }

  async unsubscribe() {
    if (!this.subscription) {
      const existing = await this.getSubscription()
      if (existing) {
        await existing.unsubscribe()
      }
      return
    }

    await this.subscription.unsubscribe()
    this.subscription = null
  }

  async getSubscription() {
    await this.initialize()
    return await this.registration.pushManager.getSubscription()
  }

  async sendSubscriptionToServer(subscription) {
    const response = await fetch('/api/push/subscribe', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(subscription)
    })

    if (!response.ok) {
      throw new Error('Failed to save subscription')
    }

    return await response.json()
  }

  urlBase64ToUint8Array(base64String) {
    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 new PushNotificationService()

Create Push Composable

Create src/composables/usePushNotifications.js:

import { ref, onMounted } from 'vue'
import pushService from '@/services/pushNotifications'

export const usePushNotifications = () => {
  const isSupported = ref(false)
  const isSubscribed = ref(false)
  const permission = ref(Notification.permission)
  const subscription = ref(null)
  const error = ref(null)

  const checkSupport = () => {
    isSupported.value =
      'serviceWorker' in navigator &&
      'PushManager' in window &&
      'Notification' in window
  }

  const checkSubscription = async () => {
    try {
      const sub = await pushService.getSubscription()
      isSubscribed.value = !!sub
      subscription.value = sub
    } catch (err) {
      error.value = err.message
    }
  }

  const subscribe = async (vapidPublicKey) => {
    error.value = null

    try {
      const sub = await pushService.subscribe(vapidPublicKey)
      await pushService.sendSubscriptionToServer(sub)

      subscription.value = sub
      isSubscribed.value = true
      permission.value = 'granted'

      return sub
    } catch (err) {
      error.value = err.message
      throw err
    }
  }

  const unsubscribe = async () => {
    error.value = null

    try {
      await pushService.unsubscribe()

      await fetch('/api/push/unsubscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ endpoint: subscription.value?.endpoint })
      })

      subscription.value = null
      isSubscribed.value = false
    } catch (err) {
      error.value = err.message
      throw err
    }
  }

  onMounted(() => {
    checkSupport()
    if (isSupported.value) {
      checkSubscription()
    }
  })

  return {
    isSupported,
    isSubscribed,
    permission,
    subscription,
    error,
    subscribe,
    unsubscribe
  }
}

Use in Component

<script setup>
import { usePushNotifications } from '@/composables/usePushNotifications'

const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY

const {
  isSupported,
  isSubscribed,
  permission,
  error,
  subscribe,
  unsubscribe
} = usePushNotifications()

const handleSubscribe = async () => {
  try {
    await subscribe(VAPID_PUBLIC_KEY)
    alert('Push notifications enabled!')
  } catch (err) {
    console.error('Subscription failed:', err)
  }
}

const handleUnsubscribe = async () => {
  try {
    await unsubscribe()
    alert('Push notifications disabled')
  } catch (err) {
    console.error('Unsubscribe failed:', err)
  }
}
</script>

<template>
  <div>
    <div v-if="!isSupported" class="warning">
      Push notifications are not supported in your browser
    </div>

    <div v-else>
      <div v-if="error" class="error">{{ error }}</div>

      <div v-if="permission === 'denied'" class="warning">
        Push notifications are blocked. Please enable them in browser settings.
      </div>

      <button
        v-if="!isSubscribed"
        @click="handleSubscribe"
        :disabled="permission === 'denied'"
      >
        Enable Push Notifications
      </button>

      <button v-else @click="handleUnsubscribe">
        Disable Push Notifications
      </button>

      <div v-if="isSubscribed" class="status">
         Push notifications enabled
      </div>
    </div>
  </div>
</template>

<style scoped>
.warning {
  background: #ff9800;
  color: white;
  padding: 12px;
  border-radius: 4px;
  margin-bottom: 16px;
}

.error {
  background: #f44336;
  color: white;
  padding: 12px;
  border-radius: 4px;
  margin-bottom: 16px;
}

.status {
  color: #4caf50;
  margin-top: 16px;
}

button {
  background: #4dba87;
  color: white;
  border: none;
  padding: 12px 24px;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

Service Worker Push Handler

Add to public/sw.js or service worker file:

self.addEventListener('push', (event) => {
  const options = {
    body: event.data ? event.data.text() : 'New notification',
    icon: '/icon-192.png',
    badge: '/badge-72.png',
    vibrate: [200, 100, 200],
    data: {
      dateOfArrival: Date.now(),
      primaryKey: 1
    },
    actions: [
      {
        action: 'explore',
        title: 'Open App'
      },
      {
        action: 'close',
        title: 'Close'
      }
    ]
  }

  event.waitUntil(
    self.registration.showNotification('My Vue App', options)
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()

  if (event.action === 'explore') {
    event.waitUntil(
      clients.openWindow('/')
    )
  }
})

Backend Push Sending (Node.js)

const webpush = require('web-push')

// Set VAPID keys
webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
)

// Store subscriptions in database
const subscriptions = new Map()

app.post('/api/push/subscribe', (req, res) => {
  const subscription = req.body
  subscriptions.set(subscription.endpoint, subscription)
  res.json({ success: true })
})

app.post('/api/push/unsubscribe', (req, res) => {
  const { endpoint } = req.body
  subscriptions.delete(endpoint)
  res.json({ success: true })
})

// Send notification to all subscribers
app.post('/api/push/send', async (req, res) => {
  const { title, body, url } = req.body

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

  const results = []

  for (const subscription of subscriptions.values()) {
    try {
      await webpush.sendNotification(subscription, payload)
      results.push({ success: true, endpoint: subscription.endpoint })
    } catch (error) {
      if (error.statusCode === 410) {
        subscriptions.delete(subscription.endpoint)
      }
      results.push({
        success: false,
        endpoint: subscription.endpoint,
        error: error.message
      })
    }
  }

  res.json({ results })
})

Custom Notification Payload

// Service worker
self.addEventListener('push', (event) => {
  const data = event.data.json()

  const options = {
    body: data.body,
    icon: data.icon || '/icon-192.png',
    badge: '/badge-72.png',
    image: data.image,
    vibrate: data.vibrate || [200, 100, 200],
    tag: data.tag,
    requireInteraction: data.requireInteraction || false,
    data: {
      url: data.url,
      ...data.customData
    },
    actions: data.actions || []
  }

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()

  const urlToOpen = event.notification.data.url || '/'

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true })
      .then((clientList) => {
        // Focus existing tab if available
        for (const client of clientList) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus()
          }
        }
        // Open new tab
        if (clients.openWindow) {
          return clients.openWindow(urlToOpen)
        }
      })
  )
})

Best Practice Note

This is the same push notification architecture we use in CoreUI’s Vue PWA templates. Web Push enables re-engagement even when the app is closed, but always respect user privacy by making subscriptions opt-in and providing easy unsubscribe options. Store VAPID keys securely and handle subscription cleanup for expired endpoints.

For production applications, consider using CoreUI’s Vue Admin Template which includes pre-configured PWA features with push notification support.

For complete PWA functionality, you might also want to learn how to add offline support in Vue and how to use Vue with Service Workers.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author