Web Developmentpwareactnextjs

Building PWA React Apps for Maximum Engagement

A practical guide to building production-ready Progressive Web Apps with React and Next.js, Service Workers, Web App Manifests, offline support, and push notifications.

Codolve Team8 min read
Share
Building PWA React Apps for Maximum Engagement

Progressive Web Apps sit at the intersection of web reach and native app capability. When done well, a PWA is installable, works offline, loads instantly on repeat visits, and can send push notifications, all from a web browser, with no App Store approval process. Here's how to build one properly with React and Next.js.

What Makes an App a PWA

Three requirements must all be met for a browser to offer the "Install" prompt:

  1. HTTPS, required for Service Workers to register
  2. Web App Manifest, describes your app's identity (name, icons, display mode, colours)
  3. Service Worker, intercepts network requests for offline support and caching

All three must be present. Missing any one of them and the browser won't offer installation.

Setting Up the Web App Manifest in Next.js

Next.js makes the manifest simple, create app/manifest.ts:

// app/manifest.ts
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "My Application",
    short_name: "MyApp",
    description: "A fast, reliable Progressive Web App built with Next.js",
    start_url: "/",
    display: "standalone",          // Hides browser UI, feels native
    orientation: "portrait",
    background_color: "#ffffff",    // Splash screen background
    theme_color: "#4f46e5",         // Browser toolbar colour on Android
    icons: [
      {
        src: "/icons/icon-192.png",
        sizes: "192x192",
        type: "image/png",
        purpose: "any",
      },
      {
        src: "/icons/icon-512.png",
        sizes: "512x512",
        type: "image/png",
        purpose: "maskable", // Adaptive icon, fills the shape on Android
      },
    ],
    screenshots: [
      {
        src: "/screenshots/desktop.png",
        sizes: "1280x720",
        type: "image/png",
        form_factor: "wide",
        label: "Dashboard view",
      },
    ],
    categories: ["productivity", "utilities"],
    lang: "en",
  };
}

The maskable icon purpose is important for Android, it allows the OS to apply its own shape (circle, squircle, etc.) to your icon. Generate maskable icons at maskable.app.

Service Workers: The Engine of Offline Support

A Service Worker is a JavaScript file that runs in the background, separate from the main browser thread. It intercepts network requests, serves cached responses, and enables background sync and push notifications.

Setting Up Workbox in Next.js

Workbox (from Google) provides pre-built caching strategies. Use next-pwa for easy integration:

npm install next-pwa
// next.config.ts
import withPWA from "next-pwa";

const nextConfig = withPWA({
  dest: "public",
  disable: process.env.NODE_ENV === "development", // Don't interfere with HMR in dev
  register: true,
  skipWaiting: true,
  runtimeCaching: [
    {
      urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
      handler: "CacheFirst",
      options: {
        cacheName: "google-fonts",
        expiration: {
          maxEntries: 10,
          maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
        },
      },
    },
    {
      urlPattern: /\/_next\/static\/.*/i,
      handler: "CacheFirst",
      options: {
        cacheName: "static-assets",
        expiration: { maxAgeSeconds: 60 * 60 * 24 * 30 }, // 30 days
      },
    },
    {
      urlPattern: /\/api\/.*/i,
      handler: "NetworkFirst",
      options: {
        cacheName: "api-responses",
        expiration: { maxAgeSeconds: 60 * 5 }, // Cache for 5 minutes as fallback
        networkTimeoutSeconds: 3,
      },
    },
  ],
})({});

export default nextConfig;

Caching Strategies Explained

Choosing the right caching strategy per resource type is critical:

Strategy When to Use Examples
CacheFirst Rarely changes, freshness less important Fonts, library scripts, images
NetworkFirst Needs to be fresh, offline fallback acceptable API responses, user data
StaleWhileRevalidate Show cached, update in background Blog posts, product listings
NetworkOnly Must be real-time, no caching acceptable Payment endpoints, secure data
CacheOnly Fully offline, never fetch Pre-cached app shell

Custom Service Worker for Advanced Patterns

For push notifications and background sync, extend the default Workbox setup:

// public/sw.js (custom service worker additions)
import { precacheAndRoute } from "workbox-precaching";

// Precache the app shell
precacheAndRoute(self.__WB_MANIFEST);

// Push notifications
self.addEventListener("push", (event) => {
  const data = event.data?.json() ?? {};

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: "/icons/icon-192.png",
      badge: "/icons/badge-72.png",
      tag: data.tag,
      data: { url: data.url },
      actions: [
        { action: "open", title: "Open" },
        { action: "dismiss", title: "Dismiss" },
      ],
    })
  );
});

// Handle notification click
self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  if (event.action === "open" || !event.action) {
    event.waitUntil(clients.openWindow(event.notification.data.url));
  }
});

