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:




