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.
Related Articles
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.



