Cours Next.js · Module 8: Déploiement et production
Progressive Web Apps et Service Workers
Dans cette leçon11
Les Progressive Web Apps (PWA) sont des applications web modernes qui combinent les meilleures caractéristiques des applications web et natives. Next.js offre un excellent support pour les PWA, permettant de créer des applications rapides, fiables et engageantes qui fonctionnent aussi hors ligne. Dans ce module, nous allons apprendre à implémenter des PWA dans Next.js à l'aide des Service Workers.
Que sont les Progressive Web Apps ?
Les Progressive Web Apps sont des applications web qui utilisent des technologies web modernes pour offrir aux utilisateurs une expérience proche des applications natives. Les caractéristiques clés des PWA sont :
- Progressive - fonctionnent pour chaque utilisateur, quel que soit le navigateur
- Responsive - s'adaptent aux différentes tailles d'écran
- Connectivity Independent - fonctionnent hors ligne ou avec une mauvaise connexion
- App-like - offrent une navigation et des interactions comme dans les applications natives
- Fresh - toujours à jour grâce au Service Worker
- Safe - servies via HTTPS
- Discoverable - identifiées comme des « applications » par les moteurs de recherche
- Re-engageable - permettent le réengagement via les push notifications
- Installable - peuvent être installées sur un appareil sans app store
- Linkable - partage facile via une URL
Service Workers - le fondement des PWA
Les Service Workers sont des scripts JavaScript qui s'exécutent en arrière-plan, indépendamment du thread principal de l'application. Ils permettent :
- La mise en cache des ressources pour le travail hors ligne
- L'interception et la modification des requêtes réseau
- L'envoi de push notifications
- La synchronisation en arrière-plan
- L'installation de l'application
Cycle de vie d'un Service Worker
- Registration - enregistrement du Service Worker
- Installation - installation et configuration du cache
- Activation - activation et nettoyage des anciens caches
- Fetch - interception des requêtes réseau
- Update - mise à jour vers une nouvelle version
Implémenter une PWA dans Next.js
1. Installation et configuration de next-pwa
La façon la plus simple d'ajouter des fonctionnalités PWA à une application Next.js est d'utiliser le paquet next-pwa :
1npm install next-pwa
2# or
3yarn add next-pwaMets à jour next.config.js :
1// next.config.js
2const withPWA = require('next-pwa')({
3 dest: 'public',
4 register: true,
5 skipWaiting: true,
6 disable: process.env.NODE_ENV === 'development', // Disable in development
7 runtimeCaching: [
8 {
9 urlPattern: /^https?.*/,
10 handler: 'NetworkFirst',
11 options: {
12 cacheName: 'offlineCache',
13 expiration: {
14 maxEntries: 200,
15 maxAgeSeconds: 24 * 60 * 60, // 24 heures
16 },
17 },
18 },
19 ],
20});
21
22module.exports = withPWA({
23 // Ta configuration Next.js standard
24 reactStrictMode: true,
25 swcMinify: true,
26});2. Le manifest de l'application
Le manifest est un fichier JSON qui contient les métadonnées de l'application PWA. Crée le fichier public/manifest.json :
1{
2 "name": "Mon application PWA",
3 "short_name": "MojaApp",
4 "description": "Example Progressive Web App built with Next.js",
5 "start_url": "/",
6 "display": "standalone",
7 "background_color": "#ffffff",
8 "theme_color": "#000000",
9 "orientation": "portrait-primary",
10 "icons": [
11 {
12 "src": "/icons/icon-72x72.png",
13 "sizes": "72x72",
14 "type": "image/png",
15 "purpose": "maskable any"
16 },
17 {
18 "src": "/icons/icon-96x96.png",
19 "sizes": "96x96",
20 "type": "image/png",
21 "purpose": "maskable any"
22 },
23 {
24 "src": "/icons/icon-128x128.png",
25 "sizes": "128x128",
26 "type": "image/png",
27 "purpose": "maskable any"
28 },
29 {
30 "src": "/icons/icon-144x144.png",
31 "sizes": "144x144",
32 "type": "image/png",
33 "purpose": "maskable any"
34 },
35 {
36 "src": "/icons/icon-152x152.png",
37 "sizes": "152x152",
38 "type": "image/png",
39 "purpose": "maskable any"
40 },
41 {
42 "src": "/icons/icon-192x192.png",
43 "sizes": "192x192",
44 "type": "image/png",
45 "purpose": "maskable any"
46 },
47 {
48 "src": "/icons/icon-384x384.png",
49 "sizes": "384x384",
50 "type": "image/png",
51 "purpose": "maskable any"
52 },
53 {
54 "src": "/icons/icon-512x512.png",
55 "sizes": "512x512",
56 "type": "image/png",
57 "purpose": "maskable any"
58 }
59 ],
60 "categories": ["productivity", "utilities"],
61 "screenshots": [
62 {
63 "src": "/screenshots/desktop.png",
64 "sizes": "1280x720",
65 "type": "image/png",
66 "form_factor": "wide"
67 },
68 {
69 "src": "/screenshots/mobile.png",
70 "sizes": "750x1334",
71 "type": "image/png",
72 "form_factor": "narrow"
73 }
74 ]
75}3. Ajouter les meta tags au HTML
Mets à jour le layout principal de l'application pour ajouter les meta tags nécessaires :
1// app/layout.tsx
2import type { Metadata, Viewport } from 'next';
3
4export const metadata: Metadata = {
5 title: 'Mon application PWA',
6 description: 'Example Progressive Web App',
7 generator: 'Next.js',
8 manifest: '/manifest.json',
9 keywords: ['nextjs', 'pwa', 'react'],
10 authors: [
11 { name: 'Your Team' },
12 ],
13 creator: 'Your Team',
14 formatDetection: {
15 email: false,
16 address: false,
17 telephone: false,
18 },
19 metadataBase: new URL('https://twoja-domena.com'),
20 alternates: {
21 canonical: '/',
22 },
23 openGraph: {
24 type: 'website',
25 siteName: 'Mon application PWA',
26 title: {
27 default: 'Mon application PWA',
28 template: '%s | Mon application PWA',
29 },
30 description: 'Example Progressive Web App',
31 },
32 twitter: {
33 card: 'summary',
34 title: {
35 default: 'Mon application PWA',
36 template: '%s | Mon application PWA',
37 },
38 description: 'Example Progressive Web App',
39 },
40 appleWebApp: {
41 title: 'Mon application PWA',
42 statusBarStyle: 'default',
43 capable: true,
44 },
45};
46
47export const viewport: Viewport = {
48 themeColor: [
49 { media: '(prefers-color-scheme: light)', color: '#ffffff' },
50 { media: '(prefers-color-scheme: dark)', color: '#000000' },
51 ],
52 minimumScale: 1,
53 initialScale: 1,
54 width: 'device-width',
55 shrinkToFit: false,
56 viewportFit: 'cover',
57};
58
59export default function RootLayout({
60 children,
61}: {
62 children: React.ReactNode;
63}) {
64 return (
65 <html lang="pl">
66 <head>
67 <link rel="icon" href="/favicon.ico" />
68 <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
69 </head>
70 <body>
71 {children}
72 </body>
73 </html>
74 );
75}Implémentation d'un Service Worker personnalisé
Si tu as besoin de plus de contrôle sur le Service Worker, tu peux créer ta propre implémentation :
1. Création du Service Worker
1// public/sw.js
2const CACHE_NAME = 'moja-app-v1';
3const urlsToCache = [
4 '/',
5 '/static/js/bundle.js',
6 '/static/css/main.css',
7 '/manifest.json',
8];
9
10// Install event - resource caching
11self.addEventListener('install', (event) => {
12 event.waitUntil(
13 caches.open(CACHE_NAME)
14 .then((cache) => {
15 console.log('Cache ouvert');
16 return cache.addAll(urlsToCache);
17 })
18 .then(() => {
19 // Forces activation of the new Service Worker
20 return self.skipWaiting();
21 })
22 );
23});
24
25// Activate event - nettoyage des anciens caches
26self.addEventListener('activate', (event) => {
27 event.waitUntil(
28 caches.keys().then((cacheNames) => {
29 return Promise.all(
30 cacheNames.map((cacheName) => {
31 if (cacheName !== CACHE_NAME) {
32 console.log('Suppression de l\'ancien cache :', cacheName);
33 return caches.delete(cacheName);
34 }
35 })
36 );
37 }).then(() => {
38 // Take control over all clients
39 return self.clients.claim();
40 })
41 );
42});
43
44// Fetch event - stratégie cache-first
45self.addEventListener('fetch', (event) => {
46 event.respondWith(
47 caches.match(event.request)
48 .then((response) => {
49 // Return from cache if available
50 if (response) {
51 return response;
52 }
53
54 // Sinon, récupère depuis le réseau
55 return fetch(event.request).then((response) => {
56 // Check if we received a valid response
57 if (!response || response.status !== 200 || response.type !== 'basic') {
58 return response;
59 }
60
61 // Clone the response
62 const responseToCache = response.clone();
63
64 caches.open(CACHE_NAME)
65 .then((cache) => {
66 cache.put(event.request, responseToCache);
67 });
68
69 return response;
70 }).catch(() => {
71 // If the request failed, return the offline page
72 if (event.request.destination === 'document') {
73 return caches.match('/offline.html');
74 }
75 });
76 })
77 );
78});
79
80// Background sync
81self.addEventListener('sync', (event) => {
82 if (event.tag === 'background-sync') {
83 event.waitUntil(doBackgroundSync());
84 }
85});
86
87function doBackgroundSync() {
88 // Implémentation de la synchronisation en arrière-plan
89 return fetch('/api/sync')
90 .then(response => response.json())
91 .then(data => {
92 console.log('Background sync completed:', data);
93 })
94 .catch(err => {
95 console.error('Background sync failed:', err);
96 });
97}2. Enregistrement du Service Worker
1// lib/serviceWorker.ts
2export function registerSW() {
3 if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
4 window.addEventListener('load', () => {
5 navigator.serviceWorker.register('/sw.js')
6 .then((registration) => {
7 console.log('SW enregistré : ', registration);
8
9 // Vérification des mises à jour
10 registration.addEventListener('updatefound', () => {
11 const newWorker = registration.installing;
12 if (newWorker) {
13 newWorker.addEventListener('statechange', () => {
14 if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
15 // A new Service Worker is available
16 showUpdateAvailableNotification();
17 }
18 });
19 }
20 });
21 })
22 .catch((error) => {
23 console.log('SW registration failed: ', error);
24 });
25 });
26 }
27}
28
29export function unregisterSW() {
30 if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
31 navigator.serviceWorker.ready.then((registration) => {
32 registration.unregister();
33 });
34 }
35}
36
37function showUpdateAvailableNotification() {
38 // Show notification about available update
39 if (confirm('A new version of the application is available. Would you like to update it?')) {
40 window.location.reload();
41 }
42}3. Utilisation dans un composant
1// app/page.tsx
2'use client';
3
4import { useEffect } from 'react';
5import { registerSW } from '@/lib/serviceWorker';
6
7export default function HomePage() {
8 useEffect(() => {
9 registerSW();
10 }, []);
11
12 return (
13 <div>
14 <h1>Mon application PWA</h1>
15 <p>Bienvenue dans l’application Progressive Web App !</p>
16 </div>
17 );
18}Stratégies de cache avancées
Différents types de contenu nécessitent différentes stratégies de mise en cache :
1. Cache First - pour les ressources statiques
1// Stratégie Cache First
2self.addEventListener('fetch', (event) => {
3 if (event.request.destination === 'image' ||
4 event.request.destination === 'style' ||
5 event.request.destination === 'script') {
6 event.respondWith(
7 caches.match(event.request).then((cachedResponse) => {
8 return cachedResponse || fetch(event.request).then((response) => {
9 const responseClone = response.clone();
10 caches.open(CACHE_NAME).then((cache) => {
11 cache.put(event.request, responseClone);
12 });
13 return response;
14 });
15 })
16 );
17 }
18});2. Network First - pour le contenu dynamique
1// Stratégie Network First
2self.addEventListener('fetch', (event) => {
3 if (event.request.url.includes('/api/')) {
4 event.respondWith(
5 fetch(event.request)
6 .then((response) => {
7 const responseClone = response.clone();
8 caches.open(CACHE_NAME).then((cache) => {
9 cache.put(event.request, responseClone);
10 });
11 return response;
12 })
13 .catch(() => {
14 return caches.match(event.request);
15 })
16 );
17 }
18});3. Stale While Revalidate - pour le contenu qui change souvent
1// Stratégie Stale While Revalidate
2self.addEventListener('fetch', (event) => {
3 if (event.request.url.includes('/news/')) {
4 event.respondWith(
5 caches.match(event.request).then((cachedResponse) => {
6 const fetchPromise = fetch(event.request).then((networkResponse) => {
7 caches.open(CACHE_NAME).then((cache) => {
8 cache.put(event.request, networkResponse.clone());
9 });
10 return networkResponse;
11 });
12
13 return cachedResponse || fetchPromise;
14 })
15 );
16 }
17});Implémentation des fonctions offline
1. Page offline
Crée une page offline dédiée :
1// app/offline/page.tsx
2export default function OfflinePage() {
3 return (
4 <div className="min-h-screen flex items-center justify-center bg-gray-100">
5 <div className="text-center">
6 <div className="mb-4">
7 <svg className="mx-auto h-16 w-16 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
8 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M12 2.25a9.75 9.75 0 1 0 0 19.5 9.75 9.75 0 0 0 0-19.5Z" />
9 </svg>
10 </div>
11 <h1 className="text-2xl font-bold text-gray-900 mb-2">You are offline</h1>
12 <p className="text-gray-600 mb-4">
13 We cannot connect to the internet. Check your connection and try again.
14 </p>
15 <button
16 onClick={() => window.location.reload()}
17 className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded"
18 >
19 Try again
20 </button>
21 </div>
22 </div>
23 );
24}2. Détection de l'état online/offline
1// hooks/useOnlineStatus.ts
2import { useState, useEffect } from 'react';
3
4export function useOnlineStatus() {
5 const [isOnline, setIsOnline] = useState(true);
6
7 useEffect(() => {
8 function handleOnline() {
9 setIsOnline(true);
10 }
11
12 function handleOffline() {
13 setIsOnline(false);
14 }
15
16 window.addEventListener('online', handleOnline);
17 window.addEventListener('offline', handleOffline);
18
19 // Check initial state
20 setIsOnline(navigator.onLine);
21
22 return () => {
23 window.removeEventListener('online', handleOnline);
24 window.removeEventListener('offline', handleOffline);
25 };
26 }, []);
27
28 return isOnline;
29}3. Composant indicateur du statut de connexion
1// components/OnlineStatusIndicator.tsx
2'use client';
3
4import { useOnlineStatus } from '@/hooks/useOnlineStatus';
5
6export default function OnlineStatusIndicator() {
7 const isOnline = useOnlineStatus();
8
9 if (isOnline) {
10 return null; // Don't show anything when there is a connection
11 }
12
13 return (
14 <div className="fixed top-0 left-0 right-0 bg-red-500 text-white text-center py-2 z-50">
15 <span className="text-sm font-medium">
16 No internet connection - you are operating in offline mode
17 </span>
18 </div>
19 );
20}Push Notifications
Les push notifications permettent de réengager l'utilisateur :
1. Enregistrement de l'abonnement
1// lib/notifications.ts
2
3// Fonction pour convertir la clé VAPID
4function urlBase64ToUint8Array(base64String: string) {
5 const padding = '='.repeat((4 - base64String.length % 4) % 4);
6 const base64 = (base64String + padding)
7 .replace(/\-/g, '+')
8 .replace(/_/g, '/');
9
10 const rawData = window.atob(base64);
11 const outputArray = new Uint8Array(rawData.length);
12
13 for (let i = 0; i < rawData.length; ++i) {
14 outputArray[i] = rawData.charCodeAt(i);
15 }
16 return outputArray;
17}
18
19export async function subscribeToPushNotifications() {
20 if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
21 throw new Error('Push notifications are not supported');
22 }
23
24 const registration = await navigator.serviceWorker.ready;
25
26 // Check if a subscription already exists
27 let subscription = await registration.pushManager.getSubscription();
28
29 if (!subscription) {
30 const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
31 if (!vapidPublicKey) {
32 throw new Error('VAPID public key is not configured');
33 }
34
35 subscription = await registration.pushManager.subscribe({
36 userVisibleOnly: true,
37 applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
38 });
39 }
40
41 // Send subscription to the server
42 await fetch('/api/notifications/subscribe', {
43 method: 'POST',
44 headers: {
45 'Content-Type': 'application/json',
46 },
47 body: JSON.stringify(subscription),
48 });
49
50 return subscription;
51}
52
53export async function unsubscribeFromPushNotifications() {
54 const registration = await navigator.serviceWorker.ready;
55 const subscription = await registration.pushManager.getSubscription();
56
57 if (subscription) {
58 await subscription.unsubscribe();
59
60 // Informe le serveur de l'annulation de l'abonnement
61 await fetch('/api/notifications/unsubscribe', {
62 method: 'POST',
63 headers: {
64 'Content-Type': 'application/json',
65 },
66 body: JSON.stringify(subscription),
67 });
68 }
69}2. Gestion des push notifications dans le Service Worker
1// public/sw.js (addition to previous code)
2
3// Handling push notifications
4self.addEventListener('push', (event) => {
5 const options = {
6 body: 'You have a new message!',
7 icon: '/icons/icon-192x192.png',
8 badge: '/icons/badge-72x72.png',
9 vibrate: [100, 50, 100],
10 data: {
11 dateOfArrival: Date.now(),
12 primaryKey: 1
13 },
14 actions: [
15 {
16 action: 'explore',
17 title: 'See more',
18 icon: '/icons/checkmark.png'
19 },
20 {
21 action: 'close',
22 title: 'Zamknij',
23 icon: '/icons/xmark.png'
24 }
25 ]
26 };
27
28 if (event.data) {
29 const data = event.data.json();
30 options.body = data.body || options.body;
31 options.data = { ...options.data, ...data.data };
32 }
33
34 event.waitUntil(
35 self.registration.showNotification('Mon application PWA', options)
36 );
37});
38
39// Handling notification click
40self.addEventListener('notificationclick', (event) => {
41 event.notification.close();
42
43 if (event.action === 'explore') {
44 // Open a specific page
45 event.waitUntil(
46 clients.openWindow('/notifications')
47 );
48 } else if (event.action === 'close') {
49 // Ferme la notification
50 return;
51 } else {
52 // Default action - open the main page
53 event.waitUntil(
54 clients.openWindow('/')
55 );
56 }
57});3. Endpoint API pour envoyer des notifications
1// app/api/notifications/send/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import webpush from 'web-push';
4import { prisma } from '@/lib/prisma';
5
6// Configuration VAPID
7webpush.setVapidDetails(
8 'mailto:your-email@example.com',
9 process.env.VAPID_PUBLIC_KEY!,
10 process.env.VAPID_PRIVATE_KEY!
11);
12
13export async function POST(request: NextRequest) {
14 try {
15 const { title, body, url } = await request.json();
16
17 // Récupère tous les abonnements de la base de données
18 const subscriptions = await prisma.pushSubscription.findMany();
19
20 const payload = JSON.stringify({
21 title,
22 body,
23 url,
24 icon: '/icons/icon-192x192.png',
25 });
26
27 // Envoie les notifications à tous les abonnements
28 const promises = subscriptions.map(async (subscription) => {
29 try {
30 await webpush.sendNotification(
31 {
32 endpoint: subscription.endpoint,
33 keys: {
34 p256dh: subscription.p256dh,
35 auth: subscription.auth,
36 },
37 },
38 payload
39 );
40 } catch (error) {
41 console.error('Error sending notification:', error);
42
43 // If the subscription is invalid, remove it
44 if (error.statusCode === 410) {
45 await prisma.pushSubscription.delete({
46 where: { id: subscription.id },
47 });
48 }
49 }
50 });
51
52 await Promise.all(promises);
53
54 return NextResponse.json({ success: true });
55 } catch (error) {
56 console.error('Error sending notifications:', error);
57 return NextResponse.json(
58 { error: 'Failed to send notifications' },
59 { status: 500 }
60 );
61 }
62}Installation de l'application (Add to Home Screen)
1. Composant InstallPrompt
1// components/InstallPrompt.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5
6interface BeforeInstallPromptEvent extends Event {
7 readonly platforms: string[];
8 readonly userChoice: Promise<{
9 outcome: 'accepted' | 'dismissed';
10 platform: string;
11 }>;
12 prompt(): Promise<void>;
13}
14
15export default function InstallPrompt() {
16 const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
17 const [showInstallButton, setShowInstallButton] = useState(false);
18
19 useEffect(() => {
20 const handler = (e: Event) => {
21 // Prevent automatic prompt display
22 e.preventDefault();
23 setDeferredPrompt(e as BeforeInstallPromptEvent);
24 setShowInstallButton(true);
25 };
26
27 window.addEventListener('beforeinstallprompt', handler);
28
29 // Check if the application is already installed
30 window.addEventListener('appinstalled', () => {
31 console.log('PWA has been installed');
32 setShowInstallButton(false);
33 setDeferredPrompt(null);
34 });
35
36 return () => {
37 window.removeEventListener('beforeinstallprompt', handler);
38 };
39 }, []);
40
41 const handleInstallClick = async () => {
42 if (!deferredPrompt) return;
43
44 // Affiche le prompt d'installation
45 deferredPrompt.prompt();
46
47 // Wait for user choice
48 const { outcome } = await deferredPrompt.userChoice;
49
50 if (outcome === 'accepted') {
51 console.log('User accepted the installation');
52 } else {
53 console.log('User rejected the installation');
54 }
55
56 setDeferredPrompt(null);
57 setShowInstallButton(false);
58 };
59
60 if (!showInstallButton) {
61 return null;
62 }
63
64 return (
65 <div className="fixed bottom-4 right-4 bg-blue-500 text-white p-4 rounded-lg shadow-lg">
66 <div className="flex items-center space-x-3">
67 <div>
68 <h3 className="font-semibold">Install application</h3>
69 <p className="text-sm opacity-90">Add to home screen for faster access</p>
70 </div>
71 <div className="flex space-x-2">
72 <button
73 onClick={handleInstallClick}
74 className="bg-white text-blue-500 px-3 py-1 rounded text-sm font-medium"
75 >
76 Zainstaluj
77 </button>
78 <button
79 onClick={() => setShowInstallButton(false)}
80 className="text-white opacity-75 hover:opacity-100"
81 >
82 ×
83 </button>
84 </div>
85 </div>
86 </div>
87 );
88}Tester une PWA
1. Lighthouse Audit
Lighthouse est un outil de Google pour l'audit des PWA. Tu peux le lancer :
- Dans Chrome DevTools (onglet Lighthouse)
- En ligne de commande :
npm install -g lighthouse - En ligne : https://web.dev/measure/
2. PWA Builder
Microsoft PWA Builder (https://www.pwabuilder.com/) est un outil pour tester et builder des PWA.
3. Tests manuels
1// utils/pwa-test.ts
2export function testPWAFeatures() {
3 const results = {
4 serviceWorker: 'serviceWorker' in navigator,
5 pushNotifications: 'PushManager' in window,
6 notifications: 'Notification' in window,
7 backgroundSync: 'serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype,
8 installPrompt: true, // Détecté dynamiquement
9 offline: 'onLine' in navigator,
10 };
11
12 console.table(results);
13 return results;
14}Bonnes pratiques PWA
1. Performance
- Utilise les stratégies de cache appropriées
- Optimise la taille du bundle
- Implémente le lazy loading
- Utilise le Service Worker pour précharger les ressources critiques
2. User Experience
- Assure des transitions fluides entre les modes online/offline
- Implémente des skeleton screens pendant le chargement
- Ajoute des indicateurs de chargement
- Fournis des messages clairs sur le statut de connexion
3. Security
- Utilise toujours HTTPS
- Valide toutes les données en entrée
- Implémente une Content Security Policy (CSP)
- Mets régulièrement à jour le Service Worker
4. Accessibility
- Fournis le support des lecteurs d'écran
- Utilise des contrastes de couleurs appropriés
- Implémente la navigation au clavier
- Ajoute les aria-labels appropriés
Résumé
Les Progressive Web Apps sont une technologie puissante qui permet de créer des applications web avec des fonctionnalités proches des applications natives. Next.js avec le paquet next-pwa ou une implémentation de Service Worker personnalisée offre tous les outils nécessaires pour créer une PWA pleinement fonctionnelle.
Éléments clés de l'implémentation d'une PWA dans Next.js :
- Service Worker - pour la mise en cache et le travail hors ligne
- Web App Manifest - pour les métadonnées de l'application
- HTTPS - requis pour une PWA
- Responsive Design - pour différents appareils
- Push Notifications - pour l'engagement de l'utilisateur
- Install Prompts - pour l'installation sur un appareil
Les PWA permettent d'offrir une meilleure expérience utilisateur, d'augmenter l'engagement et d'améliorer les performances de l'application, surtout dans des conditions de mauvaise connexion internet.
Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Demo : Progressive Web Apps et Service Workers
4interface PWAFeature {
5 id: string;
6 name: string;
7 icon: string;
8 description: string;
9 status: 'supported' | 'partial' | 'not-supported';
10 code: string;
11}
12
13function PWADemo() {
14 const [activeTab, setActiveTab] = useState<'features' | 'manifest' | 'service-worker' | 'install'>('features');
15 const [installStep, setInstallStep] = useState(0);
16 const [cacheStrategy, setCacheStrategy] = useState<'cache-first' | 'network-first' | 'stale-while-revalidate'>('cache-first');
17
18 const features: PWAFeature[] = [
19 { id: 'offline', name: 'Offline Support', icon: 'OFF', description: 'Fonctionne sans connexion internet',
20 status: 'supported', code: "// Service Worker cache\nself.addEventListener('fetch', (event) => {\n event.respondWith(\n caches.match(event.request)\n .then(cached => cached || fetch(event.request))\n );\n});" },
21 { id: 'install', name: 'Installation', icon: 'INS', description: 'Peut être ajoutée à l\'écran d\'accueil',
22 status: 'supported', code: "// manifest.json\n{\n \"name\": \"My PWA\",\n \"short_name\": \"PWA\",\n \"start_url\": \"/\",\n \"display\": \"standalone\",\n \"background_color\": \"#0f0f23\",\n \"theme_color\": \"#64ffda\"\n}" },
23 { id: 'push', name: 'Push Notifications', icon: 'PUS', description: 'Notifications même quand l\'app est fermée',
24 status: 'supported', code: "// Subscribe to push\nconst registration = await navigator.serviceWorker.ready;\nconst subscription = await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: VAPID_PUBLIC_KEY,\n});" },
25 { id: 'sync', name: 'Background Sync', icon: 'SYN', description: 'Synchronisation des données en arrière-plan',
26 status: 'partial', code: "// Background sync\nnavigator.serviceWorker.ready.then(reg => {\n reg.sync.register('sync-data');\n});" },
27 { id: 'share', name: 'Web Share API', icon: 'SHR', description: 'Partage natif du contenu',
28 status: 'supported', code: "// Share API\nawait navigator.share({\n title: 'My App',\n text: 'Check this out!',\n url: window.location.href,\n});" },
29 ];
30
31 const [selectedFeature, setSelectedFeature] = useState(features[0]);
32
33 const statusColors: Record<string, string> = { supported: '#10b981', partial: '#f59e0b', 'not-supported': '#ef4444' };
34
35 const manifest = {
36 name: "Quantum City PWA",
37 short_name: "QuantumPWA",
38 description: "Application PWA avancée",
39 start_url: "/",
40 display: "standalone",
41 background_color: "#0f0f23",
42 theme_color: "#64ffda",
43 icons: [
44 { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
45 { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
46 ],
47 };
48
49 const cacheStrategies: Record<string, { desc: string; code: string }> = {
50 'cache-first': {
51 desc: 'Vérifie d\'abord le cache, puis le réseau',
52 code: "event.respondWith(\n caches.match(event.request).then(cached =>\n cached || fetch(event.request).then(response => {\n const clone = response.clone();\n caches.open('v1').then(cache => cache.put(event.request, clone));\n return response;\n })\n )\n);",
53 },
54 'network-first': {
55 desc: 'D\'abord le réseau, le cache en fallback',
56 code: "event.respondWith(\n fetch(event.request)\n .then(response => {\n const clone = response.clone();\n caches.open('v1').then(cache => cache.put(event.request, clone));\n return response;\n })\n .catch(() => caches.match(event.request))\n);",
57 },
58 'stale-while-revalidate': {
59 desc: 'Immédiatement depuis le cache, mise à jour en arrière-plan',
60 code: "event.respondWith(\n caches.open('v1').then(cache =>\n cache.match(event.request).then(cached => {\n const fetched = fetch(event.request)\n .then(response => {\n cache.put(event.request, response.clone());\n return response;\n });\n return cached || fetched;\n })\n )\n);",
61 },
62 };
63
64 const installSteps = [
65 { title: 'next-pwa', desc: 'npm install next-pwa' },
66 { title: 'next.config.js', desc: 'Configuration de next-pwa dans next.config.js' },
67 { title: 'manifest.json', desc: 'Crée le fichier public/manifest.json' },
68 { title: 'Icons', desc: 'Ajoute les icônes 192x192 et 512x512' },
69 { title: 'Meta tags', desc: 'Ajoute les meta tags PWA dans layout.tsx' },
70 { title: 'Terminé !', desc: 'L\'application PWA est prête' },
71 ];
72
73 return (
74 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: '20px', color: '#e0e0e0', fontFamily: 'system-ui' }}>
75 <h1 style={{ color: '#64ffda', marginBottom: '8px' }}>PWA & Service Workers</h1>
76 <p style={{ color: '#8892b0', marginBottom: '24px' }}>Progressive Web Apps dans Next.js</p>
77
78 <div style={{ display: 'flex', gap: '8px', marginBottom: '24px' }}>
79 {[
80 { id: 'features' as const, label: 'Fonctionnalités PWA' },
81 { id: 'manifest' as const, label: 'Manifest' },
82 { id: 'service-worker' as const, label: 'Service Worker' },
83 { id: 'install' as const, label: 'Installation' },
84 ].map(tab => (
85 <button key={tab.id} onClick={() => setActiveTab(tab.id)} style={{
86 padding: '10px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
87 background: activeTab === tab.id ? '#64ffda' : '#1a1a2e', color: activeTab === tab.id ? '#0f0f23' : '#8892b0', fontWeight: 'bold',
88 }}>{tab.label}</button>
89 ))}
90 </div>
91
92 {activeTab === 'features' && (
93 <div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: '20px' }}>
94 <div style={{ display: 'grid', gap: '8px', alignContent: 'start' }}>
95 {features.map(f => (
96 <div key={f.id} onClick={() => setSelectedFeature(f)} style={{
97 padding: '12px', borderRadius: '8px', cursor: 'pointer',
98 background: selectedFeature.id === f.id ? '#1a2a3e' : '#1a1a2e',
99 border: selectedFeature.id === f.id ? '1px solid #64ffda' : '1px solid #333',
100 }}>
101 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
102 <strong>{f.name}</strong>
103 <span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '12px', background: statusColors[f.status] + '30', color: statusColors[f.status] }}>{f.status}</span>
104 </div>
105 <div style={{ fontSize: '12px', color: '#8892b0', marginTop: '4px' }}>{f.description}</div>
106 </div>
107 ))}
108 </div>
109 <div style={{ background: '#1a1a2e', padding: '20px', borderRadius: '12px', border: '1px solid #333' }}>
110 <h3 style={{ color: '#64ffda', marginTop: 0 }}>{selectedFeature.name}</h3>
111 <p style={{ color: '#8892b0' }}>{selectedFeature.description}</p>
112 <pre style={{ background: '#0f0f23', padding: '12px', borderRadius: '6px', fontSize: '12px', color: '#e0e0e0', overflow: 'auto', whiteSpace: 'pre-wrap' }}>{selectedFeature.code}</pre>
113 </div>
114 </div>
115 )}
116
117 {activeTab === 'manifest' && (
118 <div style={{ background: '#1a1a2e', padding: '20px', borderRadius: '12px', border: '1px solid #333' }}>
119 <h3 style={{ color: '#64ffda', marginTop: 0 }}>manifest.json</h3>
120 <pre style={{ background: '#0f0f23', padding: '16px', borderRadius: '8px', fontSize: '13px', color: '#e0e0e0', overflow: 'auto' }}>
121 {JSON.stringify(manifest, null, 2)}
122 </pre>
123 </div>
124 )}
125
126 {activeTab === 'service-worker' && (
127 <div>
128 <div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
129 {(Object.keys(cacheStrategies) as Array<keyof typeof cacheStrategies>).map(s => (
130 <button key={s} onClick={() => setCacheStrategy(s as any)} style={{
131 padding: '8px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
132 background: cacheStrategy === s ? '#7c3aed' : '#1a1a2e', color: '#fff',
133 }}>{s}</button>
134 ))}
135 </div>
136 <div style={{ background: '#1a1a2e', padding: '20px', borderRadius: '12px', border: '1px solid #333' }}>
137 <p style={{ color: '#8892b0' }}>{cacheStrategies[cacheStrategy].desc}</p>
138 <pre style={{ background: '#0f0f23', padding: '16px', borderRadius: '8px', fontSize: '12px', color: '#e0e0e0', overflow: 'auto', whiteSpace: 'pre-wrap' }}>{cacheStrategies[cacheStrategy].code}</pre>
139 </div>
140 </div>
141 )}
142
143 {activeTab === 'install' && (
144 <div style={{ background: '#1a1a2e', padding: '20px', borderRadius: '12px', border: '1px solid #333' }}>
145 <h3 style={{ color: '#64ffda', marginTop: 0 }}>Configuration d'une PWA dans Next.js</h3>
146 <div style={{ display: 'flex', gap: '8px', marginBottom: '16px', flexWrap: 'wrap' }}>
147 {installSteps.map((s, i) => (
148 <button key={i} onClick={() => setInstallStep(i)} style={{
149 padding: '8px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer', fontSize: '12px',
150 background: installStep === i ? '#64ffda' : i < installStep ? '#10b98130' : '#333',
151 color: installStep === i ? '#0f0f23' : '#8892b0',
152 }}>{i + 1}. {s.title}</button>
153 ))}
154 </div>
155 <div style={{ padding: '16px', background: '#0f0f23', borderRadius: '8px' }}>
156 <strong style={{ color: '#64ffda' }}>{installSteps[installStep].title}</strong>
157 <p style={{ color: '#8892b0', marginBottom: 0 }}>{installSteps[installStep].desc}</p>
158 </div>
159 </div>
160 )}
161 </div>
162 );
163}
164
165export default PWADemo;Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que doit inclure une application Next.js pour être considérée comme une Progressive Web App ?