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-appDuring 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-appStep 2: Install Required Dependencies
Install Firebase SDK for push notifications:
pnpm add firebase firebase-adminPART 2: Setting Up Firebase Cloud Messaging
Step 3: Create a Firebase Project
- Go to the Firebase Console: https://console.firebase.google.com/
- Click "Add project" or "Create a project"
- Enter your project name (e.g., "My PWA App")
- Choose whether to enable Google Analytics (optional)
- Click "Create project" and wait for setup to complete
Step 4: Register Your Web App in Firebase
- In your Firebase project dashboard, click the web icon (
</>) to add a web app - Enter an app nickname (e.g., "My PWA Web App")
- Click "Register app"
- Copy your Firebase configuration object - you'll need this later
Step 5: Enable Cloud Messaging and Get VAPID Key
- In the Firebase Console, go to Project Settings (gear icon)
- Click on the "Cloud Messaging" tab
- Under "Web Push certificates", click "Generate key pair"
- Copy the generated VAPID key - save this securely
Step 6: Download Service Account Key
- In Project Settings, go to the "Service Accounts" tab
- Click "Generate new private key"
- Click "Generate key" in the confirmation dialog
- A JSON file will be downloaded - save this as
serviceAccountKey.jsonin 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_hereReplace 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:
- Go to https://realfavicongenerator.net/ or https://www.pwabuilder.com/imageGenerator
- Upload your app logo/icon (minimum 512x512px)
- Generate the icon pack
- Download and extract the icons
- Place
icon-192x192.pngandicon-512x512.pngin yourpublic/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-httpsOr 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
-
Enable Notifications:
- Open https://localhost:3000
- Click "Enable Notifications"
- Accept the browser permission prompt
-
Add Products to Cart:
- Click "Add to Cart" on any product
- See the cart counter update
-
Place an Order:
- Review items in your cart
- Click "Place Order 🛒"
- You should receive a push notification!
-
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_modelStep 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
- Push to Git:
git init
git add .
git commit -m "Initial commit"
git remote add origin your-repo-url
git push -u origin main-
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_URLif using Prisma
-
Upload Firebase Service Account:
Convert serviceAccountKey.json to environment variable:
FIREBASE_SERVICE_ACCOUNT='{"type":"service_account","project_id":"..."}'- 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;-
Update Service Worker:
- Update
public/firebase-messaging-sw.jswith actual Firebase config
- Update
-
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.jsis 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
- Next.js: https://nextjs.org/docs
- Firebase Cloud Messaging: https://firebase.google.com/docs/cloud-messaging
- PWA Guide: https://web.dev/progressive-web-apps/
- Prisma Setup: https://jb.desishub.com/blog/nextjs-with-prisma-7-and-postgres

