Build a Progressive Web App (PWA) with Next.js App Router

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Progressive Web Apps (PWA) combine the best of web and native applications: home screen installation, offline functionality, push notifications, and optimal performance. In this tutorial, you will transform a Next.js App Router application into a complete PWA, step by step.

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed on your machine
  • Basic knowledge of Next.js App Router and TypeScript
  • A code editor (VS Code recommended)
  • A PWA-compatible browser (Chrome, Edge, Firefox)

What You Will Build

A Next.js application that:

  • Installs like a native app on mobile and desktop
  • Works offline with intelligent caching
  • Sends push notifications to users
  • Syncs automatically when the connection returns
  • Achieves a Lighthouse PWA score of 100

Step 1: Create the Next.js Project

Start by initializing a new Next.js project with TypeScript:

npx create-next-app@latest my-pwa-app --typescript --tailwind --app --src-dir
cd my-pwa-app

Install the dependencies needed for PWA support:

npm install next-pwa @ducanh2912/next-pwa
npm install -D webpack

We use @ducanh2912/next-pwa, the actively maintained fork compatible with Next.js 14+ and App Router.

Step 2: Configure next-pwa

Update your next.config.ts file to enable PWA support:

// next.config.ts
import type { NextConfig } from "next";
import withPWAInit from "@ducanh2912/next-pwa";
 
const withPWA = withPWAInit({
  dest: "public",
  cacheOnFrontEndNav: true,
  aggressiveFrontEndNavCaching: true,
  reloadOnOnline: true,
  swcMinify: true,
  disable: process.env.NODE_ENV === "development",
  workboxOptions: {
    disableDevLogs: true,
  },
});
 
const nextConfig: NextConfig = {
  reactStrictMode: true,
};
 
export default withPWA(nextConfig);

This configuration:

  • Generates the service worker in the public/ directory
  • Enables aggressive caching for client-side navigation
  • Automatically reloads when the connection returns
  • Disables the service worker in development to avoid conflicts

Step 3: Create the Web App Manifest

The manifest file tells the browser how to display your application once installed. Create src/app/manifest.ts:

// src/app/manifest.ts
import type { MetadataRoute } from "next";
 
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "My PWA Application",
    short_name: "MyPWA",
    description:
      "A Progressive Web App built with Next.js App Router",
    start_url: "/",
    display: "standalone",
    background_color: "#0a0a0a",
    theme_color: "#3b82f6",
    orientation: "portrait-primary",
    icons: [
      {
        src: "/icons/icon-192x192.png",
        sizes: "192x192",
        type: "image/png",
        purpose: "maskable",
      },
      {
        src: "/icons/icon-384x384.png",
        sizes: "384x384",
        type: "image/png",
      },
      {
        src: "/icons/icon-512x512.png",
        sizes: "512x512",
        type: "image/png",
        purpose: "any",
      },
    ],
    screenshots: [
      {
        src: "/screenshots/desktop.png",
        sizes: "1280x720",
        type: "image/png",
        form_factor: "wide",
      },
      {
        src: "/screenshots/mobile.png",
        sizes: "390x844",
        type: "image/png",
        form_factor: "narrow",
      },
    ],
    categories: ["productivity", "utilities"],
  };
}

Icons with purpose: "maskable" are essential for Android. They allow the system to apply an adaptive mask to your icon so it blends harmoniously with other applications.

Step 4: Add PWA Metadata

Update your root layout to include the necessary metadata:

// src/app/layout.tsx
import type { Metadata, Viewport } from "next";
import "./globals.css";
 
export const metadata: Metadata = {
  title: "My PWA Application",
  description: "A PWA built with Next.js App Router",
  appleWebApp: {
    capable: true,
    statusBarStyle: "default",
    title: "MyPWA",
  },
  formatDetection: {
    telephone: false,
  },
};
 
export const viewport: Viewport = {
  themeColor: "#3b82f6",
  width: "device-width",
  initialScale: 1,
  maximumScale: 1,
  userScalable: false,
};
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
      </head>
      <body className="antialiased">{children}</body>
    </html>
  );
}

Step 5: Generate PWA Icons

Create the necessary icons. You can use a tool like pwa-asset-generator:

