Skip to main content
Back to Blog
Tutorials
3 min read
December 12, 2024

How to Implement Progressive Web App Features in Next.js

Turn your Next.js app into a Progressive Web App with offline support, install prompts, push notifications, and background sync.

Ryel Banfield

Founder & Lead Developer

A PWA gives your website app-like capabilities: offline access, home screen installation, and push notifications.

Web App Manifest

// public/manifest.json
{
  "name": "My Business App",
  "short_name": "MyApp",
  "description": "Professional business solutions",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#0f172a",
  "orientation": "portrait-primary",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    {
      "src": "/icons/icon-maskable-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    }
  ]
}

Add Manifest to Layout

// app/layout.tsx
export const metadata = {
  manifest: "/manifest.json",
  themeColor: "#0f172a",
  appleWebApp: {
    capable: true,
    statusBarStyle: "default",
    title: "MyApp",
  },
};

Service Worker

// public/sw.js
const CACHE_NAME = "v1";
const STATIC_ASSETS = ["/", "/offline"];

// Install: pre-cache essential assets
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches
      .open(CACHE_NAME)
      .then((cache) => cache.addAll(STATIC_ASSETS))
      .then(() => self.skipWaiting())
  );
});

// Activate: clean old caches
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((key) => caches.delete(key))
      )
    ).then(() => self.clients.claim())
  );
});

// Fetch: network-first with cache fallback
self.addEventListener("fetch", (event) => {
  const { request } = event;

  // Skip non-GET and chrome-extension requests
  if (request.method !== "GET") return;
  if (request.url.startsWith("chrome-extension://")) return;

  // HTML pages: network-first
  if (request.headers.get("accept")?.includes("text/html")) {
    event.respondWith(
      fetch(request)
        .then((response) => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
          return response;
        })
        .catch(() =>
          caches.match(request).then((cached) => cached || caches.match("/offline"))
        )
    );
    return;
  }

  // Static assets: cache-first
  if (
    request.url.match(/\.(js|css|png|jpg|jpeg|webp|svg|woff2?)$/)
  ) {
    event.respondWith(
      caches.match(request).then(
        (cached) =>
          cached ||
          fetch(request).then((response) => {
            const clone = response.clone();
            caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
            return response;
          })
      )
    );
    return;
  }

  // API calls: network-only
  event.respondWith(fetch(request));
});

// Background sync
self.addEventListener("sync", (event) => {
  if (event.tag === "sync-forms") {
    event.waitUntil(syncPendingForms());
  }
});

async function syncPendingForms() {
  const db = await openDB();
  const pending = await db.getAll("pending-submissions");

  for (const submission of pending) {
    try {
      await fetch(submission.url, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(submission.data),
      });
      await db.delete("pending-submissions", submission.id);
    } catch {
      // Will retry on next sync event
    }
  }
}

Register Service Worker

// components/ServiceWorkerRegistration.tsx
"use client";

import { useEffect } from "react";

export function ServiceWorkerRegistration() {
  useEffect(() => {
    if ("serviceWorker" in navigator) {
      navigator.serviceWorker
        .register("/sw.js")
        .then((registration) => {
          registration.addEventListener("updatefound", () => {
            const newWorker = registration.installing;
            newWorker?.addEventListener("statechange", () => {
              if (
                newWorker.state === "activated" &&
                navigator.serviceWorker.controller
              ) {
                // New version available
                dispatchEvent(new CustomEvent("sw-update-available"));
              }
            });
          });
        })
        .catch(console.error);
    }
  }, []);

  return null;
}

Install Prompt

"use client";

import { useEffect, useState } from "react";

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}

export function InstallPrompt() {
  const [deferredPrompt, setDeferredPrompt] =
    useState<BeforeInstallPromptEvent | null>(null);
  const [isInstalled, setIsInstalled] = useState(false);

  useEffect(() => {
    // Check if already installed
    if (window.matchMedia("(display-mode: standalone)").matches) {
      setIsInstalled(true);
      return;
    }

    const handler = (e: Event) => {
      e.preventDefault();
      setDeferredPrompt(e as BeforeInstallPromptEvent);
    };

    window.addEventListener("beforeinstallprompt", handler);
    window.addEventListener("appinstalled", () => setIsInstalled(true));

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

  const handleInstall = async () => {
    if (!deferredPrompt) return;
    await deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    if (outcome === "accepted") setIsInstalled(true);
    setDeferredPrompt(null);
  };

  if (isInstalled || !deferredPrompt) return null;

  return (
    <div className="fixed bottom-4 right-4 bg-card border rounded-lg shadow-lg p-4 max-w-sm z-50">
      <p className="text-sm font-medium">Install our app</p>
      <p className="text-xs text-muted-foreground mt-1">
        Get quick access from your home screen with offline support.
      </p>
      <div className="flex gap-2 mt-3">
        <button
          onClick={handleInstall}
          className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded"
        >
          Install
        </button>
        <button
          onClick={() => setDeferredPrompt(null)}
          className="text-xs text-muted-foreground px-3 py-1.5"
        >
          Not now
        </button>
      </div>
    </div>
  );
}

Offline Page

// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div className="min-h-screen flex items-center justify-center p-4">
      <div className="text-center max-w-md">
        <div className="text-6xl mb-4">
          <svg className="w-16 h-16 mx-auto text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.364 5.636a9 9 0 010 12.728M5.636 18.364a9 9 0 010-12.728m12.728 0L5.636 18.364" />
          </svg>
        </div>
        <h1 className="text-2xl font-bold mb-2">You are offline</h1>
        <p className="text-muted-foreground mb-6">
          Check your internet connection and try again.
        </p>
        <button
          onClick={() => window.location.reload()}
          className="bg-primary text-primary-foreground px-4 py-2 rounded"
        >
          Retry
        </button>
      </div>
    </div>
  );
}

Update Banner

"use client";

import { useEffect, useState } from "react";

export function UpdateBanner() {
  const [showUpdate, setShowUpdate] = useState(false);

  useEffect(() => {
    const handler = () => setShowUpdate(true);
    window.addEventListener("sw-update-available", handler);
    return () => window.removeEventListener("sw-update-available", handler);
  }, []);

  if (!showUpdate) return null;

  return (
    <div className="fixed top-0 left-0 right-0 bg-primary text-primary-foreground py-2 px-4 text-center text-sm z-50">
      A new version is available.{" "}
      <button
        onClick={() => window.location.reload()}
        className="underline font-medium"
      >
        Refresh
      </button>
    </div>
  );
}

Need a Fast, Installable Web App?

We build progressive web apps that work offline and feel native. Contact us to learn more.

PWAservice workerofflineNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles