PWA Next.js : Progressive Web App Guide 2026
Transform Next.js app → Progressive Web App. Service worker, offline support, push notifications, app-like experience. Production guide 2026.
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 :
Brandon Sueur
Expert en développement web et création de produits numériques. Passionné par les technologies modernes et l'innovation, je partage mes connaissances et retours d'expérience pour aider les équipes à construire de meilleurs produits.
Articles similaires
Découvrez d'autres articles qui pourraient vous intéresser.
TypeScript Strict Mode ROI - Moins Bugs, Plus Productivité Développeurs 2026
TypeScript Strict ROI: -40% bugs production, +25% productivité, +60% confiance refactoring. Migration 15-45k€, break-even 8 mois.
React Hook Form : Le guide complet pour des formulaires performants en 2026
Maîtrisez React Hook Form pour créer des formulaires React performants et accessibles. Guide complet avec validation Zod, intégration TypeScript et patterns avancés.
Turbopack vs Webpack : Benchmark Complet et Migration Next.js 2026
Turbopack promet 10x plus de performance que Webpack. Benchmark réel sur 12 projets, guide de migration Next.js 15 et analyse technique approfondie.