JB logo

Command Palette

Search for a command to run...

yOUTUBE
Blog
Next

Building a Progressive Web App (PWA) with Next.js and Firebase Push Notifications

Complete guide to building a production-ready PWA with Next.js 14+, TypeScript, and Firebase Cloud Messaging. Learn to implement push notifications, create installable web apps, build a shopping cart with real-time notifications, and deploy to production. Includes optional Prisma integration for storing FCM tokens. Perfect for developers wanting to create modern, app-like web experiences.

Building a Progressive Web App (PWA) with Next.js and Firebase Push Notifications

A Complete Step-by-Step Guide

Tech Stack: Next.js 14+ | TypeScript | pnpm | Firebase Cloud Messaging | (Optional: Prisma + PostgreSQL)


Introduction

This guide will walk you through creating a Next.js application with TypeScript, converting it into a Progressive Web App (PWA), and implementing push notifications using Firebase Cloud Messaging (FCM). The example application features a shopping cart where users receive push notifications when they place an order.

By the end of this tutorial, you'll have a fully functional PWA with:

  • ✓ Installable on mobile and desktop devices
  • ✓ Push notification support across all platforms
  • ✓ Shopping cart functionality
  • ✓ TypeScript for type safety
  • ✓ Native app-like experience
  • ✓ Optional: Store FCM tokens in database

Prerequisites

Before starting, ensure you have:

  • Node.js (v18 or higher) installed
  • pnpm package manager (npm install -g pnpm)
  • A Google Firebase account (free)
  • Basic knowledge of React, Next.js, and TypeScript
  • A text editor (VS Code recommended)

PART 1: Creating Your Next.js Application

Step 1: Initialize a New Next.js Project

Open your terminal and run:

pnpm create next-app@latest my-pwa-app

During setup, choose the following options:

  • ✓ Would you like to use TypeScript? → Yes
  • ✓ Would you like to use ESLint? → Yes
  • ✓ Would you like to use Tailwind CSS? → Yes
  • ✓ Would you like to use src/ directory? → No
  • ✓ Would you like to use App Router? → Yes
  • ✓ Would you like to customize the default import alias? → No

Navigate to your project directory:

cd my-pwa-app

Step 2: Install Required Dependencies

Install Firebase SDK for push notifications:

pnpm add firebase firebase-admin

PART 2: Setting Up Firebase Cloud Messaging

Step 3: Create a Firebase Project

  1. Go to the Firebase Console: https://console.firebase.google.com/
  2. Click "Add project" or "Create a project"
  3. Enter your project name (e.g., "My PWA App")
  4. Choose whether to enable Google Analytics (optional)
  5. Click "Create project" and wait for setup to complete

Step 4: Register Your Web App in Firebase

  1. In your Firebase project dashboard, click the web icon (</>) to add a web app
  2. Enter an app nickname (e.g., "My PWA Web App")
  3. Click "Register app"
  4. Copy your Firebase configuration object - you'll need this later

Step 5: Enable Cloud Messaging and Get VAPID Key

  1. In the Firebase Console, go to Project Settings (gear icon)
  2. Click on the "Cloud Messaging" tab
  3. Under "Web Push certificates", click "Generate key pair"
  4. Copy the generated VAPID key - save this securely

Step 6: Download Service Account Key

  1. In Project Settings, go to the "Service Accounts" tab
  2. Click "Generate new private key"
  3. Click "Generate key" in the confirmation dialog
  4. A JSON file will be downloaded - save this as serviceAccountKey.json in your project root

⚠️ IMPORTANT: Never commit this file to version control!


PART 3: Configuring Your Next.js Application

Step 7: Create Environment Variables

Create a .env.local file in your project root:

# Firebase Configuration (Client-side)
NEXT_PUBLIC_FIREBASE_API_KEY=your_api_key_here
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_project.appspot.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
NEXT_PUBLIC_FIREBASE_APP_ID=your_app_id
 
# VAPID Key for Push Notifications
NEXT_PUBLIC_VAPID_KEY=your_vapid_key_here

Replace all your_*_here values with your actual Firebase configuration values.

Step 8: Update .gitignore

Make sure your .gitignore includes:

# Environment files
.env
.env.local
.env*.local

# Firebase service account
serviceAccountKey.json

Step 9: Configure Firebase in Your App

Create lib/firebase.ts:

import { initializeApp, getApps, FirebaseApp } from "firebase/app";
import {
  getMessaging,
  getToken,
  onMessage,
  Messaging,
} from "firebase/messaging";
 
const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
 
// Initialize Firebase
let app: FirebaseApp;
let messaging: Messaging;
 
if (typeof window !== "undefined") {
  app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
  messaging = getMessaging(app);
}
 
// Request notification permission and get FCM token
export const requestNotificationPermission = async (): Promise<
  string | null
> => {
  try {
    const permission = await Notification.requestPermission();
    if (permission === "granted") {
      const token = await getToken(messaging, {
        vapidKey: process.env.NEXT_PUBLIC_VAPID_KEY,
      });
      console.log("FCM Token:", token);
      return token;
    } else {
      console.log("Notification permission denied");
      return null;
    }
  } catch (error) {
    console.error("Error getting FCM token:", error);
    return null;
  }
};
 
// Listen for foreground messages
export const onMessageListener = (): Promise<any> =>
  new Promise((resolve) => {
    onMessage(messaging, (payload) => {
      console.log("Message received:", payload);
      resolve(payload);
    });
  });

Step 10: Create the Service Worker

Create public/firebase-messaging-sw.js:

// Give the service worker access to Firebase Messaging.
importScripts(
  "https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js"
);
importScripts(
  "https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js"
);
 
// Initialize the Firebase app in the service worker
// Replace with your actual Firebase config
 
firebase.initializeApp({
  apiKey: "AIzaSyBdArVFvaYodhomq8KIwF2Eiwu8pR9xN0k",
  authDomain: "bazaarug.firebaseapp.com",
  projectId: "bazaarug",
  storageBucket: "bazaarug.firebasestorage.app",
  messagingSenderId: "324253700286",
  appId: "1:324253700286:web:235201d7dcf4a2efcf4eef",
});
 
// Retrieve an instance of Firebase Messaging
const messaging = firebase.messaging();
 
// Handle background messages
messaging.onBackgroundMessage((payload) => {
  console.log("Received background message:", payload);
 
  const notificationTitle = payload.notification?.title || "New Notification";
  const notificationOptions = {
    body: payload.notification?.body || "You have a new notification",
    icon: "/icon-192x192.png",
    badge: "/badge-72x72.png",
    vibrate: [200, 100, 200],
    data: {
      url: "/orders", // ← This is where the user will go when they click
      ...payload.data,
    },
    requireInteraction: true, // ← Keeps notification visible until clicked
    tag: "order-notification",
  };
 
  return self.registration.showNotification(
    notificationTitle,
    notificationOptions
  );
});
 
// Handle notification clicks
self.addEventListener("notificationclick", (event) => {
  console.log("Notification clicked:", event);
  event.notification.close(); // Close the notification
 
  // Get the URL from notification data (default to orders page)
  const urlToOpen = event.notification.data?.url || "/orders";
 
  event.waitUntil(
    clients
      .matchAll({ type: "window", includeUncontrolled: true })
      .then((windowClients) => {
        // Check if there is already a window/tab open with the app
        for (let i = 0; i < windowClients.length; i++) {
          const client = windowClients[i];
          // If app is already open, navigate to the URL and focus
          if (
            client.url.includes(self.registration.scope) &&
            "focus" in client
          ) {
            client.navigate(urlToOpen);
            return client.focus();
          }
        }
        // If app is not open, open it with the URL
        if (clients.openWindow) {
          return clients.openWindow(urlToOpen);
        }
      })
  );
});

⚠️ Note: Replace the Firebase config values with your actual values. Service workers cannot access environment variables directly.


PART 4: Converting to a Progressive Web App

Step 11: Create the Web App Manifest

Create app/manifest.ts:

import { MetadataRoute } from "next";
 
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "My PWA Shopping App",
    short_name: "PWA Shop",
    description:
      "A Progressive Web App with Shopping Cart and Push Notifications",
    start_url: "/",
    display: "standalone",
    background_color: "#ffffff",
    theme_color: "#000000",
    icons: [
      {
        src: "/icon-192x192.png",
        sizes: "192x192",
        type: "image/png",
        purpose: "any maskable",
      },
      {
        src: "/icon-512x512.png",
        sizes: "512x512",
        type: "image/png",
        purpose: "any maskable",
      },
    ],
  };
}

Step 12: Generate PWA Icons

You need to create app icons. Use online tools:

  1. Go to https://realfavicongenerator.net/ or https://www.pwabuilder.com/imageGenerator
  2. Upload your app logo/icon (minimum 512x512px)
  3. Generate the icon pack
  4. Download and extract the icons
  5. Place icon-192x192.png and icon-512x512.png in your public/ folder

Step 13: Update Next.js Configuration

Update next.config.mjs:

/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "X-Content-Type-Options",
            value: "nosniff",
          },
          {
            key: "X-Frame-Options",
            value: "DENY",
          },
          {
            key: "Referrer-Policy",
            value: "strict-origin-when-cross-origin",
          },
        ],
      },
      {
        source: "/firebase-messaging-sw.js",
        headers: [
          {
            key: "Content-Type",
            value: "application/javascript; charset=utf-8",
          },
          {
            key: "Cache-Control",
            value: "no-cache, no-store, must-revalidate",
          },
          {
            key: "Service-Worker-Allowed",
            value: "/",
          },
        ],
      },
    ];
  },
};
 
export default nextConfig;

PART 5: Creating Type Definitions and Data

Step 14: Create TypeScript Types

Create types/index.ts:

export interface Product {
  id: string;
  name: string;
  price: number;
}
 
export interface CartItem extends Product {
  quantity: number;
}

Step 15: Create Dummy Product Data

Create data/products.ts:

import { Product } from "@/types";
 
export const PRODUCTS: Product[] = [
  {
    id: "1",
    name: "Wireless Headphones",
    price: 99.99,
  },
  {
    id: "2",
    name: "Smart Watch",
    price: 249.99,
  },
  {
    id: "3",
    name: "Laptop Stand",
    price: 49.99,
  },
  {
    id: "4",
    name: "USB-C Hub",
    price: 39.99,
  },
  {
    id: "5",
    name: "Mechanical Keyboard",
    price: 129.99,
  },
];

PART 6: Building the Backend API

Step 16: Set Up Firebase Admin

Create lib/firebaseAdmin.ts:

import * as admin from "firebase-admin";
 
if (!admin.apps.length) {
  const serviceAccount = require("../serviceAccountKey.json");
 
  admin.initializeApp({
    credential: admin.credential.cert(serviceAccount),
  });
}
 
export const messaging = admin.messaging();
export default admin;

Step 17: Create Send Notification API Route

Create app/api/send-notification/route.ts:

/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server";
import { messaging } from "@/lib/firebaseAdmin";
import type { CartItem } from "@/types";
 
export async function POST(request: Request) {
  try {
    const { token, items }: { token: string; items: CartItem[] } =
      await request.json();
 
    if (!token || !items || items.length === 0) {
      return NextResponse.json(
        { error: "Token and items are required" },
        { status: 400 }
      );
    }
 
    // Calculate total
    const total = items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
 
    // Create notification message
    const itemsList = items
      .map((item) => `${item.quantity}x ${item.name}`)
      .join(", ");
 
    const message = {
      notification: {
        title: "🎉 Order Confirmed!",
        body: `Your order of ${itemsList} has been placed successfully. Total: $${total.toFixed(2)}`,
      },
      data: {
        url: "/orders", // ← Add this to tell the service worker where to navigate
        total: total.toString(),
        itemCount: items.length.toString(),
      },
      token,
    };
 
    // Send the notification with error handling
    try {
      const response = await messaging.send(message);
      console.log("Successfully sent notification:", response);
 
      return NextResponse.json({
        success: true,
        message: "Notification sent successfully",
        messageId: response,
      });
    } catch (sendError: any) {
      // Handle specific FCM errors
      if (sendError.code === "messaging/registration-token-not-registered") {
        console.error("FCM token is invalid or expired");
        return NextResponse.json(
          {
            success: false,
            error:
              "Your notification token has expired. Please enable notifications again.",
            shouldRefreshToken: true,
          },
          { status: 410 }
        );
      }
 
      throw sendError;
    }
  } catch (error: any) {
    console.error("Error sending notification:", error);
    return NextResponse.json(
      {
        error: "Failed to send notification",
        details: error.message || "Unknown error",
      },
      { status: 500 }
    );
  }
}

