PWA
Rendre une application React installable et utilisable hors-ligne : deux composants client (InstallPrompt et SwRegistration), un manifest, un service worker et quelques réglages Next.js. Le tout se met en place en 4 étapes.
- Next.js 14+ avec App Router (les fichiers
manifest.tsetlayout.tsxsont spécifiques à Next.js) - Accès en HTTPS ou en localhost(le service worker est refusé en HTTP simple)
- Deux icônes PNG : 192x192 et 512x512 (dupliquées en variante
maskable)
Next.js expose automatiquement le manifest à l'URL /manifest.webmanifest dès qu'un fichier src/app/manifest.ts exporte une fonction manifest(). Il déclare le nom, le mode standaloneet les icônes qui rendent l'application installable.
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Mon Application",
short_name: "MonApp",
description: "Description de mon application",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#0C44AA",
icons: [
{
src: "/img/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/img/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/img/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/img/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}start_url— page ouverte au lancement depuis l'écran d'accueildisplay: "standalone"— ouvre l'app sans barre du navigateurpurpose: "maskable"— permet aux icônes de s'adapter au masque de l'écran d'accueil Android
Le service worker permet le fonctionnement hors-ligne. La stratégie ci-dessous est network-first : le réseau est priorité, et en cas d'échec la dernière version mise en cache est renvoyée. Incrémentez le nom du cache (à chaque déploiement) pour forcer le remplacement de l'ancienne version.
const CACHE = "app-v1";
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
clients.claim(),
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE)
.map((key) => caches.delete(key)),
),
),
]),
);
});
self.addEventListener("fetch", (event) => {
event.respondWith(
(async () => {
try {
const response = await fetch(event.request);
if (response.type === "basic" && event.request.method === "GET") {
const cache = await caches.open(CACHE);
cache.put(event.request, response.clone());
}
return response;
} catch {
const cached = await caches.match(event.request);
return cached ?? new Response("Offline", { status: 503 });
}
})(),
);
});skipWaiting+clients.claim— la nouvelle version prend le contrôle immédiatement- Seules les réponses
type === "basic"en GET sont mises en cache (pages et assets locaux) - En cas d'échec réseau sans cache → réponse
503 "Offline" - Le handler
activatepurge les anciens caches (ceux dont le nom diffère deCACHE) pour éviter qu'ils s'accumulent à chaque déploiement
Le service worker ne doit jamaisêtre mis en cache par le navigateur, sinon les mises à jour ne sont jamais prises en compte. On force l'absence de cache et le bon Content-Type.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/sw.js",
headers: [
{
key: "Cache-Control",
value: "no-cache, no-store, must-revalidate",
},
{
key: "Content-Type",
value: "application/javascript; charset=utf-8",
},
],
},
];
},
};
export default nextConfig;On déclare le manifest dans les métadonnées, puis on monte les deux composants du toolkit dans le <body>. C'est tout ce qui est nécessaire : la bannière d'installation et l'enregistrement du service worker sont gérés automatiquement.
import type { Metadata, Viewport } from "next";
import { InstallPrompt, SwRegistration } from "@ramses1er/cbk-toolkit/client";
export const metadata: Metadata = {
title: "Mon Application",
description: "Description de mon application",
manifest: "/manifest.webmanifest",
icons: [
{
rel: "apple-touch-icon",
url: "/img/icon-192.png",
sizes: "192x192",
},
],
other: {
"apple-mobile-web-app-capable": "yes",
"apple-mobile-web-app-status-bar-style": "default",
},
};
export const viewport: Viewport = {
themeColor: "#0C44AA",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="fr">
<body>
{children}
<InstallPrompt appName="Mon Application" />
<SwRegistration />
</body>
</html>
);
}InstallPrompt et SwRegistration ne rendent rien visuellement (ou la bannière quand c'est nécessaire). Les placer dans le layout racine n'ajoute aucun rendu parasite à vos pages.Si vous protégez vos routes avec un middleware (ex. authGuarddu toolkit), pensez à exclure manifest et sw du matcher pour ne pas bloquer ces ressources.
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|img/|manifest|sw).*)",
],
};Bannière d'installation qui s'adapte au navigateur : bouton natif (Chrome/Edge), instructions Chromium desktop ou instructions iOS.
import { InstallPrompt } from "@ramses1er/cbk-toolkit/client";
export default function RootLayout({ children }) {
return (
<>
{children}
<InstallPrompt appName="Mon Application" />
<InstallPrompt
appName="Mon Application"
storageKey="ma_app_install_dismissed"
installText="Installer"
/>
</>
);
}| Prop | Type | Défaut | Description |
|---|---|---|---|
| appName | string | requis | Nom de l'application affiché dans la bannière et les instructions |
| storageKey | string | cbk_install_prompt_dismissed | Clé localStorage utilisée pour ne plus afficher la bannière après fermeture |
| installText | string | Installer | Libellé du bouton d'installation natif |
Comportement
- Ne rend rien si l'app est déjà installée (mode
standalone) - iOS → instructions "Ajouter à l'écran d'accueil"
- Chromium desktop → instructions menu (⋮)
- Événement
beforeinstallprompt→ bouton d'installation natif - Fermeture → mémorisation dans localStorage via
storageKey
Enregistre le service worker au montage. Ne rend rien.
import { SwRegistration } from "@ramses1er/cbk-toolkit/client";
export default function RootLayout({ children }) {
return (
<>
{children}
<SwRegistration />
<SwRegistration swUrl="/sw.js" />
</>
);
}| Prop | Type | Défaut | Description |
|---|---|---|---|
| swUrl | string | /sw.js | URL du service worker à enregistrer (par défaut /sw.js si votre fichier est dans public/sw.js) |
Comportement
- Enregistre
navigator.serviceWorker.register(swUrl)au montage (uniquement si le navigateur supporte les service workers) - Erreur d'enregistrement → loggée en console, sans crash
Les 5 fichiers nécessaires pour une application installable avec support hors-ligne.
1. src/app/manifest.ts
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Mon Application",
short_name: "MonApp",
description: "Description de mon application",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#0C44AA",
icons: [
{
src: "/img/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/img/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
],
};
}2. public/sw.js
const CACHE = "app-v1";
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
clients.claim(),
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE)
.map((key) => caches.delete(key)),
),
),
]),
);
});
self.addEventListener("fetch", (event) => {
event.respondWith(
(async () => {
try {
const response = await fetch(event.request);
if (response.type === "basic" && event.request.method === "GET") {
const cache = await caches.open(CACHE);
cache.put(event.request, response.clone());
}
return response;
} catch {
const cached = await caches.match(event.request);
return cached ?? new Response("Offline", { status: 503 });
}
})(),
);
});3. next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/sw.js",
headers: [
{
key: "Cache-Control",
value: "no-cache, no-store, must-revalidate",
},
{
key: "Content-Type",
value: "application/javascript; charset=utf-8",
},
],
},
];
},
};
export default nextConfig;4. src/app/layout.tsx
import type { Metadata, Viewport } from "next";
import { InstallPrompt, SwRegistration } from "@ramses1er/cbk-toolkit/client";
export const metadata: Metadata = {
title: "Mon Application",
description: "Description de mon application",
manifest: "/manifest.webmanifest",
};
export const viewport: Viewport = {
themeColor: "#0C44AA",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="fr">
<body>
{children}
<InstallPrompt appName="Mon Application" />
<SwRegistration />
</body>
</html>
);
}5. (optionnel) middleware / proxy — matcher
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|img/|manifest|sw).*)",
],
};