@bhaskardey772/fcm-frontend
v1.3.0
Published
Frontend helper for Firebase Cloud Messaging — request permission, get FCM tokens, and handle push notifications without boilerplate
Maintainers
Readme
@bhaskardey772/fcm-frontend
Frontend helper for Firebase Cloud Messaging (FCM). Handle browser notification permission, get FCM device tokens, and listen for push messages — without writing any Firebase boilerplate.
Framework-agnostic (works with React, Vue, Svelte, vanilla JS/TS, or any bundler). Ships with TypeScript declarations.
Table of Contents
- Which version do I install?
- Installation
- Setup — Credentials
- Apple devices — iOS, iPadOS and Safari
- Usage
- Framework Examples
- API Reference
- How Notifications Work
- TypeScript
- Troubleshooting
Which version do I install?
Answer one question: does your project already import { initializeApp } from 'firebase/app' anywhere?
| | Default | Slim |
|---|---|---|
| My project | Fresh project / no Firebase yet | Already uses firebase (Firestore, Auth, etc.) |
| Install | npm install @bhaskardey772/fcm-frontend | npm install @bhaskardey772/fcm-frontend firebase |
| Import path | @bhaskardey772/fcm-frontend | @bhaskardey772/fcm-frontend/slim |
| firebase in bundle | Yes (bundled in — no extra install) | No (uses your existing copy) |
| Why slim? | — | Avoids doubling your bundle size by shipping two copies of firebase |
If you have no idea — use Default. You can always switch later.
Installation
Default (bundled)
firebase is bundled inside — nothing else to install.
npm install @bhaskardey772/fcm-frontendimport * as notif from '@bhaskardey772/fcm-frontend';Slim (already have firebase)
If your project already uses firebase (e.g. for Firestore, Auth, Realtime Database), use the /slim entry to avoid bundling a second copy of firebase into your app.
npm install @bhaskardey772/fcm-frontend firebaseimport * as notif from '@bhaskardey772/fcm-frontend/slim';The /slim entry uses your project's existing firebase — no duplication, no extra bundle weight.
Setup — Credentials
You need two things from the Firebase Console: a firebaseConfig object and a vapidKey string.
Where to find firebaseConfig
- Open console.firebase.google.com and select your project.
- Click the gear icon (⚙) next to "Project Overview" → Project settings.
- Scroll down to the Your apps section.
- If you haven't registered a web app yet, click the
</>(Web) icon to add one. Give it a nickname and click Register app. - Firebase shows you a code snippet. Copy the
firebaseConfigobject from it:
const firebaseConfig = {
apiKey: "AIzaSy...",
authDomain: "your-project.firebaseapp.com",
projectId: "your-project-id",
storageBucket: "your-project.firebasestorage.app",
messagingSenderId: "123456789",
appId: "1:123456789:web:abc123",
};
firebaseConfigis not a secret. It is safe to include in your frontend code. Firebase security is enforced by Security Rules and server-side auth, not by hiding these values.
Where to find vapidKey
The VAPID key allows the browser to subscribe to push notifications.
- Go to Project settings (same gear icon as above).
- Click the Cloud Messaging tab.
- Scroll to the Web Push certificates section.
- If no key pair exists, click Generate key pair. Only do this once — regenerating replaces the old key and breaks existing subscriptions.
- Copy the long string under Key pair. This is your
vapidKey.
const vapidKey = "BNS7..."; // long base64 string (~88 characters)If you lose the key, do not regenerate it unless you want all existing browser subscriptions to stop working. Go back to the same tab and copy it again.
Add the service worker
Copy the service worker that ships with this package into your public/ folder:
cp node_modules/@bhaskardey772/fcm-frontend/firebase-messaging-sw.js public/The file must be served at the root path /firebase-messaging-sw.js. Vite and Create React App copy everything from public/ to the build root automatically.
No Firebase config needed inside the file. The service worker handles push events using the native Web Push API — it does not depend on the Firebase SDK. Your
firebaseConfigis only needed in your app code for token generation. You never need to editfirebase-messaging-sw.js.
Apple devices — iOS, iPadOS and Safari
Apple supports Web Push, but with rules no other platform has. Read this before testing on an iPhone.
iOS / iPadOS: the site must be installed to the Home Screen
iOS and iPadOS expose Web Push only to web apps launched from the Home Screen (iOS 16.4+). In an ordinary Safari tab there is no Notification API and no PushManager at all — nothing this package (or any other) can do will change that.
Requirements:
iOS/iPadOS 16.4 or later.
HTTPS —
localhostdoes not count on a phone; use a real certificate or a tunnel (ngrok, Cloudflare Tunnel).A web app manifest linked from your HTML, with
displayset tostandalone:<link rel="manifest" href="/manifest.webmanifest">{ "name": "Your App", "short_name": "Your App", "start_url": "/", "display": "standalone", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } ] }start_urlmust sit inside the service worker's scope (/by default).The user taps Share → Add to Home Screen, then opens the app from that icon.
The permission prompt must come from a tap inside the installed app — see below.
Until step 4 happens, init() resolves to { supported: false, reason: 'ios-requires-home-screen-install' }. Use that to show an install prompt instead of a broken subscribe button:
const { supported, reason } = await notif.init({ firebaseConfig, vapidKey });
if (!supported && reason === 'ios-requires-home-screen-install') {
showBanner('Tap Share → Add to Home Screen to enable notifications.');
}Safari: the permission prompt needs a live user gesture
Safari only opens the permission dialog while a tap or click is still "active". Any await before the call throws that away.
// WRONG — the await consumes the gesture, Safari silently refuses to prompt
button.addEventListener('click', async () => {
await someOtherThing();
const token = await notif.requestPermission();
});
// CORRECT — requestPermission() is the first thing the handler awaits
button.addEventListener('click', async () => {
const token = await notif.requestPermission();
await someOtherThing();
});Do not call requestPermission() from a useEffect, onMounted, or page-load handler. It will not work on Apple devices.
What Safari ignores
Safari renders a plainer notification than Chrome. These fields are accepted but dropped: actions (no action buttons on WebKit), vibrate, renotify, badge, image, and requireInteraction. Title, body, icon and click-through all work.
Every push shows a notification on Apple
WebKit requires the service worker to display a notification for every push it receives. If one is skipped, Safari posts its own "This website has been updated in the background" notice, and repeatedly skipping causes Safari to revoke the push subscription.
So the "suppress the popup while the app is focused" behaviour described below applies to Chrome, Edge and Firefox only. On Apple devices the shipped service worker still shows a notification while the app is focused — quietly, and collapsed under a single tag so it does not stack.
Usage
Initialize
Call init() once when your app starts. It registers the service worker and sets up Firebase Messaging for token generation and foreground message handling.
import * as notif from '@bhaskardey772/fcm-frontend';
// or: import * as notif from '@bhaskardey772/fcm-frontend/slim';
await notif.init({
firebaseConfig: {
apiKey: 'YOUR_API_KEY',
authDomain: 'YOUR_PROJECT.firebaseapp.com',
projectId: 'YOUR_PROJECT_ID',
storageBucket: 'YOUR_PROJECT.firebasestorage.app',
messagingSenderId: 'YOUR_SENDER_ID',
appId: 'YOUR_APP_ID',
},
vapidKey: 'YOUR_VAPID_KEY',
});init() resolves to a support status rather than throwing on browsers that cannot receive push:
const { supported, reason } = await notif.init({ firebaseConfig, vapidKey });
if (!supported) {
// reason: 'ios-requires-home-screen-install' | 'insecure-context' | 'no-window'
// | 'no-service-worker' | 'no-push-manager' | 'no-notification-api'
// | 'unsupported-browser'
console.log('Push unavailable:', reason);
}Only bad configuration (missing firebaseConfig / vapidKey) and a failed service worker registration throw.
Always await init() before calling onForegroundMessage() — otherwise the listener attaches before messaging exists and silently receives nothing.
All other functions work exactly the same regardless of which entry (default or slim) you use.
Request Permission & Get Token
Apple: call this directly inside a click/tap handler, as the first thing you
await. Safari refuses to prompt once the gesture has been consumed. See Apple devices.
const token = await notif.requestPermission();
if (token) {
// Send token to your backend to register this device
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
} else {
// User denied — do not re-prompt immediately
}- Shows the browser's native permission dialog if not yet answered
- Returns the FCM token string on
'granted', ornullon'denied'
Listen for Foreground Messages
The service worker suppresses the native popup when the app is already visible — the same behaviour as WhatsApp. Use onForegroundMessage if you want to react to an incoming push while the user is in the app (e.g. show an in-app toast, update a badge):
const unsubscribe = notif.onForegroundMessage(({ title, body, data }) => {
// e.g. show a custom in-app banner instead of a system notification
console.log('New message while app is open:', title, body, data);
});
// Stop listening when component unmounts
unsubscribe();Get Token Without Prompting
On app load, silently check if a token already exists from a previous session:
const token = await notif.getDeviceToken();
// Returns token string, or null if permission not yet grantedUnsubscribe / Delete Token
On logout or when the user opts out:
// Save token before deleting
const token = await notif.getDeviceToken();
// 1. Remove from FCM
await notif.deleteToken();
// 2. Remove from your backend
await fetch('/api/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});Check Permission State
const state = notif.getPermissionState();
// 'granted' | 'denied' | 'default' | 'unsupported'
if (state === 'denied') {
// Show guide to re-enable in browser settings
}
if (state === 'default') {
// Not yet decided — safe to call requestPermission()
}
if (state === 'unsupported') {
// No Notification API at all — e.g. iOS Safari in a tab. Check getSupportStatus().
}Safe to call before init() and during server-side rendering — it never throws.
Check Push Support
const { supported, reason } = notif.getSupportStatus();
if (!supported && reason === 'ios-requires-home-screen-install') {
showBanner('Tap Share → Add to Home Screen to enable notifications.');
}Synchronous, safe before init() and during SSR. After init() has run it returns that run's result, which also accounts for browsers Firebase Messaging itself rejects ('unsupported-browser').
Framework Examples
React
Create a hook (src/hooks/usePushNotifications.ts):
import { useEffect, useRef, useState } from 'react';
import * as notif from '@bhaskardey772/fcm-frontend';
// or: import * as notif from '@bhaskardey772/fcm-frontend/slim';
const FIREBASE_CONFIG = {
apiKey: 'YOUR_API_KEY',
authDomain: 'YOUR_PROJECT.firebaseapp.com',
projectId: 'YOUR_PROJECT_ID',
storageBucket: 'YOUR_PROJECT.firebasestorage.app',
messagingSenderId: 'YOUR_SENDER_ID',
appId: 'YOUR_APP_ID',
};
const VAPID_KEY = 'YOUR_VAPID_KEY';
export function usePushNotifications() {
const [token, setToken] = useState<string | null>(null);
const [permission, setPermission] = useState(notif.getPermissionState());
const [support, setSupport] = useState(notif.getSupportStatus());
const initialized = useRef(false);
useEffect(() => {
// useRef guard prevents double-init in React StrictMode
if (initialized.current) return;
initialized.current = true;
let unsubscribe = () => {};
// init() must finish before onForegroundMessage() — otherwise the listener
// attaches before messaging exists and silently receives nothing.
(async () => {
const status = await notif.init({ firebaseConfig: FIREBASE_CONFIG, vapidKey: VAPID_KEY });
setSupport(status);
if (!status.supported) return;
unsubscribe = notif.onForegroundMessage(({ title, body }) => {
// App is visible — on Chrome/Firefox the SW skips the popup. Handle in-app here.
console.log('Foreground message:', title, body);
});
})();
return () => unsubscribe();
}, []);
// Must run straight off a click — Safari will not prompt without a live user gesture.
async function subscribe() {
const fcmToken = await notif.requestPermission();
if (!fcmToken) { setPermission(notif.getPermissionState()); return null; }
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: fcmToken }),
});
setToken(fcmToken);
setPermission('granted');
return fcmToken;
}
async function unsubscribe() {
if (!token) return;
await notif.deleteToken();
await fetch('/api/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
setToken(null);
setPermission('default');
}
return { token, permission, support, subscribe, unsubscribe };
}Use in any component (src/App.tsx):
import { usePushNotifications } from './hooks/usePushNotifications';
export default function App() {
const { token, permission, support, subscribe, unsubscribe } = usePushNotifications();
if (!support.supported) {
return support.reason === 'ios-requires-home-screen-install'
? <p>Tap Share → Add to Home Screen to enable notifications.</p>
: <p>Notifications are not available in this browser.</p>;
}
return (
<div>
{permission === 'granted' ? (
<button onClick={unsubscribe}>Unsubscribe</button>
) : (
<button onClick={subscribe}>Enable Notifications</button>
)}
{token && <p>Token: {token.slice(0, 20)}…</p>}
</div>
);
}Vue 3
// composables/usePushNotifications.ts
import { onMounted, onUnmounted, ref } from 'vue';
import * as notif from '@bhaskardey772/fcm-frontend';
// or: import * as notif from '@bhaskardey772/fcm-frontend/slim';
const FIREBASE_CONFIG = { /* ... */ };
const VAPID_KEY = 'YOUR_VAPID_KEY';
export function usePushNotifications() {
const token = ref<string | null>(null);
const permission = ref(notif.getPermissionState());
const support = ref(notif.getSupportStatus());
let unsubscribe: (() => void) | undefined;
onMounted(async () => {
support.value = await notif.init({ firebaseConfig: FIREBASE_CONFIG, vapidKey: VAPID_KEY });
if (!support.value.supported) return;
unsubscribe = notif.onForegroundMessage(({ title, body }) => {
// App is visible — on Chrome/Firefox the SW skips the popup. Handle in-app here.
console.log('Foreground message:', title, body);
});
});
onUnmounted(() => unsubscribe?.());
// Must run straight off a click — Safari will not prompt without a live user gesture.
async function subscribe() {
const fcmToken = await notif.requestPermission();
if (!fcmToken) { permission.value = notif.getPermissionState(); return; }
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: fcmToken }),
});
token.value = fcmToken;
permission.value = 'granted';
}
async function unsubscribeDevice() {
if (!token.value) return;
await notif.deleteToken();
await fetch('/api/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.value }),
});
token.value = null;
permission.value = 'default';
}
return { token, permission, support, subscribe, unsubscribeDevice };
}Vanilla TypeScript
// main.ts
import * as notif from '@bhaskardey772/fcm-frontend';
// or: import * as notif from '@bhaskardey772/fcm-frontend/slim';
const FIREBASE_CONFIG = { /* ... */ };
const VAPID_KEY = 'YOUR_VAPID_KEY';
const support = await notif.init({ firebaseConfig: FIREBASE_CONFIG, vapidKey: VAPID_KEY });
if (support.supported) {
notif.onForegroundMessage(({ title, body }) => {
// App is visible — on Chrome/Firefox the SW skips the popup. Handle in-app here.
console.log('Foreground message:', title, body);
});
} else if (support.reason === 'ios-requires-home-screen-install') {
document.getElementById('installBanner')!.hidden = false;
}
document.getElementById('subscribeBtn')!.addEventListener('click', async () => {
// First await in the handler — keeps Safari's user gesture alive for the prompt.
const token = await notif.requestPermission();
if (token) {
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
}
});
document.getElementById('unsubscribeBtn')!.addEventListener('click', async () => {
const token = await notif.getDeviceToken();
await notif.deleteToken();
if (token) {
await fetch('/api/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
}
});API Reference
init(options): Promise<SupportStatus>
Registers the service worker and initialises Firebase Messaging for token generation and foreground message handling. Must be awaited before any other function.
Resolves to { supported: false, reason } on browsers that cannot receive push instead of throwing. Throws only on missing firebaseConfig / vapidKey or a failed service worker registration.
interface InitOptions {
firebaseConfig: {
apiKey: string;
authDomain: string;
projectId: string;
storageBucket: string;
messagingSenderId: string;
appId: string;
measurementId?: string;
};
vapidKey: string;
serviceWorkerPath?: string; // default: '/firebase-messaging-sw.js'
}
type SupportStatus =
| { supported: true; reason: 'ok' }
| { supported: false; reason: UnsupportedReason };
type UnsupportedReason =
| 'no-window' // SSR / non-browser environment
| 'insecure-context' // not HTTPS or localhost
| 'ios-requires-home-screen-install' // iOS/iPadOS Safari tab — install to Home Screen
| 'no-service-worker'
| 'no-push-manager'
| 'no-notification-api'
| 'unsupported-browser'; // Firebase Messaging rejected this browsergetSupportStatus(): SupportStatus
Whether this browser can receive push, and why not when it cannot. Synchronous, never throws, safe before init() and during SSR. After init() has run it returns that run's result.
requestPermission(): Promise<string | null>
Shows the browser permission dialog. Returns the FCM token, or null if denied or unsupported.
Must be called directly inside a click/tap handler, as the first thing you await. Safari only prompts while the user gesture is still active; any earlier await consumes it. Also handles Safari 15 and older, which implement only the legacy callback form.
getDeviceToken(): Promise<string | null>
Returns the current FCM token without prompting. Returns null if permission is not granted or push is unsupported.
getPermissionState(): 'granted' | 'denied' | 'default' | 'unsupported'
Returns the current permission state synchronously. 'unsupported' means the browser has no Notification API at all — most commonly iOS Safari in a tab. Never throws; safe before init() and during SSR.
onForegroundMessage(handler): () => void
Fires when a push message arrives while the app tab is open and focused. On Chrome, Edge and Firefox the service worker skips the native popup in this state, so use this to show a custom in-app notification or update UI. On Apple devices WebKit requires a notification for every push, so a quiet, tag-collapsed one is shown as well.
Always returns an unsubscribe function — if messaging is unavailable it warns and returns a no-op rather than throwing.
interface IncomingNotification {
title: string;
body: string;
imageUrl: string;
data: Record<string, string>;
raw: unknown;
}deleteToken(): Promise<boolean>
Deletes the FCM token from the browser. Call this on logout or opt-out, then also notify your backend.
How Notifications Work
| App state | What happens |
|---|---|
| App visible (tab open & focused) | Chrome/Edge/Firefox: SW suppresses the popup — onForegroundMessage fires for in-app handling. Apple: a quiet, tag-collapsed notification is shown as well (WebKit requires one per push) |
| App in background (tab hidden / minimised) | SW shows native OS popup automatically |
| Browser running, tab closed | SW shows native OS popup automatically |
| Browser fully closed | Push is queued by the OS push service and delivered when the browser next starts |
The service worker handles push events using the native Web Push API — no Firebase SDK is loaded inside it. Your firebaseConfig is only used in your app code (for token generation and foreground messages). You never need to edit firebase-messaging-sw.js.
TypeScript
No @types/ package needed. Import types directly:
import type {
IncomingNotification,
InitOptions,
PermissionState,
SupportStatus,
UnsupportedReason,
} from '@bhaskardey772/fcm-frontend';Service Worker — Supported data Fields
The service worker reads these fields from the data object of your FCM message. All are optional.
| Field | Type | Description |
|---|---|---|
| title | string | Notification title (fallback if notification.title is absent) |
| body | string | Notification body (fallback if notification.body is absent) |
| icon | string | URL of the notification icon |
| badge | string | URL of the small monochrome badge icon (status bar) |
| image | string | URL of a large inline image |
| url | string | URL to open when the notification is clicked (default: /) |
| clickUrl | string | Alias for url |
| tag | string | Groups notifications — a new notification with the same tag replaces the old one |
| actions | string | JSON array of action buttons: [{"action":"view","title":"View"}] — ignored on Apple devices |
| requireInteraction | string | Set to "false" to let the OS auto-dismiss the notification — ignored on Apple devices |
Safari also ignores badge, image and vibration. Title, body, icon and click-through work everywhere.
Example — sending with action buttons from your backend:
await notif.sendToDevice(token, {
title: 'New message',
body: 'You have a new message.',
data: {
url: '/messages',
tag: 'chat',
actions: JSON.stringify([
{ action: 'reply', title: 'Reply' },
{ action: 'dismiss', title: 'Dismiss' },
]),
},
});Troubleshooting
Nothing happens on an iPhone or iPad
- Check
notif.getSupportStatus().reason: 'ios-requires-home-screen-install'means the site is running in a Safari tab, where iOS exposes no push APIs at all. The user must tap Share → Add to Home Screen and open the app from that icon. See Apple devices. - Requires iOS/iPadOS 16.4 or later and a real HTTPS certificate —
localhostand IP addresses do not work on a phone. - The app needs a web app manifest with
"display": "standalone"and astart_urlinside the service worker's scope.
Safari never shows the permission dialog
requestPermission()must be the first thing youawaitinside a click/tap handler. Safari discards the prompt once the user gesture has been consumed by an earlierawait.- Calling it from
useEffect,onMounted, or on page load will never work on Apple devices.
Safari stopped delivering notifications after a while
- Older versions of this package skipped
showNotification()while the app was focused. WebKit treats a push with no notification as a violation and eventually revokes the subscription. Upgrade, then have the user calldeleteToken()and subscribe again to get a fresh token.
"Service worker registration failed"
- The file must be served at exactly
/firebase-messaging-sw.js. Check thatpublic/firebase-messaging-sw.jsexists and your build tool copies it to the root. - Service workers require a secure origin. Use
http://localhostin dev, not an IP address.
Token is null after requestPermission()
getPermissionState()returning'denied'means the user blocked notifications. They must re-enable manually in browser settings — you cannot re-prompt programmatically.
Notification not showing when app is open
- This is intentional on Chrome, Edge and Firefox. The service worker suppresses popups only when the app tab is the currently focused window (WhatsApp behaviour). If the tab is open but the user switched to another window or tab, the notification will still appear. Use
onForegroundMessagefor in-app handling. - On Apple devices a quiet, tag-collapsed notification is shown anyway — WebKit requires one per push and revokes the subscription otherwise.
Notification not showing when tab is closed or browser is in background
- Open DevTools → Application → Service Workers and confirm the worker is activated with no errors.
- Try unregistering the SW and reloading — the updated SW activates immediately via
skipWaiting.
Notification not showing when browser is fully closed
- This requires the browser's background process to be running.
- Chrome: Settings → System → enable "Continue running background apps when Google Chrome is closed".
- Firefox:
about:config→dom.push.enabled→true.
Using /slim but getting "Cannot find module firebase"
npm install firebase