mkdir -p public/icons public/screenshots
npx pwa-asset-generator ./public/logo.svg ./public/icons \
  --icon-only --favicon --type png \
  --padding "10%" --background "#0a0a0a"

Or manually create the following files:

public/
├── icons/
│   ├── icon-192x192.png
│   ├── icon-384x384.png
│   └── icon-512x512.png
├── screenshots/
│   ├── desktop.png
│   └── mobile.png
└── favicon.ico

Step 6: Implement Offline Caching

Create a custom caching strategy. Update next.config.ts to include runtime caching rules:

// next.config.ts
import type { NextConfig } from "next";
import withPWAInit from "@ducanh2912/next-pwa";
 
const withPWA = withPWAInit({
  dest: "public",
  cacheOnFrontEndNav: true,
  aggressiveFrontEndNavCaching: true,
  reloadOnOnline: true,
  swcMinify: true,
  disable: process.env.NODE_ENV === "development",
  workboxOptions: {
    disableDevLogs: true,
    runtimeCaching: [
      {
        urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
        handler: "CacheFirst",
        options: {
          cacheName: "image-cache",
          expiration: {
            maxEntries: 64,
            maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
          },
        },
      },
      {
        urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
        handler: "CacheFirst",
        options: {
          cacheName: "google-fonts-cache",
          expiration: {
            maxEntries: 16,
            maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
          },
        },
      },
      {
        urlPattern: /^https:\/\/api\..*$/i,
        handler: "StaleWhileRevalidate",
        options: {
          cacheName: "api-cache",
          expiration: {
            maxEntries: 32,
            maxAgeSeconds: 60 * 60, // 1 hour
          },
        },
      },
    ],
  },
});
 
const nextConfig: NextConfig = {
  reactStrictMode: true,
};
 
export default withPWA(nextConfig);

Step 7: Create an Offline Page

When the user is offline and tries to access a page not in the cache, display a friendly error page:

// src/app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center bg-gray-950 text-white">
      <div className="text-center">
        <div className="mb-6 text-6xl">📡</div>
        <h1 className="mb-4 text-3xl font-bold">
          You are offline
        </h1>
        <p className="mb-8 text-gray-400">
          Check your internet connection and try again.
        </p>
        <button
          onClick={() => window.location.reload()}
          className="rounded-lg bg-blue-600 px-6 py-3 font-semibold
                     transition-colors hover:bg-blue-700"
        >
          Retry
        </button>
      </div>
    </div>
  );
}

The /offline page is automatically pre-cached by next-pwa. When the user loses connection, they are redirected to this page instead of seeing the browser's default error.

Step 8: Detect Connection Status

Create a custom hook to react to connection changes:

// src/hooks/useOnlineStatus.ts
"use client";
 
import { useSyncExternalStore } from "react";
 
function subscribe(callback: () => void) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}
 
function getSnapshot() {
  return navigator.onLine;
}
 
function getServerSnapshot() {
  return true; // Always online on the server
}
 
export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

Use this hook in a banner component:

// src/components/OnlineBanner.tsx
"use client";
 
import { useOnlineStatus } from "@/hooks/useOnlineStatus";
 
export function OnlineBanner() {
  const isOnline = useOnlineStatus();
 
  if (isOnline) return null;
 
  return (
    <div className="fixed top-0 left-0 right-0 z-50 bg-amber-600
                    px-4 py-2 text-center text-sm font-medium text-white">
      You are currently offline. Some features may be limited.
    </div>
  );
}

Step 9: Custom Install Button

Create an elegant install button that appears when the browser offers installation:

// src/components/InstallPrompt.tsx
"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 [isVisible, setIsVisible] = useState(false);
  const [isInstalled, setIsInstalled] = useState(false);
 
  useEffect(() => {
    if (window.matchMedia("(display-mode: standalone)").matches) {
      setIsInstalled(true);
      return;
    }
 
    const handler = (e: Event) => {
      e.preventDefault();
      setDeferredPrompt(e as BeforeInstallPromptEvent);
      setIsVisible(true);
    };
 
    window.addEventListener("beforeinstallprompt", handler);
 
    window.addEventListener("appinstalled", () => {
      setIsInstalled(true);
      setIsVisible(false);
      setDeferredPrompt(null);
    });
 
    return () => {
      window.removeEventListener("beforeinstallprompt", handler);
    };
  }, []);
 
  const handleInstall = async () => {
    if (!deferredPrompt) return;
 
    await deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
 
    if (outcome === "accepted") {
      setIsVisible(false);
    }
    setDeferredPrompt(null);
  };
 
  if (!isVisible || isInstalled) return null;
 
  return (
    <div className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-md
                    rounded-xl bg-gray-900 p-4 shadow-2xl border
                    border-gray-700 md:left-auto md:right-4">
      <div className="flex items-center gap-4">
        <div className="flex-shrink-0 text-3xl">📱</div>
        <div className="flex-1">
          <h3 className="font-semibold text-white">
            Install the app
          </h3>
          <p className="text-sm text-gray-400">
            Quick access from your home screen
          </p>
        </div>
        <div className="flex gap-2">
          <button
            onClick={() => setIsVisible(false)}
            className="rounded-lg px-3 py-2 text-sm text-gray-400
                       hover:text-white transition-colors"
          >
            Later
          </button>
          <button
            onClick={handleInstall}
            className="rounded-lg bg-blue-600 px-4 py-2 text-sm
                       font-semibold text-white hover:bg-blue-700
                       transition-colors"
          >
            Install
          </button>
        </div>
      </div>
    </div>
  );
}

Step 10: Push Notifications

Implement push notifications to engage your users:

// src/components/PushNotification.tsx
"use client";
 
import { useEffect, useState } from "react";
 
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
 
function urlBase64ToUint8Array(base64String: string) {
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, "+")
    .replace(/_/g, "/");
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}
 
export function PushNotification() {
  const [permission, setPermission] =
    useState<NotificationPermission>("default");
  const [isSubscribed, setIsSubscribed] = useState(false);
 
  useEffect(() => {
    if ("Notification" in window) {
      setPermission(Notification.permission);
    }
  }, []);
 
  const subscribeToNotifications = async () => {
    try {
      const registration = await navigator.serviceWorker.ready;
 
      const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
      });
 
      await fetch("/api/push/subscribe", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(subscription),
      });
 
      setIsSubscribed(true);
      setPermission("granted");
    } catch (error) {
      console.error("Error subscribing to notifications:", error);
    }
  };
 
  if (permission === "denied") return null;
  if (isSubscribed) return null;
 
  return (
    <button
      onClick={subscribeToNotifications}
      className="flex items-center gap-2 rounded-lg bg-green-600 px-4
                 py-2 text-sm font-semibold text-white
                 hover:bg-green-700 transition-colors"
    >
      Enable notifications
    </button>
  );
}

Step 11: Background Sync

Implement data synchronization when the connection returns:

// src/lib/backgroundSync.ts
"use client";
 
interface SyncData {
  url: string;
  method: string;
  body: string;
  timestamp: number;
}
 
const DB_NAME = "pwa-sync-queue";
const STORE_NAME = "pending-requests";
 
async function openDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onupgradeneeded = () => {
      request.result.createObjectStore(STORE_NAME, {
        autoIncrement: true,
      });
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}
 
export async function queueRequest(
  url: string,
  method: string,
  body: object
) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  const store = tx.objectStore(STORE_NAME);
 
  const syncData: SyncData = {
    url,
    method,
    body: JSON.stringify(body),
    timestamp: Date.now(),
  };
 
  store.add(syncData);
 
  if ("serviceWorker" in navigator && "SyncManager" in window) {
    const registration = await navigator.serviceWorker.ready;
    await (registration as any).sync.register("sync-pending");
  }
}
 
export async function processPendingRequests() {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  const store = tx.objectStore(STORE_NAME);
 
  const allRequests = await new Promise<SyncData[]>((resolve) => {
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
  });
 
  for (const syncData of allRequests) {
    try {
      await fetch(syncData.url, {
        method: syncData.method,
        headers: { "Content-Type": "application/json" },
        body: syncData.body,
      });
    } catch {
      return;
    }
  }
 
  const clearTx = db.transaction(STORE_NAME, "readwrite");
  clearTx.objectStore(STORE_NAME).clear();
}

