January 19, 2026


Source code: github.com/yourusername/pwa-nextjs-firebase
Don't forget to star the repo if you like it.
Demo video here
If you are only interested in the implementation, you can skip to the technical part
I've always been fascinated by Progressive Web Apps - the idea that you can build a web application that feels like a native app, works offline, and can send push notifications is just amazing. So, I decided to build a complete PWA with Next.js 14+ and Firebase Cloud Messaging to learn the ins and outs of this technology.
The goal was simple: create a shopping cart application that sends push notifications when users place orders. But as I soon discovered, building a production-ready PWA involves much more than just adding a manifest file and a service worker.
...and that's where the journey began.
Day 1: January 10, 2026
I started by setting up a fresh Next.js project with TypeScript. The initial setup was straightforward - just running pnpm create next-app@latest and choosing the right options. I decided to go with:
Everything seemed smooth until I started reading about Firebase Cloud Messaging. The documentation was extensive, but I wasn't sure where to start. Should I set up Firebase first, or build the app structure first?
I decided to start with Firebase setup. I created a new Firebase project, registered my web app, and got my configuration keys. Then came the first challenge: VAPID keys. What are they? Why do I need them?
After some research, I learned that VAPID (Voluntary Application Server Identification) keys are used to identify your application server to Firebase. You generate them in Firebase console under Cloud Messaging → Web Push certificates.
Day 2: January 11, 2026
Now came the tricky part: setting up Firebase in a Next.js environment. Next.js has both client-side and server-side rendering, which means I needed to be careful about where I initialize Firebase.
I created a lib/firebase.ts file for client-side Firebase initialization. This file would handle:
The key was to check if we're in the browser before initializing Firebase Messaging:
if (typeof window !== "undefined") {
app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
messaging = getMessaging(app);
}This check prevents Firebase from trying to initialize on the server, which would cause errors.
Day 3: January 12, 2026
Service workers! This was the part that confused me the most. I knew PWAs needed service workers, but how do they work with Firebase Cloud Messaging?
I created public/firebase-messaging-sw.js - the service worker that would handle background notifications. The file had to:
importScripts()One critical issue I encountered: Service workers can't access environment variables! This meant I had to hardcode my Firebase configuration in the service worker file. Not ideal for security, but necessary for service workers.
firebase.initializeApp({
apiKey: "YOUR_ACTUAL_API_KEY",
authDomain: "your-project.firebaseapp.com",
// ... rest of config
});Note to self: Never commit sensitive keys to GitHub. Use Firebase App Check in production for better security.
Day 4: January 13, 2026
After setting up Firebase, I needed to create the backend API to send notifications. This required Firebase Admin SDK, which runs on the server side.
I downloaded the service account key from Firebase (a JSON file with admin credentials) and created lib/firebaseAdmin.ts:
const serviceAccount = require("../serviceAccountKey.json");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});Then I created an API route at app/api/send-notification/route.ts to send notifications when users place orders. This route would:
One gotcha: FCM tokens can expire! I had to add error handling for messaging/registration-token-not-registered errors and let users know they need to re-enable notifications.
Day 5: January 14, 2026
Now for the PWA part. I needed to create a manifest file to make the app installable. Next.js 14+ has a great feature: app/manifest.ts - a TypeScript file that generates the manifest.
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",
icons: [...]
};
}I also needed to generate PWA icons. I used realfavicongenerator.net to create icons in various sizes (192x192, 512x512, etc.).
One thing I learned: the display: "standalone" property makes the app look like a native app when installed, hiding the browser UI.
Day 6: January 15, 2026
Building the shopping cart was the fun part. I decided to use React Context API for state management. Created a CartContext.tsx that would:
The cart UI needed to:
I also created product cards with "Add to Cart" buttons. Nothing fancy, just clean UI with Tailwind CSS.
Day 7: January 16, 2026
The most confusing part: foreground vs background notifications. I spent hours trying to understand why notifications worked differently when the app was open vs closed.
Here's what I learned:
Foreground (app is open):
onMessageListener() in your React codenew Notification()Background (app is closed/minimized):
firebase-messaging-sw.jsBoth are needed! Without foreground handling, users won't see notifications while browsing your app. Without background handling, users won't see notifications when the app is closed.
I implemented both:
// Foreground listener in React
onMessageListener().then((payload) => {
showSystemNotification(payload.notification.title, payload.notification.body);
});
// Background handler in service worker
messaging.onBackgroundMessage((payload) => {
self.registration.showNotification(title, options);
});Day 8: January 17, 2026
Testing time! PWAs require HTTPS to work. For local testing, I used Next.js's experimental HTTPS flag:
pnpm dev --experimental-httpsThis runs the dev server at https://localhost:3000 with a self-signed certificate.
I tested the flow:
Everything worked! I was ecstatic! 🎉
Day 9: January 18, 2026
I wanted to make the app production-ready, so I added:
I also added optional Prisma integration to store FCM tokens in a database. This allows you to:
The Prisma schema was simple:
model User {
id String @id @default(cuid())
fcmToken String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}With an API route to save tokens:
await prisma.user.upsert({
where: { fcmToken: token },
update: { fcmToken: token },
create: { fcmToken: token },
});Day 10: January 19, 2026
Final step: deploying to Vercel! The process was mostly smooth, but a few things to note:
Environment variables - Add all NEXT_PUBLIC_* variables in Vercel dashboard
Service account - Convert serviceAccountKey.json to an environment variable:
FIREBASE_SERVICE_ACCOUNT='{"type":"service_account",...}'Update firebaseAdmin.ts - Use environment variable in production:
const serviceAccount = process.env.FIREBASE_SERVICE_ACCOUNT
? JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT)
: require("../serviceAccountKey.json");Update service worker - Replace Firebase config with actual values (can't use env vars here)
After deployment, I tested the app on:
PWAs are powerful but complex:
Firebase Cloud Messaging is robust:
Next.js 14+ makes PWA development easier:
What I would do differently:
Here's the demo video showing the complete flow:
The application has three main components:
Client-side Firebase (lib/firebase.ts)
Service Worker (public/firebase-messaging-sw.js)
Backend API (app/api/send-notification/route.ts)
User places order
|
v
Frontend sends FCM token + cart items to API
|
v
API validates data and formats notification
|
v
API sends notification via Firebase Admin SDK
|
v
Firebase delivers notification to device
|
|--- [App is OPEN (foreground)]
| |
| v
| onMessageListener() receives it
| |
| v
| React code shows notification manually
|
|--- [App is CLOSED (background)]
|
v
Service worker receives it
|
v
Service worker shows notification automatically
|
v
User clicks notification
|
v
App opens to /orders pageRequesting notification permission:
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,
});
return token;
}
return null;
} catch (error) {
console.error("Error getting FCM token:", error);
return null;
}
};Sending notification from backend:
const message = {
notification: {
title: "🎉 Order Confirmed!",
body: `Your order has been placed. Total: $${total.toFixed(2)}`,
},
data: {
url: "/orders",
total: total.toString(),
},
token,
};
const response = await messaging.send(message);Handling notification clicks:
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url || "/orders";
event.waitUntil(
clients.matchAll({ type: "window" }).then((windowClients) => {
// If app is already open, focus it and navigate
for (const client of windowClients) {
if (client.url.includes(self.registration.scope)) {
client.navigate(urlToOpen);
return client.focus();
}
}
// Otherwise, open a new window
return clients.openWindow(urlToOpen);
})
);
});The cart uses React Context with localStorage persistence:
const CartContext = createContext<CartContextType | undefined>(undefined);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [cart, setCart] = useState<CartItem[]>([]);
// Load from localStorage on mount
useEffect(() => {
const savedCart = localStorage.getItem('cart');
if (savedCart) setCart(JSON.parse(savedCart));
}, []);
// Save to localStorage on change
useEffect(() => {
localStorage.setItem('cart', JSON.stringify(cart));
}, [cart]);
// ... cart methods ...
}The manifest is generated dynamically using Next.js:
export default function manifest(): MetadataRoute.Manifest {
return {
name: "My PWA Shopping App",
short_name: "PWA Shop",
start_url: "/",
display: "standalone", // Runs like native app
icons: [
{
src: "/icon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "any maskable", // Works for both standard and maskable icons
},
],
};
}Important security headers in next.config.mjs:
{
source: "/firebase-messaging-sw.js",
headers: [
{
key: "Service-Worker-Allowed",
value: "/", // Allows service worker to control entire site
},
{
key: "Cache-Control",
value: "no-cache, no-store, must-revalidate", // Always get fresh service worker
},
],
}npm install -g pnpm)git clone https://github.com/yourusername/pwa-nextjs-firebase
cd pwa-nextjs-firebasepnpm installSet up Firebase:
Create .env.local:
NEXT_PUBLIC_FIREBASE_API_KEY=your_api_key
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
NEXT_PUBLIC_VAPID_KEY=your_vapid_keyUpdate service worker:
Edit public/firebase-messaging-sw.js with your Firebase config.
Run development server:
pnpm dev --experimental-httpsTest the app:
https://localhost:3000To store FCM tokens in a database:
pnpm add prisma @prisma/client
pnpm dlx prisma initschema.prisma:model User {
id String @id @default(cuid())
fcmToken String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}pnpm dlx prisma migrate dev --name add_user_modelDATABASE_URL="postgresql://user:password@localhost:5432/mydb"Deploy to Vercel:
# Push to GitHub
git add .
git commit -m "Initial commit"
git push
# Deploy on Vercel
# 1. Import repository
# 2. Add environment variables
# 3. Deploy!For production, convert service account key to environment variable:
FIREBASE_SERVICE_ACCOUNT='{"type":"service_account","project_id":"..."}'Building a Progressive Web App with Next.js and Firebase Cloud Messaging was an incredible learning experience. The combination of modern web technologies creates applications that feel native while remaining accessible via a URL.
Key takeaways:
The final product is a fully functional shopping app that:
I hope this journey helps you build your own PWA! Don't forget to star the repo if you found it useful.
If you have any questions, feel free to reach out on Twitter/X.
Until next time, happy building! 🚀