Back to Journal
Mobile/Frontend

How to Build Progressive Web Apps Using Nextjs

Step-by-step tutorial for building Progressive Web Apps with Nextjs, from project setup through deployment.

Muneer Puthiya Purayil 24 min read

Next.js provides an excellent foundation for Progressive Web Apps by handling server-side rendering, static generation, and API routes out of the box. This tutorial walks through building a PWA with Next.js from project setup through production deployment, covering service worker integration, offline support, and installability.

Project Setup

Start with a fresh Next.js project and add the PWA dependencies:

bash
1npx create-next-app@latest my-pwa --typescript --tailwind --app
2cd my-pwa
3npm install next-pwa
4npm install -D webpack
5 

Configure next-pwa in your Next.js config:

typescript
1// next.config.ts
2import type { NextConfig } from 'next';
3import withPWAInit from 'next-pwa';
4 
5const withPWA = withPWAInit({
6 dest: 'public',
7 register: true,
8 skipWaiting: true,
9 disable: process.env.NODE_ENV === 'development',
10 runtimeCaching: [
11 {
12 urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
13 handler: 'CacheFirst',
14 options: {
15 cacheName: 'google-fonts',
16 expiration: { maxEntries: 20, maxAgeSeconds: 365 * 24 * 60 * 60 },
17 },
18 },
19 {
20 urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font\.css)$/i,
21 handler: 'StaleWhileRevalidate',
22 options: {
23 cacheName: 'static-font-assets',
24 expiration: { maxEntries: 20, maxAgeSeconds: 7 * 24 * 60 * 60 },
25 },
26 },
27 {
28 urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
29 handler: 'StaleWhileRevalidate',
30 options: {
31 cacheName: 'static-image-assets',
32 expiration: { maxEntries: 64, maxAgeSeconds: 30 * 24 * 60 * 60 },
33 },
34 },
35 {
36 urlPattern: /\/_next\/image\?url=.+$/i,
37 handler: 'StaleWhileRevalidate',
38 options: {
39 cacheName: 'next-image',
40 expiration: { maxEntries: 64, maxAgeSeconds: 24 * 60 * 60 },
41 },
42 },
43 {
44 urlPattern: /\.(?:js)$/i,
45 handler: 'StaleWhileRevalidate',
46 options: {
47 cacheName: 'static-js-assets',
48 expiration: { maxEntries: 48, maxAgeSeconds: 24 * 60 * 60 },
49 },
50 },
51 {
52 urlPattern: /\.(?:css|less)$/i,
53 handler: 'StaleWhileRevalidate',
54 options: {
55 cacheName: 'static-style-assets',
56 expiration: { maxEntries: 32, maxAgeSeconds: 24 * 60 * 60 },
57 },
58 },
59 {
60 urlPattern: /^https?:\/\/.*\/api\/.*$/i,
61 handler: 'NetworkFirst',
62 options: {
63 cacheName: 'apis',
64 networkTimeoutSeconds: 10,
65 expiration: { maxEntries: 16, maxAgeSeconds: 24 * 60 * 60 },
66 },
67 },
68 {
69 urlPattern: /.*/i,
70 handler: 'NetworkFirst',
71 options: {
72 cacheName: 'others',
73 networkTimeoutSeconds: 10,
74 expiration: { maxEntries: 32, maxAgeSeconds: 24 * 60 * 60 },
75 },
76 },
77 ],
78});
79 
80const nextConfig: NextConfig = {};
81export default withPWA(nextConfig);
82 

Web App Manifest

Create the manifest file in the public directory:

json
1{
2 "name": "My PWA App",
3 "short_name": "MyPWA",
4 "description": "A Progressive Web App built with Next.js",
5 "start_url": "/",
6 "display": "standalone",
7 "background_color": "#000000",
8 "theme_color": "#000000",
9 "orientation": "portrait-primary",
10 "icons": [
11 {
12 "src": "/icons/icon-192x192.png",
13 "sizes": "192x192",
14 "type": "image/png"
15 },
16 {
17 "src": "/icons/icon-384x384.png",
18 "sizes": "384x384",
19 "type": "image/png"
20 },
21 {
22 "src": "/icons/icon-512x512.png",
23 "sizes": "512x512",
24 "type": "image/png"
25 },
26 {
27 "src": "/icons/icon-maskable-512x512.png",
28 "sizes": "512x512",
29 "type": "image/png",
30 "purpose": "maskable"
31 }
32 ]
33}
34 

Reference the manifest and add PWA meta tags in your root layout:

typescript
1// app/layout.tsx
2import type { Metadata, Viewport } from 'next';
3 
4export const metadata: Metadata = {
5 title: 'My PWA App',
6 description: 'A Progressive Web App built with Next.js',
7 manifest: '/manifest.json',
8 appleWebApp: {
9 capable: true,
10 statusBarStyle: 'default',
11 title: 'My PWA App',
12 },
13 formatDetection: {
14 telephone: false,
15 },
16};
17 
18export const viewport: Viewport = {
19 themeColor: '#000000',
20 width: 'device-width',
21 initialScale: 1,
22 maximumScale: 1,
23};
24 
25export default function RootLayout({ children }: { children: React.ReactNode }) {
26 return (
27 <html lang="en">
28 <head>
29 <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
30 </head>
31 <body>{children}</body>
32 </html>
33 );
34}
35 

Install Prompt Component

Build a component that handles the PWA install prompt:

typescript
1// components/InstallPrompt.tsx
2'use client';
3 
4import { useState, useEffect } from 'react';
5 
6interface BeforeInstallPromptEvent extends Event {
7 prompt(): Promise<void>;
8 userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
9}
10 
11export function InstallPrompt() {
12 const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
13 const [isInstalled, setIsInstalled] = useState(false);
14 
15 useEffect(() => {
16 const handler = (e: Event) => {
17 e.preventDefault();
18 setInstallPrompt(e as BeforeInstallPromptEvent);
19 };
20 
21 const installedHandler = () => {
22 setIsInstalled(true);
23 setInstallPrompt(null);
24 };
25 
26 window.addEventListener('beforeinstallprompt', handler);
27 window.addEventListener('appinstalled', installedHandler);
28 
29 if (window.matchMedia('(display-mode: standalone)').matches) {
30 setIsInstalled(true);
31 }
32 
33 return () => {
34 window.removeEventListener('beforeinstallprompt', handler);
35 window.removeEventListener('appinstalled', installedHandler);
36 };
37 }, []);
38 
39 const handleInstall = async () => {
40 if (!installPrompt) return;
41 installPrompt.prompt();
42 const { outcome } = await installPrompt.userChoice;
43 if (outcome === 'accepted') {
44 setInstallPrompt(null);
45 }
46 };
47 
48 if (isInstalled || !installPrompt) return null;
49 
50 return (
51 <div className="fixed bottom-4 left-4 right-4 bg-white rounded-lg shadow-xl p-4 flex items-center justify-between z-50 md:left-auto md:w-96">
52 <div>
53 <p className="font-semibold text-gray-900">Install App</p>
54 <p className="text-sm text-gray-600">Add to your home screen for quick access</p>
55 </div>
56 <div className="flex gap-2">
57 <button
58 onClick={() => setInstallPrompt(null)}
59 className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700"
60 >
61 Later
62 </button>
63 <button
64 onClick={handleInstall}
65 className="px-4 py-1.5 bg-black text-white rounded-md text-sm hover:bg-gray-800"
66 >
67 Install
68 </button>
69 </div>
70 </div>
71 );
72}
73 

Offline Fallback Page

Create a dedicated offline page that the service worker serves when the network is unavailable:

typescript
1// app/offline/page.tsx
2export default function OfflinePage() {
3 return (
4 <div className="flex min-h-screen items-center justify-center bg-gray-50">
5 <div className="text-center">
6 <div className="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-gray-200">
7 <svg className="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
8 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
9 d="M18.364 5.636a9 9 0 010 12.728M5.636 18.364a9 9 0 010-12.728" />
10 </svg>
11 </div>
12 <h1 className="mb-2 text-2xl font-bold text-gray-900">You are offline</h1>
13 <p className="mb-6 text-gray-600">Check your internet connection and try again.</p>
14 <button
15 onClick={() => window.location.reload()}
16 className="rounded-md bg-black px-6 py-2 text-white hover:bg-gray-800"
17 >
18 Retry
19 </button>
20 </div>
21 </div>
22 );
23}
24 

Add the offline page to your next-pwa configuration:

typescript
1// In next.config.ts, add to withPWA options:
2const withPWA = withPWAInit({
3 // ... other options
4 fallbacks: {
5 document: '/offline',
6 },
7});
8 

Push Notifications

API Route for Push Subscription

typescript
1// app/api/push/subscribe/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import webpush from 'web-push';
4 
5webpush.setVapidDetails(
6 'mailto:[email protected]',
7 process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
8 process.env.VAPID_PRIVATE_KEY!,
9);
10 
11const subscriptions = new Map<string, webpush.PushSubscription>();
12 
13export async function POST(request: NextRequest) {
14 const { subscription, userId } = await request.json();
15 
16 subscriptions.set(userId, subscription);
17 
18 return NextResponse.json({ success: true });
19}
20 

Client-Side Push Registration

typescript
1// hooks/usePushNotifications.ts
2'use client';
3 
4import { useState, useCallback } from 'react';
5 
6function urlBase64ToUint8Array(base64String: string): Uint8Array {
7 const padding = '='.repeat((4 - base64String.length % 4) % 4);
8 const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
9 const rawData = window.atob(base64);
10 return Uint8Array.from(rawData, (char) => char.charCodeAt(0));
11}
12 
13export function usePushNotifications() {
14 const [isSubscribed, setIsSubscribed] = useState(false);
15 const [permission, setPermission] = useState<NotificationPermission>('default');
16 
17 const subscribe = useCallback(async () => {
18 const result = await Notification.requestPermission();
19 setPermission(result);
20 
21 if (result !== 'granted') return false;
22 
23 const registration = await navigator.serviceWorker.ready;
24 const subscription = await registration.pushManager.subscribe({
25 userVisibleOnly: true,
26 applicationServerKey: urlBase64ToUint8Array(
27 process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
28 ),
29 });
30 
31 await fetch('/api/push/subscribe', {
32 method: 'POST',
33 headers: { 'Content-Type': 'application/json' },
34 body: JSON.stringify({
35 subscription: subscription.toJSON(),
36 userId: 'current-user-id',
37 }),
38 });
39 
40 setIsSubscribed(true);
41 return true;
42 }, []);
43 
44 return { isSubscribed, permission, subscribe };
45}
46 

Need a second opinion on your mobile/frontend architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Offline Data with IndexedDB

Build a hook for offline-capable data fetching:

typescript
1// hooks/useOfflineData.ts
2'use client';
3 
4import { useState, useEffect, useCallback } from 'react';
5 
6function openDB(dbName: string, storeName: string): Promise<IDBDatabase> {
7 return new Promise((resolve, reject) => {
8 const request = indexedDB.open(dbName, 1);
9 request.onupgradeneeded = () => {
10 const db = request.result;
11 if (!db.objectStoreNames.contains(storeName)) {
12 db.createObjectStore(storeName, { keyPath: 'key' });
13 }
14 };
15 request.onsuccess = () => resolve(request.result);
16 request.onerror = () => reject(request.error);
17 });
18}
19 
20export function useOfflineData<T>(key: string, fetchUrl: string) {
21 const [data, setData] = useState<T | null>(null);
22 const [isOffline, setIsOffline] = useState(false);
23 const [isLoading, setIsLoading] = useState(true);
24 
25 const fetchAndCache = useCallback(async () => {
26 setIsLoading(true);
27 try {
28 const response = await fetch(fetchUrl);
29 if (!response.ok) throw new Error('Network response not ok');
30 
31 const freshData = await response.json();
32 setData(freshData);
33 setIsOffline(false);
34 
35 // Cache in IndexedDB
36 const db = await openDB('app-cache', 'data');
37 const tx = db.transaction('data', 'readwrite');
38 tx.objectStore('data').put({ key, value: freshData, timestamp: Date.now() });
39 } catch {
40 // Fallback to cached data
41 try {
42 const db = await openDB('app-cache', 'data');
43 const tx = db.transaction('data', 'readonly');
44 const request = tx.objectStore('data').get(key);
45 request.onsuccess = () => {
46 if (request.result) {
47 setData(request.result.value);
48 setIsOffline(true);
49 }
50 };
51 } catch {
52 // No cached data available
53 }
54 } finally {
55 setIsLoading(false);
56 }
57 }, [key, fetchUrl]);
58 
59 useEffect(() => {
60 fetchAndCache();
61 }, [fetchAndCache]);
62 
63 return { data, isOffline, isLoading, refetch: fetchAndCache };
64}
65 

Using the Offline Data Hook in a Page

typescript
1// app/dashboard/page.tsx
2'use client';
3 
4import { useOfflineData } from '@/hooks/useOfflineData';
5 
6interface DashboardData {
7 stats: { label: string; value: number }[];
8 recentActivity: { id: string; message: string; timestamp: string }[];
9}
10 
11export default function DashboardPage() {
12 const { data, isOffline, isLoading } = useOfflineData<DashboardData>(
13 'dashboard',
14 '/api/dashboard'
15 );
16 
17 if (isLoading) {
18 return <div className="animate-pulse">Loading...</div>;
19 }
20 
21 return (
22 <div className="p-6">
23 {isOffline && (
24 <div className="mb-4 rounded-md bg-amber-50 p-3 text-sm text-amber-800">
25 You are viewing cached data. Some information may be outdated.
26 </div>
27 )}
28 
29 <h1 className="mb-6 text-2xl font-bold">Dashboard</h1>
30 
31 {data && (
32 <>
33 <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
34 {data.stats.map(stat => (
35 <div key={stat.label} className="rounded-lg bg-white p-4 shadow">
36 <p className="text-sm text-gray-500">{stat.label}</p>
37 <p className="text-2xl font-bold">{stat.value.toLocaleString()}</p>
38 </div>
39 ))}
40 </div>
41 
42 <div className="mt-8">
43 <h2 className="mb-4 text-lg font-semibold">Recent Activity</h2>
44 <ul className="space-y-2">
45 {data.recentActivity.map(item => (
46 <li key={item.id} className="rounded bg-white p-3 shadow-sm">
47 <p>{item.message}</p>
48 <time className="text-xs text-gray-400">
49 {new Date(item.timestamp).toLocaleDateString()}
50 </time>
51 </li>
52 ))}
53 </ul>
54 </div>
55 </>
56 )}
57 </div>
58 );
59}
60 

Offline Form Submission

Handle form submissions that work offline:

typescript
1// hooks/useOfflineForm.ts
2'use client';
3 
4import { useState, useCallback } from 'react';
5 
6interface QueuedSubmission {
7 id: string;
8 url: string;
9 method: string;
10 body: string;
11 timestamp: number;
12}
13 
14export function useOfflineForm(submitUrl: string) {
15 const [isPending, setIsPending] = useState(false);
16 const [isQueued, setIsQueued] = useState(false);
17 
18 const submit = useCallback(async (data: Record<string, unknown>) => {
19 setIsPending(true);
20 
21 try {
22 const response = await fetch(submitUrl, {
23 method: 'POST',
24 headers: { 'Content-Type': 'application/json' },
25 body: JSON.stringify(data),
26 });
27 
28 if (!response.ok) throw new Error('Submit failed');
29 setIsPending(false);
30 return { success: true, queued: false };
31 } catch {
32 // Queue for later submission
33 const submission: QueuedSubmission = {
34 id: crypto.randomUUID(),
35 url: submitUrl,
36 method: 'POST',
37 body: JSON.stringify(data),
38 timestamp: Date.now(),
39 };
40 
41 const db = await openDB();
42 const tx = db.transaction('submissions', 'readwrite');
43 tx.objectStore('submissions').add(submission);
44 
45 // Register for background sync if available
46 if ('serviceWorker' in navigator && 'SyncManager' in window) {
47 const registration = await navigator.serviceWorker.ready;
48 await (registration as any).sync.register('sync-submissions');
49 }
50 
51 setIsPending(false);
52 setIsQueued(true);
53 return { success: true, queued: true };
54 }
55 }, [submitUrl]);
56 
57 return { submit, isPending, isQueued };
58}
59 
60function openDB(): Promise<IDBDatabase> {
61 return new Promise((resolve, reject) => {
62 const request = indexedDB.open('form-queue', 1);
63 request.onupgradeneeded = () => {
64 request.result.createObjectStore('submissions', { keyPath: 'id' });
65 };
66 request.onsuccess = () => resolve(request.result);
67 request.onerror = () => reject(request.error);
68 });
69}
70 

Testing the PWA

Lighthouse Audit

Run a Lighthouse PWA audit after building:

bash
1npm run build
2npm run start
3# In another terminal:
4npx lighthouse http://localhost:3000 --only-categories=pwa --output=json --output-path=./lighthouse-report.json
5 

Integration Tests

typescript
1// __tests__/pwa.test.ts
2import { test, expect } from '@playwright/test';
3 
4test('service worker registers', async ({ page }) => {
5 await page.goto('/');
6 const swRegistration = await page.evaluate(async () => {
7 const reg = await navigator.serviceWorker.getRegistration();
8 return reg !== undefined;
9 });
10 expect(swRegistration).toBe(true);
11});
12 
13test('app works offline', async ({ page, context }) => {
14 // First visit to cache assets
15 await page.goto('/');
16 await page.waitForLoadState('networkidle');
17 
18 // Go offline
19 await context.setOffline(true);
20 
21 // Navigate and verify content loads
22 await page.goto('/');
23 const heading = await page.textContent('h1');
24 expect(heading).toBeTruthy();
25});
26 
27test('manifest is valid', async ({ page }) => {
28 const response = await page.goto('/manifest.json');
29 const manifest = await response?.json();
30 expect(manifest.name).toBeTruthy();
31 expect(manifest.icons.length).toBeGreaterThanOrEqual(2);
32 expect(manifest.start_url).toBe('/');
33 expect(manifest.display).toBe('standalone');
34});
35 

Deployment to Vercel

bash
1# Build and deploy
2npm run build
3vercel --prod
4 

Add the necessary headers in vercel.json:

json
1{
2 "headers": [
3 {
4 "source": "/sw.js",
5 "headers": [
6 { "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" },
7 { "key": "Service-Worker-Allowed", "value": "/" }
8 ]
9 },
10 {
11 "source": "/manifest.json",
12 "headers": [
13 { "key": "Cache-Control", "value": "public, max-age=86400" }
14 ]
15 }
16 ]
17}
18 

Conclusion

Next.js simplifies PWA development by handling the build pipeline, routing, and API layer while next-pwa manages service worker generation and caching strategies. The App Router's server components work seamlessly with PWA patterns — server-rendered pages get cached by the service worker on first visit, providing instant subsequent loads.

The combination of Next.js metadata API for manifest and meta tags, React hooks for install prompts and offline data, and IndexedDB for client-side persistence creates a PWA that rivals native app experiences. Start with the core features (manifest, service worker, offline fallback) and incrementally add push notifications and background sync as your user base grows.

FAQ

Need expert help?

Building with mobile/frontend?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026