// Background sync for offline form submissions
self.addEventListener("sync", (event) => {
  if (event.tag === "submit-form") {
    event.waitUntil(processPendingSubmissions());
  }
});

Offline User Experience

An offline-capable app is only as good as its offline UX. Users need to know what works offline and what doesn't.

Offline Fallback Page

// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center text-center px-4">
      <div className="text-6xl mb-6">📡</div>
      <h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
        You are offline
      </h1>
      <p className="text-gray-500 dark:text-gray-400 max-w-sm mb-8">
        Some features are unavailable without an internet connection. Previously
        loaded pages are still accessible.
      </p>
      <button
        onClick={() => window.location.reload()}
        className="bg-indigo-600 text-white px-6 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors"
      >
        Try again
      </button>
    </div>
  );
}

Detecting and Responding to Network Status

"use client";
import { useState, useEffect } from "react";

export function NetworkStatus() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const updateStatus = () => setIsOnline(navigator.onLine);
    window.addEventListener("online", updateStatus);
    window.addEventListener("offline", updateStatus);
    setIsOnline(navigator.onLine);
    return () => {
      window.removeEventListener("online", updateStatus);
      window.removeEventListener("offline", updateStatus);
    };
  }, []);

  if (isOnline) return null;

  return (
    <div className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-gray-900 text-white px-4 py-2 rounded-full text-sm font-medium z-50">
      No internet connection
    </div>
  );
}

Push Notifications

Push notifications are the most impactful PWA feature for re-engagement. Implementation requires a push service (we recommend web-push + VAPID keys for self-hosted, or a service like OneSignal).

Requesting Permission and Subscribing

// lib/push-notifications.ts
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;

export async function subscribeToPushNotifications() {
  if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
    throw new Error("Push notifications not supported");
  }

  const permission = await Notification.requestPermission();
  if (permission !== "granted") {
    throw new Error("Push notification permission denied");
  }

  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true, // Required, all pushes must show a notification
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

  // Save subscription to your server
  await fetch("/api/push/subscribe", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

Installation Prompt: Don't Let It Surprise Users

The browser's default install prompt appears at the worst moments. Intercept it and show it when it makes sense:

"use client";
import { useState, useEffect } from "react";

export function InstallPrompt() {
  const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
  const [showBanner, setShowBanner] = useState(false);

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault(); // Stop the default prompt
      setDeferredPrompt(e);
      setShowBanner(true);
    };

    window.addEventListener("beforeinstallprompt", handler);
    return () => window.removeEventListener("beforeinstallprompt", handler);
  }, []);

  async function handleInstall() {
    if (!deferredPrompt) return;
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    setDeferredPrompt(null);
    setShowBanner(false);
    // Track outcome in analytics
  }

  if (!showBanner) return null;

  return (
    <div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-800 p-5 z-50">
      <h3 className="font-bold text-gray-900 dark:text-white mb-1">Install App</h3>
      <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
        Install for a faster experience with offline access.
      </p>
      <div className="flex gap-2">
        <button onClick={handleInstall} className="flex-1 bg-indigo-600 text-white rounded-lg py-2 text-sm font-semibold">
          Install
        </button>
        <button onClick={() => setShowBanner(false)} className="px-3 py-2 text-gray-400 hover:text-gray-600 text-sm">
          Not now
        </button>
      </div>
    </div>
  );
}

For a production web application that needs PWA capabilities, these patterns form the complete implementation foundation.

Frequently Asked Questions

Do PWAs work on iOS?

Yes, with limitations. Safari supports Service Workers, Web App Manifests, and basic offline support. Push notifications on iOS require iOS 16.4+ and only work when the app is installed to the home screen. The install experience on iOS is manual (Share → Add to Home Screen), the native install prompt isn't available.

How do I test my PWA in development?

Use next build && next start to test in production mode (Service Workers don't register in development). In Chrome DevTools, the Application panel has dedicated sections for manifests, service workers, and cache storage. Use Lighthouse's "Progressive Web App" audit for a full checklist.

Can a PWA replace a native mobile app?

For many use cases, yes, especially internal tools, dashboards, and content apps. Camera access, GPS, Bluetooth, and deep OS integration still work better in native apps. For consumer-facing apps that need the App Store for discoverability, a PWA plus a native app shell (Capacitor, React Native) is a common hybrid approach.

How do I handle Service Worker updates?

Use skipWaiting and clientsClaim to activate updates immediately, or prompt users to refresh: listen for the waiting state on the SW registration and show a "New version available, tap to update" banner.

Does using a PWA affect SEO?

Not negatively. PWAs are indexed normally by Googlebot. The Service Worker intercepts requests for human users, not crawlers. Good PWA practices (performance, offline support) often improve Core Web Vitals, which positively affects SEO.

Tags

#pwa#react#nextjs#service worker#offline#mobile#web app manifest
Share
userImage1userImage2userImage3

Build impactful digital products

Ready to Start Your Next Big Project ?

Contact Us