PART 7: Building the Frontend UI

Step 18: Create Cart Context

Create contexts/CartContext.tsx:

'use client';
 
import React, { createContext, useContext, useState, useEffect } from 'react';
import type { Product, CartItem } from '@/types';
 
interface CartContextType {
  cart: CartItem[];
  addToCart: (product: Product) => void;
  removeFromCart: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  clearCart: () => void;
  getTotalPrice: () => number;
  getTotalItems: () => number;
}
 
const CartContext = createContext<CartContextType | undefined>(undefined);
 
export function CartProvider({ children }: { children: React.ReactNode }) {
  const [cart, setCart] = useState<CartItem[]>([]);
 
  // Load cart from localStorage on mount
  useEffect(() => {
    const savedCart = localStorage.getItem('cart');
    if (savedCart) {
      setCart(JSON.parse(savedCart));
    }
  }, []);
 
  // Save cart to localStorage whenever it changes
  useEffect(() => {
    localStorage.setItem('cart', JSON.stringify(cart));
  }, [cart]);
 
  const addToCart = (product: Product) => {
    setCart((prevCart) => {
      const existingItem = prevCart.find((item) => item.id === product.id);
      if (existingItem) {
        return prevCart.map((item) =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }
      return [...prevCart, { ...product, quantity: 1 }];
    });
  };
 
  const removeFromCart = (productId: string) => {
    setCart((prevCart) => prevCart.filter((item) => item.id !== productId));
  };
 
  const updateQuantity = (productId: string, quantity: number) => {
    if (quantity <= 0) {
      removeFromCart(productId);
      return;
    }
    setCart((prevCart) =>
      prevCart.map((item) =>
        item.id === productId ? { ...item, quantity } : item
      )
    );
  };
 
  const clearCart = () => {
    setCart([]);
  };
 
  const getTotalPrice = () => {
    return cart.reduce((total, item) => total + item.price * item.quantity, 0);
  };
 
  const getTotalItems = () => {
    return cart.reduce((total, item) => total + item.quantity, 0);
  };
 
  return (
    <CartContext.Provider
      value={{
        cart,
        addToCart,
        removeFromCart,
        updateQuantity,
        clearCart,
        getTotalPrice,
        getTotalItems,
      }}
    >
      {children}
    </CartContext.Provider>
  );
}
 
export function useCart() {
  const context = useContext(CartContext);
  if (context === undefined) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
}

Step 19: Update Root Layout

Update app/layout.tsx:

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { CartProvider } from '@/contexts/CartContext';
 
const inter = Inter({ subsets: ['latin'] });
 
export const metadata: Metadata = {
  title: 'PWA Shopping App',
  description: 'A Progressive Web App with Shopping Cart and Push Notifications',
};
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <CartProvider>{children}</CartProvider>
      </body>
    </html>
  );
}

Step 20: Create Product Card Component

Create components/ProductCard.tsx:

'use client';
 
import { useCart } from '@/contexts/CartContext';
import type { Product } from '@/types';
 
interface ProductCardProps {
  product: Product;
}
 
export default function ProductCard({ product }: ProductCardProps) {
  const { addToCart } = useCart();
 
  return (
    <div className="border rounded-lg p-6 shadow-md hover:shadow-lg transition-shadow bg-white">
      <div className="aspect-square bg-gradient-to-br from-blue-100 to-blue-200 rounded-md mb-4 flex items-center justify-center">
        <span className="text-6xl">📦</span>
      </div>
      <h3 className="font-semibold text-lg mb-2">{product.name}</h3>
      <div className="flex items-center justify-between mt-4">
        <span className="text-2xl font-bold text-blue-600">
          ${product.price.toFixed(2)}
        </span>
        <button
          onClick={() => addToCart(product)}
          className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors font-medium"
        >
          Add to Cart
        </button>
      </div>
    </div>
  );
}

Step 21: Create Cart Component

Create components/Cart.tsx:

"use client";
 
import { useState } from "react";
import { useCart } from "./CartProvider";
 
interface CartProps {
  fcmToken: string | null;
  onOrderSuccess: () => void;
  onTokenExpired: () => void;
}
 
