Blog · Guides

Push notifications in Vue: subscription, permissions, service worker

A service worker in public, a composable that separates permission from subscription and a button that subscribes on click: web push in a Vue 3 + Vite app, no plugins.

Leer en español

Push notifications in a Vue 3 + Vite app take three pieces: a service worker in public/, a composable that loads the SDK, exposes permission and subscription state and subscribes from a click, and a backend that sends with the secret key. Vite leaves URL imports alone, so you don't need any plugin.

In short:

  • public/castly-sw.js holds a single line, and Vite copies it to the root of dist/.
  • Vite doesn't bundle import('https://castly.sh/sdk/v1/castly.mjs'), in dev or in the build; TypeScript only needs a module declaration.
  • A usePush composable with shared state tells five cases apart: unsupported, needs install, blocked, off and on.
  • Permission granted does not mean subscribed: read the real state with pushManager.getSubscription().
  • The public key goes in a VITE_ variable; the secret key lives only on your backend.

Step 1: the service worker in public

Vite serves the public/ folder at the root during development and copies it as-is into dist/ on build. Create public/castly-sw.js:

importScripts('https://castly.sh/sdk/v1/sw.js');

That file receives push messages, shows the notification and opens the URL on click. Because it sits at the root, its scope covers every route of your app.

Two special cases. If you deploy under a sub-path with Vite's base option (say /app/), the file is served at /app/castly-sw.js: pass that as swPath when initializing, and the service worker's scope becomes that folder. And if you already use vite-plugin-pwa in generateSW mode, don't add a second service worker: list the SDK URL in the workbox.importScripts option and pass swPath: '/sw.js'.

Step 2: load the SDK from Vite

The SDK is not on npm: it is an ES module downloaded from its URL. Vite treats https:// URLs as external, so after a build you'll find the import() untouched and the browser resolves it at runtime.

Use a dynamic import rather than a static one. With import { Castly } from 'https://…' at the top of a module, if that URL fails to load (an ad blocker, a corporate network), the whole module graph that depends on it fails and your app may render a blank page. With import(), the failure stays contained in the notifications button.

For TypeScript, declare the module with the methods you use in src/castly.d.ts, and put the public key in .env as VITE_CASTLY_PUBLIC_KEY=pk_live_…:

declare module 'https://castly.sh/sdk/v1/castly.mjs' {
  export interface CastlyClient {
    init(options: { publicKey: string; autoPrompt?: boolean; swPath?: string }): unknown
    isSupported(): boolean
    needsInstall(): boolean
    permission(): NotificationPermission | 'unsupported'
    subscribe(options?: { userId?: string; tags?: Record<string, string> }): Promise<{ id: string }>
    unsubscribe(): Promise<void>
  }
  export const Castly: CastlyClient
}

Remember that anything starting with VITE_ ends up in public JavaScript. That is fine for the pk_live_… key, which can only register subscribers, and a serious mistake for the secret one.

If your app sends a Content-Security-Policy header, add https://castly.sh to script-src and connect-src; otherwise the import fails and the composable reports the browser as unsupported, or subscribing fails with a network error.

Step 3: the usePush composable

State lives at module level, outside the function, so every component that uses the composable (the button in your menu and the one on the settings page, for instance) sees the same thing and the SDK loads only once:

// src/composables/usePush.ts
import { readonly, ref } from 'vue'
import type { CastlyClient } from 'https://castly.sh/sdk/v1/castly.mjs'

type PushState = 'loading' | 'unsupported' | 'needs-install' | 'blocked' | 'off' | 'on'

const state = ref<PushState>('loading')
const error = ref<string | null>(null)
let castly: CastlyClient | undefined
let loading: Promise<void> | undefined

async function refresh() {
  if (!castly) return
  if (!castly.isSupported()) state.value = 'unsupported'
  else if (castly.needsInstall()) state.value = 'needs-install'
  else if (castly.permission() === 'denied') state.value = 'blocked'
  else {
    const reg = await navigator.serviceWorker.getRegistration(import.meta.env.BASE_URL)
    state.value = (await reg?.pushManager.getSubscription()) ? 'on' : 'off'
  }
}

function load() {
  loading ??= import('https://castly.sh/sdk/v1/castly.mjs')
    .then(({ Castly }) => {
      Castly.init({ publicKey: import.meta.env.VITE_CASTLY_PUBLIC_KEY, autoPrompt: false })
      castly = Castly
      return refresh()
    })
    .catch(() => {
      state.value = 'unsupported'
    })
  return loading
}

