Construire une Progressive Web App (PWA) avec Next.js App Router

Les Progressive Web Apps (PWA) combinent le meilleur du web et des applications natives : installation sur l'écran d'accueil, fonctionnement hors-ligne, notifications push et performances optimales. Dans ce tutoriel, vous allez transformer une application Next.js App Router en PWA complète, étape par étape.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé sur votre machine
- Une connaissance de base de Next.js App Router et TypeScript
- Un éditeur de code (VS Code recommandé)
- Un navigateur compatible PWA (Chrome, Edge, Firefox)
Ce que vous allez construire
Une application Next.js qui :
- S'installe comme une application native sur mobile et desktop
- Fonctionne hors-ligne grâce au cache intelligent
- Envoie des notifications push aux utilisateurs
- Se synchronise automatiquement quand la connexion revient
- Obtient un score Lighthouse PWA de 100
Étape 1 : Créer le projet Next.js
Commençons par initialiser un nouveau projet Next.js avec TypeScript :
npx create-next-app@latest my-pwa-app --typescript --tailwind --app --src-dir
cd my-pwa-appInstallez les dépendances nécessaires pour la PWA :
npm install next-pwa @ducanh2912/next-pwa
npm install -D webpackNous utilisons @ducanh2912/next-pwa qui est le fork maintenu activement et compatible avec Next.js 14+ et App Router.
Étape 2 : Configurer next-pwa
Modifiez votre fichier next.config.ts pour activer le support PWA :
// next.config.ts
import type { NextConfig } from "next";
import withPWAInit from "@ducanh2912/next-pwa";
const withPWA = withPWAInit({
dest: "public",
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
swcMinify: true,
disable: process.env.NODE_ENV === "development",
workboxOptions: {
disableDevLogs: true,
},
});
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default withPWA(nextConfig);Cette configuration :
- Génère le service worker dans le dossier
public/ - Active le cache agressif pour la navigation côté client
- Recharge automatiquement quand la connexion revient
- Désactive le service worker en développement pour éviter les conflits
Étape 3 : Créer le Web App Manifest
Le fichier manifest indique au navigateur comment afficher votre application une fois installée. Créez src/app/manifest.ts :
// src/app/manifest.ts
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Mon Application PWA",
short_name: "MaPWA",
description:
"Une Progressive Web App construite avec Next.js App Router",
start_url: "/",
display: "standalone",
background_color: "#0a0a0a",
theme_color: "#3b82f6",
orientation: "portrait-primary",
icons: [
{
src: "/icons/icon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icons/icon-384x384.png",
sizes: "384x384",
type: "image/png",
},
{
src: "/icons/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
],
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",
},
],
categories: ["productivity", "utilities"],
};
}Les icônes avec purpose: "maskable" sont essentielles pour Android. Elles permettent au système d'appliquer un masque adaptatif à votre icône pour qu'elle s'intègre harmonieusement avec les autres applications.
Étape 4 : Ajouter les métadonnées PWA
Mettez à jour votre layout racine pour inclure les métadonnées nécessaires :
// src/app/layout.tsx
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Mon Application PWA",
description: "Une PWA construite avec Next.js App Router",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "MaPWA",
},
formatDetection: {
telephone: false,
},
};
export const viewport: Viewport = {
themeColor: "#3b82f6",
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
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 className="antialiased">{children}</body>
</html>
);
}Étape 5 : Générer les icônes PWA
Créez les icônes nécessaires. Vous pouvez utiliser un outil comme pwa-asset-generator :
mkdir -p public/icons public/screenshots
npx pwa-asset-generator ./public/logo.svg ./public/icons \
--icon-only --favicon --type png \
--padding "10%" --background "#0a0a0a"Ou créez manuellement les fichiers suivants :
public/
├── icons/
│ ├── icon-192x192.png
│ ├── icon-384x384.png
│ └── icon-512x512.png
├── screenshots/
│ ├── desktop.png
│ └── mobile.png
└── favicon.ico
Étape 6 : Implémenter le cache hors-ligne
Créez une stratégie de cache personnalisée. Ajoutez un fichier worker/index.ts :
// worker/index.ts
import { defaultCache } from "@ducanh2912/next-pwa/cache";
/** @type {import("@ducanh2912/next-pwa").RuntimeCaching[]} */
export const runtimeCaching = [
// Cache des pages HTML avec stratégie Network First
{
urlPattern: /^https:\/\/.*\.html$/,
handler: "NetworkFirst",
options: {
cacheName: "html-cache",
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 24 heures
},
},
},
// Cache des images avec stratégie Cache First
{
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
handler: "CacheFirst",
options: {
cacheName: "image-cache",
expiration: {
maxEntries: 64,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 jours
},
},
},
// Cache des polices Google
{
urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "google-fonts-cache",
expiration: {
maxEntries: 16,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 an
},
},
},
// Cache des appels API avec stratégie Stale While Revalidate
{
urlPattern: /^https:\/\/api\..*$/i,
handler: "StaleWhileRevalidate",
options: {
cacheName: "api-cache",
expiration: {
maxEntries: 32,
maxAgeSeconds: 60 * 60, // 1 heure
},
},
},
...defaultCache,
];Mettez à jour next.config.ts pour utiliser cette configuration :
// next.config.ts
import type { NextConfig } from "next";
import withPWAInit from "@ducanh2912/next-pwa";
const withPWA = withPWAInit({
dest: "public",
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
swcMinify: true,
disable: process.env.NODE_ENV === "development",
workboxOptions: {
disableDevLogs: true,
runtimeCaching: [
{
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
handler: "CacheFirst",
options: {
cacheName: "image-cache",
expiration: {
maxEntries: 64,
maxAgeSeconds: 30 * 24 * 60 * 60,
},
},
},
{
urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "google-fonts-cache",
expiration: {
maxEntries: 16,
maxAgeSeconds: 365 * 24 * 60 * 60,
},
},
},
],
},
});
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default withPWA(nextConfig);Étape 7 : Créer une page hors-ligne
Quand l'utilisateur est hors-ligne et tente d'accéder à une page non mise en cache, affichez une page d'erreur conviviale :
// src/app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-950 text-white">
<div className="text-center">
<div className="mb-6 text-6xl">📡</div>
<h1 className="mb-4 text-3xl font-bold">
Vous êtes hors-ligne
</h1>
<p className="mb-8 text-gray-400">
Vérifiez votre connexion Internet et réessayez.
</p>
<button
onClick={() => window.location.reload()}
className="rounded-lg bg-blue-600 px-6 py-3 font-semibold
transition-colors hover:bg-blue-700"
>
Réessayer
</button>
</div>
</div>
);
}La page /offline est automatiquement pré-cachée par next-pwa. Quand l'utilisateur perd la connexion, il est redirigé vers cette page au lieu de voir l'erreur par défaut du navigateur.
Étape 8 : Détecter le statut de connexion
Créez un hook personnalisé pour réagir aux changements de connexion :
// src/hooks/useOnlineStatus.ts
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
function subscribe(callback: () => void) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true; // Toujours en ligne côté serveur
}
export function useOnlineStatus() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}Utilisez ce hook dans un composant bannière :
// src/components/OnlineBanner.tsx
"use client";
import { useOnlineStatus } from "@/hooks/useOnlineStatus";
export function OnlineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-amber-600
px-4 py-2 text-center text-sm font-medium text-white">
⚠️ Vous êtes actuellement hors-ligne.
Certaines fonctionnalités peuvent être limitées.
</div>
);
}Étape 9 : Bouton d'installation personnalisé
Créez un bouton d'installation élégant qui apparaît quand le navigateur propose l'installation :
// src/components/InstallPrompt.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 [isVisible, setIsVisible] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
// Vérifier si déjà installée
if (window.matchMedia("(display-mode: standalone)").matches) {
setIsInstalled(true);
return;
}
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setIsVisible(true);
};
window.addEventListener("beforeinstallprompt", handler);
window.addEventListener("appinstalled", () => {
setIsInstalled(true);
setIsVisible(false);
setDeferredPrompt(null);
});
return () => {
window.removeEventListener("beforeinstallprompt", handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
setIsVisible(false);
}
setDeferredPrompt(null);
};
if (!isVisible || isInstalled) return null;
return (
<div className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-md
rounded-xl bg-gray-900 p-4 shadow-2xl border
border-gray-700 md:left-auto md:right-4">
<div className="flex items-center gap-4">
<div className="flex-shrink-0 text-3xl">📱</div>
<div className="flex-1">
<h3 className="font-semibold text-white">
Installer l'application
</h3>
<p className="text-sm text-gray-400">
Accès rapide depuis votre écran d'accueil
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setIsVisible(false)}
className="rounded-lg px-3 py-2 text-sm text-gray-400
hover:text-white transition-colors"
>
Plus tard
</button>
<button
onClick={handleInstall}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm
font-semibold text-white hover:bg-blue-700
transition-colors"
>
Installer
</button>
</div>
</div>
</div>
);
}Étape 10 : Notifications Push
Implémentez les notifications push pour engager vos utilisateurs. Créez d'abord le composant côté client :
// src/components/PushNotification.tsx
"use client";
import { useEffect, useState } from "react";
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
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;
}
export function PushNotification() {
const [permission, setPermission] =
useState<NotificationPermission>("default");
const [isSubscribed, setIsSubscribed] = useState(false);
useEffect(() => {
if ("Notification" in window) {
setPermission(Notification.permission);
}
}, []);
const subscribeToNotifications = async () => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Envoyer l'abonnement à votre serveur
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
setIsSubscribed(true);
setPermission("granted");
} catch (error) {
console.error(
"Erreur lors de l'abonnement aux notifications:",
error
);
}
};
if (permission === "denied") return null;
if (isSubscribed) return null;
return (
<button
onClick={subscribeToNotifications}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4
py-2 text-sm font-semibold text-white
hover:bg-green-700 transition-colors"
>
🔔 Activer les notifications
</button>
);
}Créez ensuite la route API pour gérer les abonnements :
// src/app/api/push/subscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
// En production, stockez les abonnements dans une base de données
const subscriptions: PushSubscription[] = [];
export async function POST(request: NextRequest) {
try {
const subscription = await request.json();
subscriptions.push(subscription);
return NextResponse.json(
{ message: "Abonnement enregistré" },
{ status: 201 }
);
} catch (error) {
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}Étape 11 : Synchronisation en arrière-plan
Implémentez la synchronisation des données quand la connexion revient :
// src/lib/backgroundSync.ts
"use client";
interface SyncData {
url: string;
method: string;
body: string;
timestamp: number;
}
const DB_NAME = "pwa-sync-queue";
const STORE_NAME = "pending-requests";
async function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME, {
autoIncrement: true,
});
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function queueRequest(
url: string,
method: string,
body: object
) {
const db = await openDB();
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const syncData: SyncData = {
url,
method,
body: JSON.stringify(body),
timestamp: Date.now(),
};
store.add(syncData);
// Enregistrer le sync si l'API est disponible
if ("serviceWorker" in navigator && "SyncManager" in window) {
const registration = await navigator.serviceWorker.ready;
await (registration as any).sync.register("sync-pending");
}
}
export async function processPendingRequests() {
const db = await openDB();
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const allRequests = await new Promise<SyncData[]>((resolve) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
});
for (const syncData of allRequests) {
try {
await fetch(syncData.url, {
method: syncData.method,
headers: { "Content-Type": "application/json" },
body: syncData.body,
});
} catch {
// Si ça échoue encore, on garde dans la file
return;
}
}
// Vider la file après succès
const clearTx = db.transaction(STORE_NAME, "readwrite");
clearTx.objectStore(STORE_NAME).clear();
}Utilisez cette synchronisation dans vos formulaires :
// src/components/ContactForm.tsx
"use client";
import { useState } from "react";
import { useOnlineStatus } from "@/hooks/useOnlineStatus";
import { queueRequest } from "@/lib/backgroundSync";
export function ContactForm() {
const isOnline = useOnlineStatus();
const [status, setStatus] = useState<
"idle" | "sending" | "sent" | "queued"
>("idle");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setStatus("sending");
const formData = new FormData(e.currentTarget);
const data = Object.fromEntries(formData);
if (isOnline) {
try {
await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setStatus("sent");
} catch {
await queueRequest("/api/contact", "POST", data);
setStatus("queued");
}
} else {
await queueRequest("/api/contact", "POST", data);
setStatus("queued");
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input
name="email"
type="email"
required
placeholder="votre@email.com"
className="w-full rounded-lg border border-gray-700 bg-gray-800
px-4 py-3 text-white placeholder-gray-500"
/>
<textarea
name="message"
required
placeholder="Votre message..."
rows={4}
className="w-full rounded-lg border border-gray-700 bg-gray-800
px-4 py-3 text-white placeholder-gray-500"
/>
<button
type="submit"
disabled={status === "sending"}
className="w-full rounded-lg bg-blue-600 px-6 py-3
font-semibold text-white hover:bg-blue-700
disabled:opacity-50 transition-colors"
>
{status === "sending" && "Envoi en cours..."}
{status === "idle" && "Envoyer"}
{status === "sent" && "✅ Message envoyé"}
{status === "queued" &&
"📥 Message en file (envoi automatique au retour en ligne)"}
</button>
</form>
);
}Étape 12 : Assembler le tout
Mettez à jour votre page d'accueil pour intégrer tous les composants :
// src/app/page.tsx
import { InstallPrompt } from "@/components/InstallPrompt";
import { OnlineBanner } from "@/components/OnlineBanner";
import { PushNotification } from "@/components/PushNotification";
import { ContactForm } from "@/components/ContactForm";
export default function Home() {
return (
<>
<OnlineBanner />
<main className="min-h-screen bg-gray-950 text-white">
<div className="mx-auto max-w-4xl px-4 py-16">
<h1 className="mb-4 text-5xl font-bold bg-gradient-to-r
from-blue-400 to-purple-500 bg-clip-text
text-transparent">
Mon Application PWA
</h1>
<p className="mb-8 text-xl text-gray-400">
Installable, hors-ligne, rapide.
</p>
<div className="mb-12 grid gap-6 md:grid-cols-3">
<FeatureCard
icon="📱"
title="Installable"
description="Ajoutez l'app à votre écran d'accueil"
/>
<FeatureCard
icon="🔌"
title="Hors-ligne"
description="Fonctionne sans connexion Internet"
/>
<FeatureCard
icon="🔔"
title="Notifications"
description="Restez informé en temps réel"
/>
</div>
<div className="mb-8">
<PushNotification />
</div>
<section className="rounded-xl border border-gray-800 p-6">
<h2 className="mb-4 text-2xl font-semibold">
Contactez-nous
</h2>
<ContactForm />
</section>
</div>
</main>
<InstallPrompt />
</>
);
}
function FeatureCard({
icon,
title,
description,
}: {
icon: string;
title: string;
description: string;
}) {
return (
<div className="rounded-xl border border-gray-800 p-6 text-center
hover:border-gray-600 transition-colors">
<div className="mb-3 text-4xl">{icon}</div>
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
<p className="text-sm text-gray-400">{description}</p>
</div>
);
}Tester votre PWA
Test local
Lancez un build de production pour tester le service worker :
npm run build
npm startLe service worker ne fonctionne pas en mode développement (npm run dev). Vous devez toujours tester avec un build de production.
Audit Lighthouse
- Ouvrez Chrome DevTools (F12)
- Allez dans l'onglet Lighthouse
- Sélectionnez Progressive Web App
- Lancez l'audit
Votre application devrait obtenir un score proche de 100 si tous les critères sont respectés.
Vérifications manuelles
Testez les scénarios suivants :
- Installation : cliquez sur l'icône d'installation dans la barre d'adresse
- Hors-ligne : activez le mode avion et naviguez dans l'application
- Cache : visitez des pages, passez hors-ligne, puis revisitez-les
- Notifications : activez les notifications et envoyez un test
Ajouter au fichier .gitignore
Le service worker et les fichiers de cache générés ne doivent pas être versionnés :
# PWA
public/sw.js
public/sw.js.map
public/workbox-*.js
public/workbox-*.js.map
public/worker-*.js
public/worker-*.js.map
public/fallback-*.js
public/swe-worker-*.jsDépannage
Le service worker ne se met pas à jour
Videz le cache dans Chrome DevTools, onglet Application, puis section Service Workers, et cliquez sur "Unregister". Rechargez ensuite la page.
L'invite d'installation n'apparaît pas
L'événement beforeinstallprompt ne se déclenche que si :
- La page est servie en HTTPS (ou localhost)
- Le manifest est valide avec les champs requis
- Un service worker est enregistré
- L'utilisateur n'a pas déjà installé l'application
Les notifications ne fonctionnent pas
Vérifiez que :
- Les clés VAPID sont correctement configurées
- Le service worker est actif
- L'utilisateur a accordé la permission
- Le navigateur supporte l'API Push
Prochaines étapes
- Ajoutez le partage natif avec l'API Web Share
- Implémentez le mode sombre adaptatif avec
prefers-color-scheme - Explorez les Periodic Background Sync pour des mises à jour régulières
- Intégrez Workbox pour des stratégies de cache avancées
- Ajoutez le support des raccourcis d'application dans le manifest
Conclusion
Vous avez transformé une application Next.js en Progressive Web App complète. Votre application est maintenant installable sur tous les appareils, fonctionne hors-ligne grâce au cache intelligent, et peut envoyer des notifications push pour engager vos utilisateurs.
Les PWA représentent le meilleur compromis entre applications web et natives : une seule base de code, un déploiement instantané via URL, et des capacités natives. Avec Next.js App Router et les outils modernes, créer une PWA n'a jamais été aussi accessible.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire une API Production-Ready avec tRPC, Prisma et Next.js
Apprenez à créer une API entièrement type-safe et prête pour la production avec tRPC, Prisma ORM et Next.js 15 App Router. Guide complet de la configuration au déploiement avec les meilleures pratiques.

AI SDK 4.0 : Nouvelles Fonctionnalites et Cas d'Utilisation
Decouvrez les nouvelles fonctionnalites et cas d'utilisation d'AI SDK 4.0, incluant le support PDF, l'utilisation de l'ordinateur et plus encore.

Créer un Web Scraper Intelligent avec Playwright et l'API Claude en TypeScript
Apprenez à construire un scraper web intelligent qui utilise Playwright pour l'automatisation du navigateur et l'IA Claude pour extraire, nettoyer et structurer les données de n'importe quel site — sans sélecteurs CSS fragiles.