DEV Community

Bipin C
Bipin C

Posted on

# Self-Hosted Push Notifications Part-3

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

  1. Dependencies
  2. TypeScript Type Definitions
  3. API Routes (SSR)
  4. Custom Hook
  5. PushNotificationManager Component
  6. Utility Functions
  7. Integration in _app.tsx
  8. Usage Examples
  9. 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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
*/
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Issue 2: "Notification permission denied"

Cause: User clicked "Block" or "Deny".

Solution:

  1. User must manually reset permissions in browser settings
  2. Chrome: Settings β†’ Privacy β†’ Site Settings β†’ Notifications
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Issue 5: "Notifications don't work on iOS"

Cause: iOS Safari requires PWA installation.

Solution:

  1. User must "Add to Home Screen"
  2. Open app from home screen icon (not browser)
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

Issue 6: "Subscription works but no notifications received"

Cause: Service worker not handling push events.

Solution:

  1. Check service worker is registered: Chrome DevTools β†’ Application β†’ Service Workers
  2. Verify push event listener exists in sw.js
  3. 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:

  1. Complete service worker implementation
  2. Push event handling
  3. Notification display customization
  4. Click handlers and actions
  5. PWA manifest configuration
  6. Offline support
  7. 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)