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

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-appInstall the dependencies needed for PWA support:
npm install next-pwa @ducanh2912/next-pwa
npm install -D webpackWe 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 startThe service worker does not work in development mode (npm run dev). You must always test with a production build.
Lighthouse Audit
- Open Chrome DevTools (F12)
- Go to the Lighthouse tab
- Select Progressive Web App
- 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-*.jsTroubleshooting
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.
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

Building a Production-Ready API with tRPC, Prisma, and Next.js App Router
Learn how to build a fully type-safe, production-ready API using tRPC, Prisma ORM, and Next.js 15 App Router. Complete guide from setup to deployment with best practices.

AI SDK 4.0: New Features and Use Cases
Discover the new features and use cases of AI SDK 4.0, including PDF support, computer use, and more.

Building a Conversational AI App with Next.js
Learn how to build a web application that enables real-time voice conversations with AI agents using Next.js and ElevenLabs.