PWA Next.js : Progressive Web App Guide 2026

Brandon Sueur16 min

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 :

  1. ✅ HTTPS (mandatory)
  2. ✅ Web App Manifest (manifest.json)
  3. ✅ Service Worker (offline support)
  4. ✅ Responsive design
  5. ✅ 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 :

  1. DevTools → Application → Service Workers
  2. Check "Offline"
  3. Reload page → Should show offline page

Test Cache :

  1. DevTools → Application → Cache Storage
  2. Inspect cached resources

Simulate install :

  1. DevTools → Application → Manifest
  2. Click "Add to Home Screen"

Conclusion

PWA = future web apps. 2026 = table stakes modern apps.

Minimum PWA setup (1 hour) :

  1. ✅ Install next-pwa
  2. ✅ Create manifest.json
  3. ✅ Add offline page
  4. ✅ Test Lighthouse audit

Full PWA setup (1 day) :

  1. ✅ Custom service worker
  2. ✅ Install prompt
  3. ✅ Push notifications
  4. ✅ Background sync
  5. ✅ 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 :