export default function FCMCart({
  fcmToken,
  onOrderSuccess,
  onTokenExpired,
}: CartProps) {
  const { cart, updateQuantity, removeFromCart, clearCart, getTotalPrice } =
    useCart();
  const [isOrdering, setIsOrdering] = useState(false);
 
  const handlePlaceOrder = async () => {
    if (!fcmToken) {
      alert("Please enable notifications first to receive order confirmation");
      return;
    }
 
    if (cart.length === 0) {
      alert("Your cart is empty");
      return;
    }
 
    setIsOrdering(true);
 
    try {
      const response = await fetch("/api/send-notification", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          token: fcmToken,
          items: cart,
        }),
      });
 
      const data = await response.json();
 
      if (data.success) {
        clearCart();
        onOrderSuccess();
      } else if (data.shouldRefreshToken) {
        // Token expired, need to refresh
        alert(
          "Your notification permission has expired. Please enable notifications again."
        );
        onTokenExpired();
      } else {
        alert("Failed to place order: " + data.error);
      }
    } catch (error) {
      console.error("Error placing order:", error);
      alert("Failed to place order");
    } finally {
      setIsOrdering(false);
    }
  };
 
  if (cart.length === 0) {
    return (
      <div className="bg-white rounded-lg shadow-md p-6">
        <h2 className="text-2xl font-bold mb-4">Shopping Cart</h2>
        <p className="text-gray-500">Your cart is empty</p>
      </div>
    );
  }
 
  return (
    <div className="bg-white rounded-lg shadow-md p-6">
      <h2 className="text-2xl font-bold mb-4">Shopping Cart</h2>
      <div className="space-y-4 mb-6">
        {cart.map((item) => (
          <div key={item.id} className="flex items-center gap-4 border-b pb-4">
            <div className="w-16 h-16 bg-gradient-to-br from-blue-100 to-blue-200 rounded flex-shrink-0 flex items-center justify-center">
              <span className="text-2xl">📦</span>
            </div>
            <div className="flex-1">
              <h3 className="font-semibold">{item.name}</h3>
              <p className="text-gray-600">${item.price.toFixed(2)}</p>
            </div>
            <div className="flex items-center gap-2">
              <button
                onClick={() => updateQuantity(item.id, item.quantity - 1)}
                className="w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 font-semibold"
              >
                -
              </button>
              <span className="w-8 text-center font-medium">
                {item.quantity}
              </span>
              <button
                onClick={() => updateQuantity(item.id, item.quantity + 1)}
                className="w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 font-semibold"
              >
                +
              </button>
            </div>
            <div className="text-right">
              <p className="font-semibold">
                ${(item.price * item.quantity).toFixed(2)}
              </p>
              <button
                onClick={() => removeFromCart(item.id)}
                className="text-red-500 text-sm hover:text-red-700"
              >
                Remove
              </button>
            </div>
          </div>
        ))}
      </div>
      <div className="border-t pt-4">
        <div className="flex justify-between items-center mb-4">
          <span className="text-xl font-bold">Total:</span>
          <span className="text-2xl font-bold text-blue-600">
            ${getTotalPrice().toFixed(2)}
          </span>
        </div>
        <button
          onClick={handlePlaceOrder}
          disabled={isOrdering || !fcmToken}
          className="w-full bg-green-600 text-white py-3 rounded-lg font-semibold hover:bg-green-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
        >
          {isOrdering ? "Placing Order..." : "Place Order 🛒"}
        </button>
        {!fcmToken && (
          <p className="text-sm text-amber-600 mt-2 text-center">
            ⚠️ Enable notifications to receive order confirmation
          </p>
        )}
      </div>
    </div>
  );
}
 

Step 22: Create Main Page Component

Update app/page.tsx:

/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
 
import { useState, useEffect } from "react";
import {
  requestNotificationPermission,
  onMessageListener,
} from "@/lib/firebase";
import { useCart } from "../cart/CartProvider";
import { PRODUCTS } from "@/data";
import ProductCard from "../global/ProductCard";
import FCMCart from "../cart/FCMCart";
 
export default function PushNotifications() {
  const [isSupported, setIsSupported] = useState(false);
  const [fcmToken, setFcmToken] = useState<string | null>(null);
  const [notification, setNotification] = useState<{
    title: string;
    body: string;
  } | null>(null);
  const { getTotalItems } = useCart();
 
  // Register service worker
  async function registerServiceWorker() {
    try {
      await navigator.serviceWorker.register("/firebase-messaging-sw.js");
      console.log("Service Worker registered");
    } catch (error) {
      console.error("Service Worker registration failed:", error);
    }
  }
 
  // Show system notification (for foreground messages)
  const showSystemNotification = (title: string, body: string, data?: any) => {
    if ("Notification" in window && Notification.permission === "granted") {
      const notification = new Notification(title, {
        body: body,
        icon: "/icon-192x192.png",
        badge: "/badge-72x72.png",
        vibrate: [200, 100, 200],
        tag: "order-notification",
        requireInteraction: true, // ← Keeps notification visible until clicked
        data: {
          url: "/orders", // ← URL to navigate to
          ...data,
        },
      });
 
      // Handle notification click
      notification.onclick = (event) => {
        event.preventDefault(); // Prevent default behavior
        window.focus(); // Focus the window
        window.location.href = "/orders"; // Navigate to orders page
        notification.close(); // Close the notification
      };
    }
  };
 
  // Check browser support on mount
  useEffect(() => {
    const checkSupport = () => {
      if (
        typeof window !== "undefined" &&
        "serviceWorker" in navigator &&
        "PushManager" in window
      ) {
        return true;
      }
      return false;
    };
 
    setIsSupported(checkSupport());
  }, []);
 
  // Register service worker when supported
  useEffect(() => {
    if (isSupported) {
      registerServiceWorker();
    }
  }, [isSupported]);
 
  // Load FCM token from localStorage
  useEffect(() => {
    const savedToken = localStorage.getItem("fcmToken");
    if (savedToken) {
      setFcmToken(savedToken);
    }
  }, []);
 
  // Listen for foreground messages
  useEffect(() => {
    if (typeof window === "undefined") return;
 
    const unsubscribe = onMessageListener()
      .then((payload: any) => {
        const title = payload.notification?.title || "New Notification";
        const body = payload.notification?.body || "";
 
        // Show in-app banner
        setNotification({ title, body });
        setTimeout(() => setNotification(null), 5000);
 
        // IMPORTANT: Also show system notification with data
        showSystemNotification(title, body, payload.data);
      })
      .catch((err) => console.log("Failed to receive message: ", err));
 
    return () => {
      if (unsubscribe && typeof unsubscribe === "function") {
        unsubscribe();
      }
    };
  }, []);
 
  const handleEnableNotifications = async () => {
    const token = await requestNotificationPermission();
    if (token) {
      setFcmToken(token);
      localStorage.setItem("fcmToken", token);
    }
  };
 
  const handleTokenExpired = () => {
    setFcmToken(null);
    localStorage.removeItem("fcmToken");
  };
 
  const handleOrderSuccess = () => {
    setNotification({
      title: "✅ Success!",
      body: "Your order has been placed. Check your notifications!",
    });
    setTimeout(() => setNotification(null), 5000);
  };
 
  if (!isSupported) {
    return (
      <div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
        <div className="bg-white rounded-lg shadow-md p-8 max-w-md">
          <h1 className="text-2xl font-bold mb-4">
            Push Notifications Not Supported
          </h1>
          <p className="text-gray-600">
            Your browser does not support push notifications. Please use a
            modern browser like Chrome, Firefox, or Safari.
          </p>
        </div>
      </div>
    );
  }
 
  return (
    <div className="min-h-screen bg-gray-50">
      {/* Header */}
      <header className="bg-white shadow-sm sticky top-0 z-10">
        <div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
          <h1 className="text-2xl font-bold text-gray-900">🛍️ PWA Shop</h1>
          <div className="flex items-center gap-4">
            <div className="relative">
              <span className="text-2xl">🛒</span>
              {getTotalItems() > 0 && (
                <span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-semibold">
                  {getTotalItems()}
                </span>
              )}
            </div>
            {!fcmToken ? (
              <button
                onClick={handleEnableNotifications}
                className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors text-sm font-medium"
              >
                🔔 Enable Notifications
              </button>
            ) : (
              <span className="text-sm text-green-600 font-semibold">
Notifications Enabled
              </span>
            )}
          </div>
        </div>
      </header>
 
      {/* Notification Toast */}
      {notification && (
        <div className="fixed top-20 right-4 bg-white shadow-lg rounded-lg p-4 max-w-sm z-50 animate-slide-in border-l-4 border-blue-500">
          <h3 className="font-bold text-lg mb-1">{notification.title}</h3>
          <p className="text-gray-600">{notification.body}</p>
        </div>
      )}
 
      <div className="max-w-7xl mx-auto px-4 py-8">
        <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
          {/* Products Section */}
          <div className="lg:col-span-1">
            <h2 className="text-2xl font-bold mb-6">Products</h2>
            <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
              {PRODUCTS.slice(0, 2).map((product) => (
                <ProductCard key={product.id} product={product} />
              ))}
            </div>
          </div>
 
          {/* Cart Section */}
          <div className="lg:col-span-2">
            <div className="sticky top-24">
              <FCMCart
                fcmToken={fcmToken}
                onOrderSuccess={handleOrderSuccess}
                onTokenExpired={handleTokenExpired}
              />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}
 

Step 23: Update Global Styles

Update app/globals.css:

@tailwind base;
@tailwind components;
@tailwind utilities;
 
@layer utilities {
  @keyframes slide-in {
    from {
      transform: translateX(100%);
      opacity: 0;
    }
    to {
      transform: translateX(0);
      opacity: 1;
    }
  }
 
  .animate-slide-in {
    animation: slide-in 0.3s ease-out;
  }
}

The difference between the foreground and background

┌─────────────────────────────────────────────────────┐ │ │ │ FOREGROUND = App is OPEN and user is looking at it│ │ ┌─────────────────────────────────────┐ │ │ │ Your PWA Shop is VISIBLE │ │ │ │ User can see the page │ │ │ │ Browser tab is ACTIVE │ │ │ └─────────────────────────────────────┘ │ │ │ │ When notification arrives: │ │ → Goes to onMessageListener() in your React code │ │ → You must MANUALLY show notification │ │ │ └─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐ │ │ │ BACKGROUND = App is CLOSED, minimized, or in tab │ │ ┌─────────────────────────────────────┐ │ │ │ Browser is minimized │ │ │ │ OR different tab is open │ │ │ │ OR browser is closed │ │ │ └─────────────────────────────────────┘ │ │ │ │ When notification arrives: │ │ → Goes to Service Worker (firebase-messaging-sw.js)│ │ → Service Worker AUTOMATICALLY shows notification │ │ │ └─────────────────────────────────────────────────────┘ Do You Need Both? YES! Here's Why: Scenario 1: User Has App Open (Foreground) typescriptUser is browsing your PWA Shop... └─> Notification arrives └─> Firebase sends it to onMessageListener() └─> Your React code handles it └─> Must call new Notification() manually └─> If you DON'T handle this, user sees NOTHING! Without foreground handling:

❌ User places order ❌ No notification appears (even though it was sent!) ❌ User thinks the order failed ❌ Bad user experience

Scenario 2: User Minimized/Closed App (Background) typescriptUser minimized the browser... └─> Notification arrives └─> Firebase sends it to Service Worker └─> Service Worker handles it automatically └─> Shows system notification └─> If you DON'T have service worker, user sees NOTHING! Without background handling:

❌ User isn't looking at app ❌ No notification appears ❌ Defeats the purpose of push notifications ❌ User misses important updates

PART 8: Testing Your PWA

Step 24: Test Locally with HTTPS

PWAs require HTTPS. For local testing:

pnpm dev --experimental-https

Or update your package.json:

{
  "scripts": {
    "dev": "next dev --experimental-https",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }
}

Your app will be available at: https://localhost:3000

Step 25: Test the Complete Flow

  1. Enable Notifications:

  2. Add Products to Cart:

    • Click "Add to Cart" on any product
    • See the cart counter update
  3. Place an Order:

    • Review items in your cart
    • Click "Place Order 🛒"
    • You should receive a push notification!
  4. Test Background Notifications:

    • Minimize the browser
    • Place another order
    • Notification appears even when app is in background

Step 26: Test PWA Installation

Desktop (Chrome/Edge):

  • Look for install icon in address bar
  • Or: Menu → "Install PWA Shop"

Mobile (iOS Safari):

  • Tap Share button → "Add to Home Screen"

Mobile (Android Chrome):

  • Menu → "Add to Home screen"

PART 9 (Optional): Storing FCM Tokens in Database

If you want to persist FCM tokens in a database, follow this optional section. You can skip this if you just want to test the PWA functionality.

Step 27: Set Up Prisma (Optional)

Note: If you haven't set up Prisma yet, follow this guide: https://jb.desishub.com/blog/nextjs-with-prisma-7-and-postgres

Once you have Prisma installed, add this simple model to your prisma/schema.prisma:

model User {
  id        String   @id @default(cuid())
  fcmToken  String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Run the migration:

pnpm dlx prisma migrate dev --name add_user_model

Step 28: Create Prisma Client (Optional)

Create lib/prisma.ts:

import { PrismaClient } from "@prisma/client";
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};
 
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
 
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Step 29: Create Subscribe API Route (Optional)

Create app/api/subscribe/route.ts:

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
 
export async function POST(request: Request) {
  try {
    const { token } = await request.json();
 
    if (!token) {
      return NextResponse.json({ error: "Token is required" }, { status: 400 });
    }
 
    // Store or update the FCM token
    const user = await prisma.user.upsert({
      where: { fcmToken: token },
      update: { fcmToken: token },
      create: { fcmToken: token },
    });
 
    console.log("Token saved to database:", user.id);
 
    return NextResponse.json({
      success: true,
      message: "Token saved successfully",
      userId: user.id,
    });
  } catch (error) {
    console.error("Subscribe error:", error);
    return NextResponse.json(
      { error: "Failed to save token" },
      { status: 500 }
    );
  }
}

Step 30: Update Main Page to Save Token (Optional)

In app/page.tsx, update the handleEnableNotifications function:

const handleEnableNotifications = async () => {
  const token = await requestNotificationPermission();
  if (token) {
    setFcmToken(token);
    localStorage.setItem("fcmToken", token);
 
    // Save to database (optional)
    await fetch("/api/subscribe", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token }),
    });
  }
};

PART 10: Deployment

Step 31: Deploy to Vercel

  1. Push to Git:
git init
git add .
git commit -m "Initial commit"
git remote add origin your-repo-url
git push -u origin main
  1. Deploy on Vercel:

    • Go to https://vercel.com
    • Click "Add New" → "Project"
    • Import your repository
    • Add environment variables:
      • NEXT_PUBLIC_FIREBASE_* (all Firebase config)
      • NEXT_PUBLIC_VAPID_KEY
      • (Optional) DATABASE_URL if using Prisma
  2. Upload Firebase Service Account:

Convert serviceAccountKey.json to environment variable:

FIREBASE_SERVICE_ACCOUNT='{"type":"service_account","project_id":"..."}'
  1. Update firebaseAdmin.ts for production:
import * as admin from "firebase-admin";
 
if (!admin.apps.length) {
  const serviceAccount = process.env.FIREBASE_SERVICE_ACCOUNT
    ? JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT)
    : require("../serviceAccountKey.json");
 
  admin.initializeApp({
    credential: admin.credential.cert(serviceAccount),
  });
}
 
export const messaging = admin.messaging();
export default admin;
  1. Update Service Worker:

    • Update public/firebase-messaging-sw.js with actual Firebase config
  2. Deploy!


Troubleshooting

Notifications Not Working:

  • Ensure you're using HTTPS
  • Check browser console for errors
  • Verify Firebase config is correct
  • Check notification permissions in browser settings

Service Worker Issues:

  • Clear browser cache
  • Check DevTools → Application → Service Workers
  • Ensure firebase-messaging-sw.js is in public folder

PWA Not Installing:

  • Verify manifest.ts is correct
  • Check all icons exist in public folder
  • Use DevTools → Application → Manifest to debug

Conclusion

Congratulations! You've built a PWA with:

  • ✅ Next.js with TypeScript
  • ✅ Shopping cart functionality
  • ✅ Firebase push notifications
  • ✅ PWA installability
  • ✅ Optional: Database integration

Happy coding! 🚀


Additional Resources