export function usePush() {
  load()

  async function enable(userId?: string) {
    if (!castly) return
    error.value = null
    try {
      await castly.subscribe({ userId }) // first thing in the click: the gesture still counts
    } catch (e) {
      error.value = e instanceof Error ? e.message : String(e)
    }
    await refresh()
  }

  async function disable() {
    await castly?.unsubscribe()
    await refresh()
  }

  return { state: readonly(state), error: readonly(error), enable, disable }
}

Three decisions in that code deserve a word:

  • The order of checks. On an iPhone where the site isn't installed, isSupported() returns true (it will work once installed) and so does needsInstall(); that's why the second check comes after the first and yields a different message.
  • Permission versus subscription. Someone may have granted permission and unsubscribed later, or cleared site data. permission() is not enough: the on state comes from pushManager.getSubscription(), the standard API that tells you whether this browser holds an active subscription.
  • No waiting before subscribing. enable awaits nothing before subscribe. Safari and Firefox only show the permission prompt while the request is still tied to the click.

The composable returns state and error as readonly: components can read them, but only enable and disable change them, so the UI never drifts from what the browser reports. Note what disable does: it removes this browser's subscription, but the permission stays granted. The button goes back to off, and turning it on again subscribes straight away without showing the prompt.

Step 4: the component

With the composable in place, the component just renders each state:

<!-- src/components/PushToggle.vue -->
<script setup lang="ts">
import { usePush } from '@/composables/usePush'

const props = defineProps<{ userId: string }>()
const { state, error, enable, disable } = usePush()
</script>

<template>
  <div>
    <button v-if="state === 'off'" @click="enable(props.userId)">Turn on notifications</button>
    <button v-else-if="state === 'on'" @click="disable">Turn off notifications</button>
    <p v-else-if="state === 'needs-install'">
      On iPhone, add this site to your Home Screen and open it from there.
    </p>
    <p v-else-if="state === 'blocked'">Notifications are blocked: re-enable them in site settings.</p>
    <p v-if="error" role="alert">{{ error }}</p>
  </div>
</template>

Passing userId links the device to your user: later you can send to them by ID, on all their devices, without storing endpoints yourself. If you also pass tags, as in enable calling subscribe({ userId, tags: { plan: 'pro' } }), your backend can target a segment with { "type": "segment", "tags": { "plan": "pro" } }. And if your app already uses Pinia, the same logic fits in a store; module-level state is enough when only a couple of components need it.

Where you place the component matters as much as the code. Next to the action that gives the notification meaning ("Notify me when they reply"), it converts better than a prompt on page load. If you prefer the opt-in prompt you configure in the castly dashboard, drop autoPrompt: false and the SDK shows it according to the rules you set. The quickstart covers that setup in plain HTML.

Step 5: send from your backend

Vue runs in the browser, so sends come from your server, whether that's Express, Nitro, Laravel or a serverless function. On Node 18 or later a fetch is enough:

// On your backend, never in Vue code
await fetch('https://castly.sh/v1/notifications', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CASTLY_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `comment:${comment.id}`,
  },
  body: JSON.stringify({
    to: { type: 'users', userIds: [post.authorId] },
    title: 'New comment on your post',
    body: comment.text.slice(0, 120),
    url: `https://yourapp.com/posts/${post.id}`,
  }),
})

One SPA-specific detail: if you use Vue Router with createWebHistory, clicking the notification opens /posts/42 directly. Vite's dev server handles that route, but in production your host must return index.html for every app route; otherwise the click lands on a 404. If you also maintain a React codebase, the Next.js guide follows the same steps with a server action.

Frequently asked questions

Do I need a Vite plugin to use the SDK?

No. Vite keeps https:// URL imports external, both in dev and in the build, and the browser downloads the module directly. You only need a type declaration if you use TypeScript.

Why doesn't the permission prompt appear when I click the button?

Usually there is an await before the call to subscribe, or the SDK hasn't loaded yet. It can also be that the user already blocked the site: the browser won't ask again and permission() returns denied.

Does the same approach work with Nuxt?

The idea is the same, but the composable must only run on the client, because navigator and service workers don't exist on the server. In Nuxt, load the SDK inside onMounted or in a client-only plugin.

Can I use my own service worker instead of castly-sw.js?

Yes. Add importScripts('https://castly.sh/sdk/v1/sw.js') at the top of your service worker and pass its path as swPath when initializing the SDK.

How do I test notifications locally?

http://localhost counts as a secure context, so npm run dev is enough to subscribe and receive pushes. To test on a phone you need HTTPS, for example through a tunnel or a preview deployment.

Put it into practice with castlyWeb and PWA push notifications for developers: an SDK under 3 KB, a REST API and delivery logs for every device. Free up to 10,000 pushes a month.
Create a free account →

Send your first push notification today.

Create a project, add the SDK and send with one API call. Free to start.

Create a free account →