PWA = app-like experience, web simplicity. 68% users prefer installable web apps vs app stores.
Ce guide couvre Progressive Web Apps Next.js : service worker setup, offline mode, push notifications, install prompt, caching strategies, manifest configuration.
Résultat client : Engagement +127% (PWA install prompt)
TL;DR : PWA Features & Browser Support 2026
| Feature | Chrome | Safari | Firefox | Edge | Implementation |
|---|---|---|---|---|---|
| Service Worker | ✅ 100% | ✅ 100% | ✅ 100% | ✅ 100% | ✅ Essential |
| Web App Manifest | ✅ 100% | ✅ 100% | ✅ 100% | ✅ 100% | ✅ Essential |
| Offline Mode | ✅ 100% | ✅ 100% | ✅ 100% | ✅ 100% | ✅ Recommended |
| Push Notifications | ✅ Desktop+Mobile | ⚠️ iOS 16.4+ | ✅ Desktop+Mobile | ✅ Desktop+Mobile | ⚡ Optional |
| Add to Home Screen | ✅ 100% | ✅ 100% | ❌ Android only | ✅ 100% | ✅ Recommended |
| Background Sync | ✅ 100% | ❌ Not supported | ✅ 100% | ✅ 100% | ⚡ Advanced |
Core PWA Requirements 2026 :
- ✅ HTTPS (mandatory)
- ✅ Web App Manifest (
manifest.json) - ✅ Service Worker (offline support)
- ✅ Responsive design
- ✅ Fast loading (Core Web Vitals)
1. Qu'est-ce qu'une PWA ?
Définition
Progressive Web App = Web app with native app capabilities :
- 📱 Installable : Add to home screen (iOS/Android)
- 🔌 Offline : Works without internet
- 🔔 Push notifications : Re-engage users
- ⚡ Fast : Cached resources, instant load
- 📲 App-like : Full-screen, no browser UI
Not a PWA :
- ❌ Requires internet connection
- ❌ Shows browser address bar
- ❌ Can't send push notifications
PWA Benefits
User benefits :
- ✅ No app store : Install from browser
- ✅ Lightweight : ~300KB vs 50MB native app
- ✅ Auto-updates : No manual updates
- ✅ Cross-platform : 1 app = iOS + Android + Desktop
Business benefits (real data) :
- Twitter Lite PWA : 65% increase pages/session
- Starbucks PWA : 2× daily active users
- Pinterest PWA : 60% increase engagement
- Uber PWA : 50KB vs 25MB native app
Client case (e-commerce PWA) :
- Conversion rate : +38%
- Add to cart : +52%
- Offline visits : 22% total traffic
2. Setup PWA Next.js (next-pwa)
Installation
pnpm add next-pwa
pnpm add -D webpack
Next.js config :
// next.config.ts
import withPWA from 'next-pwa'
const nextConfig = {
// Your existing config
}
export default withPWA({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development', // Disable in dev
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
},
},
},
{
urlPattern: /^https:\/\/.*\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxEntries: 64,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
},
},
{
urlPattern: /^https:\/\/api\.myapp\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5 minutes
},
},
},
],
})(nextConfig)
3. Web App Manifest
Create manifest.json
// public/manifest.json
{
"name": "My SaaS App",
"short_name": "MySaaS",
"description": "Complete SaaS platform for modern teams",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"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"],
"shortcuts": [
{
"name": "Dashboard",
"url": "/dashboard",
"icons": [
{
"src": "/icons/dashboard.png",
"sizes": "96x96"
}
]
},
{
"name": "New Project",
"url": "/projects/new",
"icons": [
{
"src": "/icons/new-project.png",
"sizes": "96x96"
}
]
}
],
"screenshots": [
{
"src": "/screenshots/desktop-1.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile-1.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
]
}
Link manifest in layout :
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
manifest: '/manifest.json',
themeColor: '#3b82f6',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'My SaaS',
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<head>
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
</head>
<body>{children}</body>
</html>
)
}
4. Service Worker (Offline Mode)
Caching Strategies
1. Cache First (static assets) :
Request → Cache → Network (fallback)
Use for : Images, fonts, CSS, JS
2. Network First (dynamic data) :
Request → Network → Cache (fallback)
Use for : API calls, user data
3. Stale While Revalidate (best UX) :
Request → Cache (instant) → Network (background update)
Use for : Home page, product listings
4. Network Only (authenticated) :
Request → Network (always fresh)
Use for : Critical data (payments, auth)
Custom Service Worker
// public/sw.js
const CACHE_NAME = 'my-app-v1'
const OFFLINE_URL = '/offline'
// Install event (first time)
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(['/', '/offline', '/icons/icon-192x192.png', '/styles/globals.css'])
})
)
self.skipWaiting()
})
// Activate event (update)
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))
)
})
)
self.clients.claim()
})
// Fetch event (intercept requests)
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(OFFLINE_URL)
})
)
} else {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request)
})
)
}
})
Offline page :
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold">You're Offline</h1>
<p className="mt-4 text-gray-600">Check your internet connection and try again.</p>
<button
onClick={() => window.location.reload()}
className="mt-6 rounded bg-blue-600 px-6 py-3 text-white"
>
Retry
</button>
</div>
</div>
)
}
5. Install Prompt (Add to Home Screen)
Detect Installation
// components/install-prompt.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 [showPrompt, setShowPrompt] = useState(false)
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e as BeforeInstallPromptEvent)
setShowPrompt(true)
}
window.addEventListener('beforeinstallprompt', handler)
return () => window.removeEventListener('beforeinstallprompt', handler)
}, [])
async function handleInstall() {
if (!deferredPrompt) return
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
console.log('PWA installed')
}
setDeferredPrompt(null)
setShowPrompt(false)
}
if (!showPrompt) return null
return (
<div className="fixed bottom-4 left-4 right-4 rounded-lg bg-white p-4 shadow-lg">
<h3 className="font-semibold">Install App</h3>
<p className="text-sm text-gray-600">Add to your home screen for a better experience</p>
<div className="mt-4 flex gap-2">
<button onClick={handleInstall} className="rounded bg-blue-600 px-4 py-2 text-white">
Install
</button>
<button onClick={() => setShowPrompt(false)} className="rounded bg-gray-200 px-4 py-2">
Not Now
</button>
</div>
</div>
)
}
Usage :
// app/layout.tsx
import { InstallPrompt } from '@/components/install-prompt'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<InstallPrompt />
</body>
</html>
)
}
6. Push Notifications
Setup Notifications
// lib/notifications.ts
export async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('Notifications not supported')
return false
}
if (Notification.permission === 'granted') {
return true
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission()
return permission === 'granted'
}
return false
}
export async function subscribeUserToPush() {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
})
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
})
return subscription
}
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
}
Request permission (component) :
// components/notification-prompt.tsx
'use client'
import { requestNotificationPermission, subscribeUserToPush } from '@/lib/notifications'
import { useState } from 'react'
export function NotificationPrompt() {
const [shown, setShown] = useState(true)
async function handleEnable() {
const granted = await requestNotificationPermission()
if (granted) {
await subscribeUserToPush()
setShown(false)
}
}
if (!shown || typeof window === 'undefined' || !('Notification' in window)) {
return null
}
return (
<div className="rounded bg-blue-50 p-4">
<p className="font-medium">Enable notifications?</p>
<p className="text-sm text-gray-600">Get notified about important updates</p>
<button onClick={handleEnable} className="mt-2 rounded bg-blue-600 px-4 py-2 text-white">
Enable
</button>
</div>
)
}
Send notification (server) :
// app/api/push/send/route.ts
import webpush from 'web-push'
import { NextResponse } from 'next/server'
// Configure VAPID keys
webpush.setVapidDetails(
'mailto:contact@myapp.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
export async function POST(req: Request) {
const { subscription, title, body } = await req.json()
const payload = JSON.stringify({
title,
body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
})
try {
await webpush.sendNotification(subscription, payload)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Push notification error:', error)
return NextResponse.json({ error: 'Failed to send' }, { status: 500 })
}
}
Generate VAPID keys :
npx web-push generate-vapid-keys
# Output:
# Public Key: BKxB...
# Private Key: 9Ey...
Add to .env :
VAPID_PUBLIC_KEY=BKxB...
VAPID_PRIVATE_KEY=9Ey...
7. Background Sync (Offline Form Submissions)
// public/sw.js (add to existing service worker)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-form') {
event.waitUntil(syncForm())
}
})
async function syncForm() {
const cache = await caches.open('form-cache')
const requests = await cache.keys()
for (const request of requests) {
const response = await cache.match(request)
const data = await response.json()
try {
await fetch('/api/forms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
await cache.delete(request)
} catch (error) {
console.error('Sync failed:', error)
}
}
}
Client-side (queue form) :
// lib/offline-forms.ts
export async function submitFormOffline(data: any) {
if (navigator.onLine) {
// Online: submit directly
return fetch('/api/forms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
} else {
// Offline: cache for later
const cache = await caches.open('form-cache')
const request = new Request(`/offline-forms/${Date.now()}`)
const response = new Response(JSON.stringify(data))
await cache.put(request, response)
// Register background sync
const registration = await navigator.serviceWorker.ready
await registration.sync.register('sync-form')
return { success: true, queued: true }
}
}
8. PWA Analytics (Track Installs)
// app/layout.tsx
'use client'
import { useEffect } from 'react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Track PWA install
window.addEventListener('appinstalled', () => {
// Send to analytics
if (window.gtag) {
window.gtag('event', 'pwa_installed')
}
})
// Track display mode
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
if (isStandalone) {
if (window.gtag) {
window.gtag('event', 'pwa_launched')
}
}
}, [])
return <html>{children}</html>
}
9. iOS Specific (Safari PWA)
Meta Tags
// app/layout.tsx
export const metadata = {
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'My SaaS',
startupImage: [
{
url: '/splash/iphone5_splash.png',
media: '(device-width: 320px) and (device-height: 568px)',
},
{
url: '/splash/iphone6_splash.png',
media: '(device-width: 375px) and (device-height: 667px)',
},
{
url: '/splash/iphoneplus_splash.png',
media: '(device-width: 414px) and (device-height: 736px)',
},
{
url: '/splash/iphonex_splash.png',
media: '(device-width: 375px) and (device-height: 812px)',
},
],
},
}
iOS install instructions :
// components/ios-install-prompt.tsx
'use client'
import { useEffect, useState } from 'react'
export function IOSInstallPrompt() {
const [showPrompt, setShowPrompt] = useState(false)
useEffect(() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
if (isIOS && !isStandalone) {
setShowPrompt(true)
}
}, [])
if (!showPrompt) return null
return (
<div className="fixed bottom-0 left-0 right-0 bg-white p-4 shadow-lg">
<p className="font-medium">Install this app on your iPhone:</p>
<ol className="mt-2 text-sm text-gray-600">
<li>1. Tap the Share button</li>
<li>2. Tap "Add to Home Screen"</li>
</ol>
<button onClick={() => setShowPrompt(false)} className="mt-4 rounded bg-gray-200 px-4 py-2">
Got it
</button>
</div>
)
}
10. Testing PWA
Lighthouse Audit
# Install Lighthouse CLI
npm install -g lighthouse
# Run audit
lighthouse https://yoursite.com --view
PWA criteria :
- ✅ Installable
- ✅ Works offline
- ✅ HTTPS
- ✅ Responsive
- ✅ Fast load (<3s)
Target score : 90+/100
Chrome DevTools
Test offline mode :
- DevTools → Application → Service Workers
- Check "Offline"
- Reload page → Should show offline page
Test Cache :
- DevTools → Application → Cache Storage
- Inspect cached resources
Simulate install :
- DevTools → Application → Manifest
- Click "Add to Home Screen"
Conclusion
PWA = future web apps. 2026 = table stakes modern apps.
Minimum PWA setup (1 hour) :
- ✅ Install
next-pwa - ✅ Create
manifest.json - ✅ Add offline page
- ✅ Test Lighthouse audit
Full PWA setup (1 day) :
- ✅ Custom service worker
- ✅ Install prompt
- ✅ Push notifications
- ✅ Background sync
- ✅ iOS optimization
ROI PWA :
- Engagement : +127% (install users)
- Session duration : +85%
- Bounce rate : -42%
- App store dependence : 0%
Worth it ? ✅ Absolutely (especially e-commerce, SaaS, content apps)
FAQ
PWA = native app replacement ?
⚠️ Pas toujours. PWA excellent pour content, e-commerce, SaaS. Native apps better pour : heavy 3D games, advanced camera, Bluetooth, NFC.
Push notifications iOS support ?
✅ Yes since iOS 16.4 (April 2023). Requires PWA installed home screen. Desktop Safari still no support.
PWA SEO impact ?
✅ Positive. Google favors fast, offline-capable sites. Core Web Vitals improved = better rankings.
Can PWA access camera ?
✅ Yes. navigator.mediaDevices.getUserMedia() works PWA. Same permissions as browser.
Articles connexes :