By following this guide, you will turn a standard Next.js 15 application into a fully installable Progressive Web App (PWA) with offline support, a custom install prompt, and a passing Lighthouse PWA audit score above 90. The entire setup takes roughly 60–90 minutes on a project that already has a basic Next.js 15 scaffold.

What You'll Build

  • A Next.js 15 App Router project configured as an installable PWA on desktop and mobile
  • A Web App Manifest that passes Chrome's installability criteria
  • A service worker using Workbox 7 that caches static assets and API routes for offline use
  • A custom in-app install prompt that appears after user engagement
  • A Lighthouse PWA audit score above 90, verified locally and on Vercel

Prerequisites

  • Node.js 20+ and pnpm 9 (or npm 10) installed locally
  • A working Next.js 15 project using the App Router (app/ directory)
  • Basic familiarity with React Server Components and next.config.js
  • A Vercel account (free tier works) for deployment verification
  • Chrome DevTools for Lighthouse auditing

Step 1: Install the PWA Plugin

The @ducanh2912/next-pwa package is the most actively maintained PWA plugin for Next.js 15 as of mid-2026. It wraps Workbox 7 and integrates cleanly with the App Router's build output.

pnpm add @ducanh2912/next-pwa
pnpm add -D workbox-window

This installs the plugin and the Workbox window helper, which you will use in Step 5 for the install prompt.

Common pitfall: The older next-pwa package by shadowwalker is not maintained for Next.js 14+ and will cause build errors with the App Router. Use @ducanh2912/next-pwa instead.

Step 2: Configure next.config.js

Wrap your existing Next.js config with the PWA plugin. This tells Workbox where to generate the service worker and which files to precache.

// next.config.js
const withPWA = require('@ducanh2912/next-pwa').default;

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
};

module.exports = withPWA({
  dest: 'public',
  cacheOnFrontEndNav: true,
  aggressiveFrontEndNavCaching: true,
  reloadOnOnline: true,
  swcMinify: true,
  disable: process.env.NODE_ENV === 'development',
  workboxOptions: {
    disableDevLogs: true,
  },
})(nextConfig);

Setting disable: process.env.NODE_ENV === 'development' prevents the service worker from interfering with hot module replacement during local development.

Pro tip: Add public/sw.js and public/workbox-*.js to your .gitignore. These files are generated at build time and do not belong in version control.

Why does the dest: 'public' setting matter?

Next.js serves everything in public/ at the root path (/). Browsers require the service worker to be served from the same scope as your app — typically /sw.js. Placing generated files in public/ satisfies this requirement without any extra server configuration.

Step 3: Create the Web App Manifest

Create app/manifest.ts using Next.js 15's native Metadata API. This is the recommended approach as of Next.js 15 — it generates a standards-compliant manifest.json automatically.

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

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'My PWA App',
    short_name: 'MyApp',
    description: 'A fast, installable web application',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#0f172a',
    orientation: 'portrait-primary',
    icons: [
      {
        src: '/icons/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
        purpose: 'maskable',
      },
      {
        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',
      },
    ],
  };
}

Place your icon files in public/icons/. Use a tool like PWABuilder to generate all required icon sizes from a single 1024×1024 source PNG.

Common pitfall: Omitting the screenshots field causes Chrome on Android (as of version 120+) to silently skip the enhanced install dialog. Always include at least one screenshot.

Step 4: Add iOS Meta Tags to the Root Layout

Safari on iOS does not fully respect the Web App Manifest for standalone display. Add iOS-specific meta tags in app/layout.tsx.

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'My PWA App',
  description: 'A fast, installable web application',
  appleWebApp: {
    capable: true,
    statusBarStyle: 'default',
    title: 'MyApp',
    startupImage: [
      {
        url: '/splash/apple-splash-2048-2732.png',
        media:
          '(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)',
      },
    ],
  },
  formatDetection: {
    telephone: false,
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    
      {children}
    
  );
}

Next.js 15's Metadata API handles the apple-mobile-web-app-capable and related meta tags automatically from the appleWebApp object.

Step 5: Build a Custom Install Prompt

Browsers fire a beforeinstallprompt event before showing the native install UI. Intercept this event to display your own branded prompt at the right moment.

'use client';
// components/InstallPrompt.tsx
import { useEffect, useState } from 'react';

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

export default function InstallPrompt() {
  const [installEvent, setInstallEvent] =
    useState(null);
  const [showBanner, setShowBanner] = useState(false);

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault();
      setInstallEvent(e as BeforeInstallPromptEvent);
      setShowBanner(true);
    };
    window.addEventListener('beforeinstallprompt', handler);
    return () => window.removeEventListener('beforeinstallprompt', handler);
  }, []);

  const handleInstall = async () => {
    if (!installEvent) return;
    await installEvent.prompt();
    const { outcome } = await installEvent.userChoice;
    if (outcome === 'accepted') setShowBanner(false);
    setInstallEvent(null);
  };

  if (!showBanner) return null;

  return (
    

Install this app for a faster experience.

); }

Add <InstallPrompt /> inside your root layout's <body>. The banner only appears on Chromium-based browsers (Chrome, Edge, Samsung Internet) that support beforeinstallprompt. On Safari, rely on the iOS splash screen and a manual "Add to Home Screen" hint.

When should you trigger the install prompt?

Show the install banner after a meaningful interaction — for example, after a user completes a key action or visits three or more pages. Triggering it immediately on first load leads to dismissal rates above 85% based on Google's UX research. Delay the prompt until the user has demonstrated intent.

Step 6: Implement an Offline Fallback Page

Create a dedicated offline page that Workbox serves when the user is offline and a cached version of the requested page does not exist.

// app/offline/page.tsx
export default function OfflinePage() {
  return (
    

You're offline

Check your connection and try again.

); }

Then register this route in your Workbox configuration inside next.config.js: