Progressive Web Apps (PWAs) in 2025: Why They Still Matter (and How to Build Them)
"PWAs are dead," they said. "Native apps won," they said. Yet here we are in 2025, and PWAs are having a moment. Twitter became X PWA, Instagram works offline, and even traditionally native-first companies are investing heavily in Progressive Web Apps.
I've built PWAs for e-commerce companies that saw 40% higher conversion rates than their native apps. I've seen PWAs reduce development costs by 60% while reaching 100% more users. The technology has matured, browser support is excellent, and the business case is stronger than ever.
Let me show you why PWAs are winning in 2025 and how to build them right.
The PWA Renaissance: Why Now?
What Changed Since 2020
Browser Support:
- iOS Safari finally supports Service Workers properly
- Desktop PWA installation in all major browsers
- Push notifications work everywhere (including iOS 16.4+)
- Web Share API, Badging API, File System Access API
Performance:
- Service Workers are stable and performant
- Modern frameworks (Next.js, Vite) have excellent PWA support
- Better caching strategies and background sync
- Faster JavaScript engines
Business Drivers:
- App store fatigue (users install fewer apps)
- Development cost reduction (one codebase vs multiple)
- Instant access (no installation friction)
- Better SEO and discoverability
The Numbers Don't Lie
PWA Performance in 2025:
- Pinterest PWA: 60% increase in core engagements
- Starbucks PWA: 2x daily active users
- Uber PWA: 3x faster than native on 2G
- Flipkart PWA: 70% increase in conversions
Building PWAs with Next.js
Next.js makes PWA development straightforward with excellent tooling.
Setup: next-pwa
npm install next-pwa
npm install --save-dev webpack// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https?.*/,
handler: 'NetworkFirst',
options: {
cacheName: 'offlineCache',
expiration: {
maxEntries: 200,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
},
},
},
],
buildExcludes: [/middleware-manifest\.json$/],
});
module.exports = withPWA({
// Your existing Next.js config
experimental: {
appDir: true,
},
});Web App Manifest
// public/manifest.json
{
"name": "My PWA App",
"short_name": "MyApp",
"description": "A powerful Progressive Web App",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"categories": ["productivity", "business"],
"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"
}
]
}Layout Configuration
// app/layout.tsx
import { Metadata, Viewport } from 'next';
export const metadata: Metadata = {
title: 'My PWA App',
description: 'A powerful Progressive Web App',
generator: 'Next.js',
manifest: '/manifest.json',
keywords: ['nextjs', 'pwa', 'typescript'],
authors: [{ name: 'Your Name' }],
icons: {
icon: '/favicon.ico',
shortcut: '/favicon.ico',
apple: '/icons/icon-192x192.png',
},
};
export const viewport: Viewport = {
minimumScale: 1,
initialScale: 1,
width: 'device-width',
shrinkToFit: false,
viewportFit: 'cover',
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#000000' },
],
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<meta name="application-name" content="My PWA App" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="My PWA App" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="msapplication-TileColor" content="#2B5797" />
<meta name="msapplication-tap-highlight" content="no" />
<link rel="apple-touch-icon" href="/icons/icon-152x152.png" />
<link rel="mask-icon" href="/icons/maskable-icon.svg" color="#000000" />
<link rel="shortcut icon" href="/favicon.ico" />
</head>
<body>
{children}
</body>
</html>
);
}Offline-First Architecture
The key to great PWAs is thinking offline-first.
Service Worker Strategy
// public/sw.js (if not using next-pwa)
const CACHE_NAME = 'my-pwa-v1';
const urlsToCache = [
'/',
'/offline',
'/static/js/bundle.js',
'/static/css/main.css',
];
// Install event
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
// Fetch event
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Return cached version or fetch from network
return response || fetch(event.request);
}
)
);
});Offline Page
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md mx-auto text-center">
<div className="mb-8">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">
You're offline
</h1>
<p className="text-gray-600 mb-8">
No internet connection found. Check your connection or try again.
</p>
<button
onClick={() => window.location.reload()}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Try Again
</button>
</div>
</div>
);
}Data Synchronization
// hooks/useOfflineSync.ts
import { useState, useEffect } from 'react';
export function useOfflineSync() {
const [isOnline, setIsOnline] = useState(true);
const [pendingActions, setPendingActions] = useState<any[]>([]);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
syncPendingActions();
};
const handleOffline = () => {
setIsOnline(false);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const syncPendingActions = async () => {
for (const action of pendingActions) {
try {
await fetch(action.url, action.options);
// Remove successful action
setPendingActions(prev => prev.filter(a => a.id !== action.id));
} catch (error) {
console.error('Sync failed:', error);
}
}
};
const queueAction = (action: any) => {
if (!isOnline) {
setPendingActions(prev => [...prev, { ...action, id: Date.now() }]);
}
};
return { isOnline, queueAction, pendingActions };
}Install Prompts and User Experience
Custom Install Prompt
// components/InstallPrompt.tsx
'use client';
import { useState, useEffect } from 'react';
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e);
setShowPrompt(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setDeferredPrompt(null);
setShowPrompt(false);
}
};
if (!showPrompt) return null;
return (
<div className="fixed bottom-4 left-4 right-4 bg-white border rounded-lg shadow-lg p-4 z-50">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Install App</h3>
<p className="text-sm text-gray-600">
Add to your home screen for quick access
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowPrompt(false)}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
>
Later
</button>
<button
onClick={handleInstall}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
Install
</button>
</div>
</div>
</div>
);
}App-Like Navigation
// components/AppShell.tsx
'use client';
import { useState } from 'react';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const navigation = [
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Search', href: '/search', icon: SearchIcon },
{ name: 'Profile', href: '/profile', icon: UserIcon },
];
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
<div className="px-4 py-3">
<h1 className="text-lg font-semibold">My PWA App</h1>
</div>
</header>
{/* Main Content */}
<main className="pb-16">
{children}
</main>
{/* Bottom Navigation */}
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200">
<div className="grid grid-cols-3">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`flex flex-col items-center py-2 px-1 ${
isActive
? 'text-blue-600'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.name}</span>
</Link>
);
})}
</div>
</nav>
</div>
);
}Push Notifications
Service Worker Notifications
// public/sw.js
self.addEventListener('push', (event) => {
const options = {
body: event.data ? event.data.text() : 'New notification!',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2'
},
actions: [
{
action: 'explore',
title: 'Explore this new world',
icon: '/icons/checkmark.png'
},
{
action: 'close',
title: 'Close',
icon: '/icons/xmark.png'
},
]
};
event.waitUntil(
self.registration.showNotification('My PWA App', options)
);
});Push Notification Setup
// hooks/usePushNotifications.ts
export function usePushNotifications() {
const [isSupported, setIsSupported] = useState(false);
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
useEffect(() => {
setIsSupported('serviceWorker' in navigator && 'PushManager' in window);
}, []);
const subscribeToPush = async () => {
try {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!)
});
setSubscription(sub);
// Send subscription to your server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sub),
});
} catch (error) {
console.error('Push subscription failed:', error);
}
};
return { isSupported, subscription, subscribeToPush };
}Testing Your PWA
Lighthouse Audit
# Install Lighthouse CLI
npm install -g lighthouse
# Audit your PWA
lighthouse https://your-pwa-url.com --only-categories=pwa --output=html --output-path=./pwa-audit.htmlPWA Checklist
Essential PWA Requirements:
- [ ] Web app manifest with required fields
- [ ] Service worker that caches resources
- [ ] Served over HTTPS
- [ ] Responsive design
- [ ] Fast loading (< 3s on 3G)
Advanced PWA Features:
- [ ] Offline functionality
- [ ] Install prompts
- [ ] Push notifications
- [ ] Background sync
- [ ] App-like navigation
- [ ] Splash screen
Testing Tools
// components/PWADebugger.tsx (development only)
export function PWADebugger() {
const [swRegistration, setSWRegistration] = useState<ServiceWorkerRegistration | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
// Service Worker registration status
navigator.serviceWorker.ready.then(setSWRegistration);
// Online/offline status
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (process.env.NODE_ENV !== 'development') return null;
return (
<div className="fixed top-4 right-4 bg-black text-white p-2 rounded text-xs">
<div>SW: {swRegistration ? '✅' : '❌'}</div>
<div>Online: {isOnline ? '✅' : '❌'}</div>
</div>
);
}Business Impact and ROI
Case Study: E-commerce PWA
Before (Native App Only):
- Development cost: $150K (iOS + Android)
- App store approval: 2-3 weeks
- Discovery: Organic app store only
- Conversion rate: 2.1%
- User acquisition cost: $45
After (PWA + Native):
- Additional development cost: $30K
- Instant deployment
- Discovery: Web + app store + social sharing
- Conversion rate: 2.9% (+38%)
- User acquisition cost: $28 (-38%)
Results:
- 60% cost reduction vs native-only approach
- 40% higher conversion rates
- 3x faster feature deployment
- Better SEO and discoverability
Common Pitfalls and Solutions
1. iOS Safari Quirks
// Handle iOS-specific issues
useEffect(() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS) {
// Fix viewport issues
const viewport = document.querySelector('meta[name=viewport]');
viewport?.setAttribute('content',
'width=device-width, initial-scale=1, viewport-fit=cover'
);
// Handle safe area insets
document.documentElement.style.setProperty(
'--safe-area-inset-top',
'env(safe-area-inset-top)'
);
}
}, []);2. Cache Management
// Clear outdated caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => {
return cacheName !== CACHE_NAME;
})
.map((cacheName) => {
return caches.delete(cacheName);
})
);
})
);
});The Bottom Line
PWAs in 2025 offer the best of both worlds: web accessibility with native-like experiences. They're perfect for:
Ideal Use Cases:
- E-commerce and retail
- Content platforms and media
- Productivity and business tools
- Social platforms
- Financial services
When to Choose PWA Over Native:
- Budget constraints
- Rapid iteration needs
- Web-first user acquisition
- Cross-platform consistency
- SEO and discoverability important
When to Still Go Native:
- Heavy device integration needed
- Performance-critical applications
- Platform-specific UI important
- Existing native user base
Key takeaways:
- PWAs are mature and well-supported in 2025
- Next.js makes PWA development straightforward
- Offline-first thinking is crucial
- Business benefits often outweigh native advantages
- Test thoroughly across devices and browsers
The future is multi-platform, and PWAs are a crucial part of that strategy.
---
Have you built a PWA recently? What challenges did you face? Share your experience in the comments below.Want articles like this in your inbox?
Join developers and founders who get practical insights on frontend, SaaS, and building better products.
Written by Salman Izhar
Full Stack Developer specializing in React, Next.js, and building high-converting web applications.
Learn More