Step 12: Put It All Together

Update your home page to integrate all the components:

// src/app/page.tsx
import { InstallPrompt } from "@/components/InstallPrompt";
import { OnlineBanner } from "@/components/OnlineBanner";
import { PushNotification } from "@/components/PushNotification";
import { ContactForm } from "@/components/ContactForm";
 
export default function Home() {
  return (
    <>
      <OnlineBanner />
      <main className="min-h-screen bg-gray-950 text-white">
        <div className="mx-auto max-w-4xl px-4 py-16">
          <h1 className="mb-4 text-5xl font-bold bg-gradient-to-r
                         from-blue-400 to-purple-500 bg-clip-text
                         text-transparent">
            My PWA Application
          </h1>
          <p className="mb-8 text-xl text-gray-400">
            Installable, offline-capable, fast.
          </p>
 
          <div className="mb-12 grid gap-6 md:grid-cols-3">
            <FeatureCard
              icon="📱"
              title="Installable"
              description="Add the app to your home screen"
            />
            <FeatureCard
              icon="🔌"
              title="Offline"
              description="Works without internet connection"
            />
            <FeatureCard
              icon="🔔"
              title="Notifications"
              description="Stay informed in real time"
            />
          </div>
 
          <div className="mb-8">
            <PushNotification />
          </div>
 
          <section className="rounded-xl border border-gray-800 p-6">
            <h2 className="mb-4 text-2xl font-semibold">
              Contact Us
            </h2>
            <ContactForm />
          </section>
        </div>
      </main>
      <InstallPrompt />
    </>
  );
}
 
function FeatureCard({
  icon,
  title,
  description,
}: {
  icon: string;
  title: string;
  description: string;
}) {
  return (
    <div className="rounded-xl border border-gray-800 p-6 text-center
                    hover:border-gray-600 transition-colors">
      <div className="mb-3 text-4xl">{icon}</div>
      <h3 className="mb-2 text-lg font-semibold">{title}</h3>
      <p className="text-sm text-gray-400">{description}</p>
    </div>
  );
}

Testing Your PWA

Local Testing

Run a production build to test the service worker:

npm run build
npm start

The service worker does not work in development mode (npm run dev). You must always test with a production build.

Lighthouse Audit

  1. Open Chrome DevTools (F12)
  2. Go to the Lighthouse tab
  3. Select Progressive Web App
  4. Run the audit

Your application should achieve a score close to 100 if all criteria are met.

Manual Checks

Test the following scenarios:

  • Installation: click the install icon in the address bar
  • Offline: enable airplane mode and navigate the application
  • Caching: visit pages, go offline, then revisit them
  • Notifications: enable notifications and send a test

Add to .gitignore

The generated service worker and cache files should not be versioned:

# PWA
public/sw.js
public/sw.js.map
public/workbox-*.js
public/workbox-*.js.map
public/worker-*.js
public/worker-*.js.map
public/fallback-*.js
public/swe-worker-*.js

Troubleshooting

Service worker not updating

Clear the cache in Chrome DevTools, Application tab, Service Workers section, and click "Unregister". Then reload the page.

Install prompt not appearing

The beforeinstallprompt event only fires if:

  • The page is served over HTTPS (or localhost)
  • The manifest is valid with required fields
  • A service worker is registered
  • The user has not already installed the application

Notifications not working

Verify that:

  • VAPID keys are correctly configured
  • The service worker is active
  • The user has granted permission
  • The browser supports the Push API

Next Steps

  • Add native sharing with the Web Share API
  • Implement adaptive dark mode with prefers-color-scheme
  • Explore Periodic Background Sync for regular updates
  • Integrate Workbox for advanced caching strategies
  • Add app shortcut support in the manifest

Conclusion

You have transformed a Next.js application into a complete Progressive Web App. Your application is now installable on all devices, works offline with intelligent caching, and can send push notifications to engage your users.

PWAs represent the best compromise between web and native applications: a single codebase, instant deployment via URL, and native capabilities. With Next.js App Router and modern tools, creating a PWA has never been more accessible.


Want to read more tutorials? Check out our latest tutorial on Mastering Twilio SMS: A Beginner’s Guide to Node.js Messaging for Business.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles