See all blogs

January 19, 2026

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

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

Source code: github.com/yourusername/pwa-nextjs-firebase

Don't forget to star the repo if you like it.

Demo video here

backstory

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.

emotional rollercoaster

rabbit hole: part 1

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:

  • TypeScript for type safety
  • Tailwind CSS for styling
  • App Router (the new Next.js paradigm)
  • No src/ directory (keep it simple)

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.

rabbit hole: part 2

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:

  • Initializing the Firebase app
  • Requesting notification permissions
  • Getting FCM tokens
  • Listening for foreground messages

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.

rabbit hole: part 3

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:

  1. Import Firebase scripts using importScripts()
  2. Initialize Firebase (yes, again, but in the service worker context)
  3. Handle background messages
  4. Show notifications with custom actions

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.

rabbit hole: part 4

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:

  1. Receive the FCM token and cart items
  2. Calculate the total
  3. Format the notification message
  4. Send via Firebase Admin SDK

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.

rabbit hole: part 5

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.

rabbit hole: part 6

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:

  • Store cart items in state
  • Persist cart to localStorage
  • Provide methods to add, remove, update items
  • Calculate totals

The cart UI needed to:

  • Display cart items with quantity controls
  • Show total price
  • Have a "Place Order" button that triggers the notification

I also created product cards with "Add to Cart" buttons. Nothing fancy, just clean UI with Tailwind CSS.

rabbit hole: part 7

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):

  • Notification goes to onMessageListener() in your React code
  • You must manually show a notification using new Notification()
  • If you don't handle this, users see nothing!

Background (app is closed/minimized):

  • Notification goes to the service worker
  • Service worker automatically shows it
  • You configure the notification in firebase-messaging-sw.js

Both 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);
});

moment of truth

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-https

This runs the dev server at https://localhost:3000 with a self-signed certificate.

I tested the flow:

  1. ✅ Enable notifications → Browser asks for permission
  2. ✅ Add products to cart → Cart updates correctly
  3. ✅ Place order → Notification appears!
  4. ✅ Minimize browser → Notification still works!
  5. ✅ Click notification → Opens app and navigates to orders page

Everything worked! I was ecstatic! 🎉

further improvements

Day 9: January 18, 2026

I wanted to make the app production-ready, so I added:

  1. Better error handling - Handle expired tokens, network errors, etc.
  2. Loading states - Show "Placing Order..." while sending notification
  3. Toast notifications - Show success/error messages in the UI
  4. Responsive design - Made it work great on mobile and desktop
  5. Install button - Added a header showing notification status

I also added optional Prisma integration to store FCM tokens in a database. This allows you to:

  • Send notifications to all users
  • Send targeted notifications
  • Track notification history

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 },
});

deployment

Day 10: January 19, 2026

Final step: deploying to Vercel! The process was mostly smooth, but a few things to note:

  1. Environment variables - Add all NEXT_PUBLIC_* variables in Vercel dashboard

  2. Service account - Convert serviceAccountKey.json to an environment variable:

    FIREBASE_SERVICE_ACCOUNT='{"type":"service_account",...}'
  3. 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");
  4. Update service worker - Replace Firebase config with actual values (can't use env vars here)

After deployment, I tested the app on:

  • ✅ Desktop Chrome - Installed as PWA
  • ✅ Mobile Chrome - Added to home screen
  • ✅ iPhone Safari - Added to home screen
  • ✅ All received push notifications perfectly!

lessons learned

PWAs are powerful but complex:

  • Service workers run in a separate context from your app
  • You need to handle both foreground and background notifications
  • HTTPS is mandatory, even for local development
  • Testing on real devices is crucial

Firebase Cloud Messaging is robust:

  • Handles token management automatically
  • Works across all platforms
  • Provides detailed error messages
  • Admin SDK makes server-side sending easy

Next.js 14+ makes PWA development easier:

  • Built-in manifest.ts support
  • TypeScript for type safety
  • API routes for backend logic
  • Great developer experience

What I would do differently:

  • Start with a simpler example before building the shopping cart
  • Read more about service worker lifecycle before diving in
  • Test on mobile devices earlier in the process
  • Use Firebase App Check from the start for better security

demo

Here's the demo video showing the complete flow:

technical details

how it works

Architecture Overview

The application has three main components:

  1. Client-side Firebase (lib/firebase.ts)

    • Requests notification permissions
    • Gets FCM token from Firebase
    • Listens for foreground messages
    • Shows in-app notifications
  2. Service Worker (public/firebase-messaging-sw.js)

    • Handles background notifications
    • Shows system notifications when app is closed
    • Handles notification clicks
    • Navigates to specific pages when clicked
  3. Backend API (app/api/send-notification/route.ts)

    • Uses Firebase Admin SDK
    • Sends notifications to specific tokens
    • Handles errors (expired tokens, etc.)
    • Returns success/failure status

Notification Flow

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 page

Key Code Snippets

Requesting 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);
    })
  );
});

Cart State Management

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 ...
}

PWA Manifest

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
      },
    ],
  };
}

Security Headers

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
    },
  ],
}

setup

Prerequisites

  • Node.js 18+ installed
  • pnpm package manager (npm install -g pnpm)
  • Firebase account (free tier works)
  • Basic knowledge of React, Next.js, TypeScript

Quick Start

  1. Clone the repository:
git clone https://github.com/yourusername/pwa-nextjs-firebase
cd pwa-nextjs-firebase
  1. Install dependencies:
pnpm install
  1. Set up Firebase:

    • Create a Firebase project at console.firebase.google.com
    • Register a web app
    • Enable Cloud Messaging
    • Generate VAPID key
    • Download service account key
  2. 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_key
  1. Update service worker:

    Edit public/firebase-messaging-sw.js with your Firebase config.

  2. Run development server:

pnpm dev --experimental-https
  1. Test the app:

    • Open https://localhost:3000
    • Accept the self-signed certificate warning
    • Enable notifications
    • Add products to cart
    • Place an order
    • See the notification appear!

Optional: Database Setup

To store FCM tokens in a database:

  1. Install Prisma:
pnpm add prisma @prisma/client
pnpm dlx prisma init
  1. Add User model to schema.prisma:
model User {
  id        String   @id @default(cuid())
  fcmToken  String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
  1. Run migration:
pnpm dlx prisma migrate dev --name add_user_model
  1. Update environment variables:
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

Deployment

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":"..."}'

conclusion

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:

  • ✅ PWAs bridge the gap between web and native apps
  • ✅ Firebase Cloud Messaging makes push notifications simple
  • ✅ Next.js 14+ provides great PWA development experience
  • ✅ TypeScript ensures type safety throughout the stack
  • ✅ Service workers are powerful but require careful handling

The final product is a fully functional shopping app that:

  • Installs like a native app
  • Works offline (with proper caching)
  • Sends push notifications
  • Provides excellent user experience
  • Runs on any device with a modern browser

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! 🚀

additional resources

  • Next.js Documentation
  • Firebase Cloud Messaging Docs
  • MDN PWA Guide
  • Web.dev PWA
  • Service Worker API

See all blogs