Self-Hosted Push Notifications Specification
Part 3: Frontend Implementation (Next.js)
Version: 1.0
Last Updated: October 2025
Prerequisites: Part 2: Backend Implementation
Author: Bunty9
License: MIT (Free to use and adapt)
Table of Contents
- Dependencies
- TypeScript Type Definitions
- API Routes (SSR)
- Custom Hook
- PushNotificationManager Component
- Utility Functions
- Integration in _app.tsx
- Usage Examples
- Troubleshooting
Dependencies
Install Required Packages
# Navigate to frontend directory
cd frontend
# Create Next.js app if you haven't already
npx create-next-app@latest . --typescript
# No additional packages needed!
# Push API is built into modern browsers
package.json (Relevant Dependencies)
{
"dependencies": {
"next": "^14.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0"
}
}
TypeScript Type Definitions
types/push.ts
// ==================== Request/Response Types ====================
export interface PushSubscriptionKeys {
p256dh: string;
auth: string;
}
export interface SubscribePushRequest {
endpoint: string;
keys: PushSubscriptionKeys;
userAgent: string;
}
export interface SubscribePushResponse {
success: boolean;
message: string;
subscriptionId: string;
deviceId: string;
deviceName: string;
}
export interface UnsubscribePushRequest {
endpoint: string;
}
export interface DeviceInfo {
id: string;
deviceId: string;
deviceName: string;
browser: string;
os: string;
deviceType: 'mobile' | 'desktop' | 'tablet';
isActive: boolean;
lastUsedAt?: string;
createdAt: string;
}
export interface GetDevicesResponse {
success: boolean;
count: number;
devices: DeviceInfo[];
}
// ==================== Notification Payload ====================
export interface NotificationAction {
action: string;
title: string;
icon?: string;
}
export interface PushPayload {
title: string;
body: string;
icon?: string;
badge?: string;
image?: string;
tag?: string;
url?: string;
data?: Record<string, any>;
requireInteraction?: boolean;
silent?: boolean;
vibrate?: number[];
actions?: NotificationAction[];
}
// ==================== Browser API Types ====================
export interface PushSubscriptionOptions {
userVisibleOnly: boolean;
applicationServerKey: Uint8Array;
}
// ==================== Component State ====================
export interface PushNotificationState {
isSupported: boolean;
permission: NotificationPermission;
isSubscribed: boolean;
isLoading: boolean;
error: string | null;
}
API Routes (SSR)
These routes act as secure proxies to your Go backend, forwarding cookies for authentication.
pages/api/push/subscribe.ts
import type { NextApiRequest, NextApiResponse } from 'next';
const USER_SERVICE_URL = process.env.BACKEND_API_URL || 'http://localhost:8080';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { endpoint, keys, userAgent } = req.body;
// Validate request
if (!endpoint || !keys || !keys.p256dh || !keys.auth) {
return res.status(400).json({ error: 'Missing required fields' });
}
// Forward to backend with cookies
const cookieHeader = req.headers.cookie || '';
const response = await fetch(`${USER_SERVICE_URL}/api/push/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': cookieHeader, // Critical: Forward entire cookie header
},
body: JSON.stringify({
endpoint,
keys,
userAgent: userAgent || req.headers['user-agent'] || '',
}),
});
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json(data);
}
return res.status(200).json(data);
} catch (error) {
console.error('Subscribe error:', error);
return res.status(500).json({
error: 'Failed to subscribe to push notifications'
});
}
}
pages/api/push/unsubscribe.ts
import type { NextApiRequest, NextApiResponse } from 'next';
const USER_SERVICE_URL = process.env.BACKEND_API_URL || 'http://localhost:8080';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: 'Endpoint is required' });
}
const cookieHeader = req.headers.cookie || '';
const response = await fetch(`${USER_SERVICE_URL}/api/push/unsubscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': cookieHeader,
},
body: JSON.stringify({ endpoint }),
});
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json(data);
}
return res.status(200).json(data);
} catch (error) {
console.error('Unsubscribe error:', error);
return res.status(500).json({
error: 'Failed to unsubscribe from push notifications'
});
}
}
pages/api/push/devices.ts
import type { NextApiRequest, NextApiResponse } from 'next';
const USER_SERVICE_URL = process.env.BACKEND_API_URL || 'http://localhost:8080';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const cookieHeader = req.headers.cookie || '';
const response = await fetch(`${USER_SERVICE_URL}/api/push/devices`, {
method: 'GET',
headers: {
'Cookie': cookieHeader,
},
});
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json(data);
}
return res.status(200).json(data);
} catch (error) {
console.error('Get devices error:', error);
return res.status(500).json({
error: 'Failed to fetch devices'
});
}
}
Custom Hook
hooks/usePushNotifications.ts
A reusable React hook that encapsulates all push notification logic.
import { useState, useEffect, useCallback } from 'react';
import type { PushNotificationState, DeviceInfo } from '@/types/push';
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '';
export const usePushNotifications = () => {
const [state, setState] = useState<PushNotificationState>({
isSupported: false,
permission: 'default',
isSubscribed: false,
isLoading: false,
error: null,
});
// Check if push notifications are supported
useEffect(() => {
if (typeof window === 'undefined') return;
const isSupported =
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window;
setState((prev) => ({
...prev,
isSupported,
permission: isSupported ? Notification.permission : 'denied',
}));
}, []);
// Check if already subscribed
useEffect(() => {
if (!state.isSupported) return;
checkSubscription();
}, [state.isSupported]);
// Check if user is already subscribed
const checkSubscription = async () => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setState((prev) => ({
...prev,
isSubscribed: subscription !== null,
permission: Notification.permission,
}));
} catch (error) {
console.error('Failed to check subscription:', error);
}
};
// Request notification permission
const requestPermission = async (): Promise<NotificationPermission> => {
if (!state.isSupported) {
throw new Error('Push notifications are not supported');
}
const permission = await Notification.requestPermission();
setState((prev) => ({ ...prev, permission }));
return permission;
};
// Subscribe to push notifications
const subscribe = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
// Check support
if (!state.isSupported) {
throw new Error('Push notifications are not supported in this browser');
}
// Request permission if not granted
if (Notification.permission === 'default') {
const permission = await requestPermission();
if (permission !== 'granted') {
throw new Error('Notification permission denied');
}
} else if (Notification.permission !== 'granted') {
throw new Error('Notification permission denied');
}
// Wait for service worker to be ready
const registration = await navigator.serviceWorker.ready;
// Subscribe to push manager
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Convert subscription to JSON
const subscriptionJSON = subscription.toJSON();
// Send subscription to backend
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: subscriptionJSON.endpoint,
keys: {
p256dh: subscriptionJSON.keys?.p256dh || '',
auth: subscriptionJSON.keys?.auth || '',
},
userAgent: navigator.userAgent,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to subscribe');
}
const data = await response.json();
setState((prev) => ({
...prev,
isSubscribed: true,
isLoading: false,
error: null,
}));
return data;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to subscribe';
setState((prev) => ({
...prev,
isLoading: false,
error: errorMessage,
}));
throw error;
}
}, [state.isSupported]);
// Unsubscribe from push notifications
const unsubscribe = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
throw new Error('No subscription found');
}
// Unsubscribe from push manager
await subscription.unsubscribe();
// Notify backend
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: subscription.endpoint,
}),
});
setState((prev) => ({
...prev,
isSubscribed: false,
isLoading: false,
error: null,
}));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to unsubscribe';
setState((prev) => ({
...prev,
isLoading: false,
error: errorMessage,
}));
throw error;
}
}, []);
// Get user's devices
const getDevices = useCallback(async (): Promise<DeviceInfo[]> => {
try {
const response = await fetch('/api/push/devices');
if (!response.ok) {
throw new Error('Failed to fetch devices');
}
const data = await response.json();
return data.data.devices || [];
} catch (error) {
console.error('Failed to get devices:', error);
return [];
}
}, []);
return {
...state,
subscribe,
unsubscribe,
requestPermission,
checkSubscription,
getDevices,
};
};
// ==================== Utility Functions ====================
/**
* Converts a base64 URL-safe string to Uint8Array (required for VAPID key)
*/
function urlBase64ToUint8Array(base64String: string): Uint8Array {
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;
}
/**
* Converts ArrayBuffer to base64 string
*/
function arrayBufferToBase64(buffer: ArrayBuffer | null): string {
if (!buffer) return '';
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
PushNotificationManager Component
components/PushNotificationManager.tsx
A self-contained UI component that handles subscription management.
'use client';
import { useEffect, useState } from 'react';
import { usePushNotifications } from '@/hooks/usePushNotifications';
interface PushNotificationManagerProps {
className?: string;
showDismiss?: boolean; // Allow users to dismiss the prompt
autoHide?: boolean; // Auto-hide after successful subscription
autoHideDelay?: number; // Delay before auto-hide (ms)
}
export const PushNotificationManager = ({
className = '',
showDismiss = true,
autoHide = true,
autoHideDelay = 3000,
}: PushNotificationManagerProps) => {
const {
isSupported,
permission,
isSubscribed,
isLoading,
error,
subscribe,
unsubscribe,
} = usePushNotifications();
const [isVisible, setIsVisible] = useState(true);
const [isClient, setIsClient] = useState(false);
const [isDismissed, setIsDismissed] = useState(false);
// Check if dismissed in localStorage
useEffect(() => {
setIsClient(true);
const dismissed = localStorage.getItem('push-notification-dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
setIsVisible(false);
}
}, []);
// Auto-hide after successful subscription
useEffect(() => {
if (isSubscribed && autoHide) {
const timer = setTimeout(() => {
setIsVisible(false);
}, autoHideDelay);
return () => clearTimeout(timer);
}
}, [isSubscribed, autoHide, autoHideDelay]);
const handleSubscribe = async () => {
try {
await subscribe();
} catch (error) {
console.error('Failed to subscribe:', error);
}
};
const handleUnsubscribe = async () => {
try {
await unsubscribe();
} catch (error) {
console.error('Failed to unsubscribe:', error);
}
};
const handleDismiss = () => {
setIsVisible(false);
setIsDismissed(true);
localStorage.setItem('push-notification-dismissed', 'true');
};
// Don't render on server
if (!isClient) {
return null;
}
// Don't show if dismissed or not visible
if (!isVisible || isDismissed) {
return null;
}
// Don't show if not supported
if (!isSupported) {
return null;
}
// Already subscribed - show success message
if (isSubscribed) {
return (
<div className={`notification-banner success ${className}`}>
<div className="notification-content">
<div className="notification-icon">β
</div>
<div className="notification-text">
<p className="notification-title">Notifications Enabled</p>
<p className="notification-body">
You'll receive important updates and notifications
</p>
</div>
</div>
<div className="notification-actions">
<button
onClick={handleUnsubscribe}
className="btn-secondary"
disabled={isLoading}
>
Unsubscribe
</button>
{showDismiss && (
<button onClick={handleDismiss} className="btn-close">
Γ
</button>
)}
</div>
</div>
);
}
// Permission denied
if (permission === 'denied') {
return (
<div className={`notification-banner warning ${className}`}>
<div className="notification-content">
<div className="notification-icon">β οΈ</div>
<div className="notification-text">
<p className="notification-title">Notifications Blocked</p>
<p className="notification-body">
You've blocked notifications. Enable them in your browser settings.
</p>
</div>
</div>
{showDismiss && (
<button onClick={handleDismiss} className="btn-close">
Γ
</button>
)}
</div>
);
}
// Prompt to enable notifications
return (
<div className={`notification-banner info ${className}`}>
<div className="notification-content">
<div className="notification-icon">π</div>
<div className="notification-text">
<p className="notification-title">Stay Updated</p>
<p className="notification-body">
Enable notifications to receive important updates
</p>
</div>
</div>
<div className="notification-actions">
<button
onClick={handleSubscribe}
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Enabling...' : 'Enable Notifications'}
</button>
{showDismiss && (
<button onClick={handleDismiss} className="btn-close">
Γ
</button>
)}
</div>
{error && (
<div className="notification-error">
<p>{error}</p>
</div>
)}
</div>
);
};
// ==================== Styles ====================
// Add to your global CSS or styled-components
/*
.notification-banner {
position: fixed;
bottom: 20px;
right: 20px;
max-width: 400px;
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background: white;
z-index: 1000;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.notification-banner.success {
border-left: 4px solid #10b981;
}
.notification-banner.warning {
border-left: 4px solid #f59e0b;
}
.notification-banner.info {
border-left: 4px solid #3b82f6;
}
.notification-content {
display: flex;
align-items: start;
gap: 12px;
margin-bottom: 12px;
}
.notification-icon {
font-size: 24px;
flex-shrink: 0;
}
.notification-text {
flex: 1;
}
.notification-title {
font-weight: 600;
margin-bottom: 4px;
}
.notification-body {
font-size: 14px;
color: #6b7280;
}
.notification-actions {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
.btn-primary {
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 8px 16px;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
}
.btn-secondary:hover {
background: #e5e7eb;
}
.btn-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #9ca3af;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-close:hover {
color: #6b7280;
}
.notification-error {
margin-top: 8px;
padding: 8px;
background: #fee2e2;
border-radius: 4px;
font-size: 14px;
color: #991b1b;
}
@media (max-width: 640px) {
.notification-banner {
bottom: 0;
right: 0;
left: 0;
max-width: none;
border-radius: 0;
}
}
*/
Utility Functions
utils/push-utils.ts
/**
* Registers the service worker
*/
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service Worker registered:', registration);
return registration;
} catch (error) {
console.error('Service Worker registration failed:', error);
return null;
}
}
return null;
}
/**
* Checks if push notifications are supported
*/
export function isPushNotificationSupported(): boolean {
return (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
/**
* Gets the current notification permission
*/
export function getNotificationPermission(): NotificationPermission {
if (typeof window === 'undefined' || !('Notification' in window)) {
return 'denied';
}
return Notification.permission;
}
/**
* Checks if user is subscribed to push notifications
*/
export async function isSubscribed(): Promise<boolean> {
if (!isPushNotificationSupported()) {
return false;
}
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
return subscription !== null;
} catch (error) {
console.error('Failed to check subscription status:', error);
return false;
}
}
/**
* Shows a local notification (for testing)
*/
export async function showLocalNotification(
title: string,
body: string,
options?: NotificationOptions
): Promise<void> {
if (!isPushNotificationSupported()) {
throw new Error('Notifications not supported');
}
if (Notification.permission !== 'granted') {
throw new Error('Notification permission not granted');
}
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, {
body,
...options,
});
}
Integration in _app.tsx
pages/_app.tsx
import { useEffect } from 'react';
import type { AppProps } from 'next/app';
import { PushNotificationManager } from '@/components/PushNotificationManager';
import { registerServiceWorker } from '@/utils/push-utils';
import '@/styles/globals.css';
export default function App({ Component, pageProps }: AppProps) {
// Register service worker on app mount
useEffect(() => {
registerServiceWorker();
}, []);
return (
<>
<Component {...pageProps} />
{/* Add PushNotificationManager globally */}
<PushNotificationManager
showDismiss={true}
autoHide={true}
autoHideDelay={3000}
/>
</>
);
}
pages/_document.tsx
CRITICAL: Link PWA manifest in HTML head.
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="en">
<Head>
{/* PWA Manifest - REQUIRED for notifications */}
<link rel="manifest" href="/manifest.json" />
{/* PWA Icons */}
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icon-192x192.png" />
{/* Theme Color */}
<meta name="theme-color" content="#3b82f6" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
Usage Examples
Example 1: Programmatic Notification
// In any component
import { usePushNotifications } from '@/hooks/usePushNotifications';
export function MyComponent() {
const { subscribe, isSubscribed, isLoading } = usePushNotifications();
const handleEnableNotifications = async () => {
try {
await subscribe();
alert('Notifications enabled!');
} catch (error) {
alert('Failed to enable notifications');
}
};
return (
<button onClick={handleEnableNotifications} disabled={isLoading || isSubscribed}>
{isSubscribed ? 'Notifications Enabled' : 'Enable Notifications'}
</button>
);
}
Example 2: Device Management UI
import { useEffect, useState } from 'react';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import type { DeviceInfo } from '@/types/push';
export function DeviceList() {
const { getDevices } = usePushNotifications();
const [devices, setDevices] = useState<DeviceInfo[]>([]);
useEffect(() => {
fetchDevices();
}, []);
const fetchDevices = async () => {
const deviceList = await getDevices();
setDevices(deviceList);
};
return (
<div className="device-list">
<h3>Your Devices ({devices.length}/5)</h3>
{devices.map((device) => (
<div key={device.id} className="device-card">
<div className="device-info">
<h4>{device.deviceName}</h4>
<p>{device.browser} β’ {device.os}</p>
<p className="device-date">
Last used: {device.lastUsedAt ? new Date(device.lastUsedAt).toLocaleDateString() : 'Never'}
</p>
</div>
<div className="device-status">
{device.isActive ? (
<span className="status-active">Active</span>
) : (
<span className="status-inactive">Inactive</span>
)}
</div>
</div>
))}
</div>
);
}
Example 3: Conditional Rendering
import { usePushNotifications } from '@/hooks/usePushNotifications';
export function NotificationBell() {
const { isSupported, isSubscribed, permission } = usePushNotifications();
// Don't show bell if not supported
if (!isSupported) {
return null;
}
return (
<button className="notification-bell">
π
{isSubscribed ? (
<span className="badge">β</span>
) : permission === 'denied' ? (
<span className="badge">β</span>
) : (
<span className="badge">!</span>
)}
</button>
);
}
Example 4: Backend-Triggered Notification
// Backend logic (Go) - when booking is confirmed
func (bs *BookingService) ConfirmBooking(bookingID uuid.UUID) error {
// ... confirm booking logic ...
// Send push notification
payload := types.PushPayload{
Title: "Booking Confirmed!",
Body: fmt.Sprintf("Your booking at %s is confirmed", spaceName),
Icon: "/icon-192x192.png",
Tag: "booking_confirmed",
URL: fmt.Sprintf("/bookings/%s", bookingID),
Data: map[string]interface{}{
"bookingId": bookingID.String(),
"type": "booking_confirmed",
},
}
// Non-blocking send
go pushService.SendToUser(userID, payload)
return nil
}
Troubleshooting
Issue 1: "Service worker registration failed"
Cause: Service worker file not found or HTTPS not enabled.
Solution:
# Check file exists
ls public/sw.js
# Development: Next.js serves /public at root
# Access at: http://localhost:3000/sw.js
# Production: Ensure HTTPS is enabled
Issue 2: "Notification permission denied"
Cause: User clicked "Block" or "Deny".
Solution:
- User must manually reset permissions in browser settings
- Chrome: Settings β Privacy β Site Settings β Notifications
- Firefox: Preferences β Privacy β Permissions β Notifications
Issue 3: "VAPID public key not configured"
Cause: Missing environment variable.
Solution:
# Create .env.local
echo "NEXT_PUBLIC_VAPID_PUBLIC_KEY=YOUR_PUBLIC_KEY_HERE" > .env.local
# Restart dev server
npm run dev
Issue 4: "Failed to subscribe: DOMException"
Cause: VAPID key mismatch or invalid format.
Solution:
// Verify key in hook
console.log('VAPID Key:', process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY);
// Ensure it's the same as backend
// Should be base64 URL-safe string, ~88 characters
Issue 5: "Notifications don't work on iOS"
Cause: iOS Safari requires PWA installation.
Solution:
- User must "Add to Home Screen"
- Open app from home screen icon (not browser)
- Then notifications will work
Detection Code:
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isStandalone = (window.navigator as any).standalone;
if (isIOS && !isStandalone) {
// Show "Add to Home Screen" instruction
}
Issue 6: "Subscription works but no notifications received"
Cause: Service worker not handling push events.
Solution:
- Check service worker is registered: Chrome DevTools β Application β Service Workers
- Verify push event listener exists in
sw.js
- Test with backend
/api/push/test/:userId
endpoint
Next Steps
β Frontend implementation complete!
Now proceed to:
β‘οΈ Part 4: Service Worker & PWA Configuration
Part 4 will cover:
- Complete service worker implementation
- Push event handling
- Notification display customization
- Click handlers and actions
- PWA manifest configuration
- Offline support
- Background sync (advanced)
Summary
You now have a complete, production-ready frontend for push notifications with:
β
Type-safe API routes with cookie forwarding
β
Reusable usePushNotifications hook
β
PushNotificationManager component with UI
β
Multi-device support
β
Automatic permission handling
β
Error boundaries and fallbacks
β
localStorage persistence for dismissed prompts
β
Comprehensive utility functions
The frontend seamlessly integrates with your Go backend through SSR API routes.
Top comments (0)