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 = "version:2.7.0";
// 1) App shell you want offline without browsing — edit this list
const PRECACHE_URLS = ["/", "/about", "/profil", "/manifest.webmanifest", "/img/logo.png"];
// 2) All GET APIs you want offline without browsing — edit this list
const API_PRECACHE_URLS = ["/api/members", "/api/donations", "/api/activities"];
// Tell every open tab how caching is going: { type, op, done, total, url? }.
// op is "install" (precache) or "warm" (on-demand warm-up).
async function broadcastProgress(msg) {
try {
const all = await clients.matchAll({ includeUncontrolled: true, type: "window" });
all.forEach((client) => client.postMessage(msg));
} catch {}
}
async function cacheOne(cache, url) {
try {
if (url.startsWith("/api")) {
const res = await fetch(url, { credentials: "include" });
if (res.ok) await cache.put(url, res.clone());
return res.ok;
}
await cache.add(new Request(url, { cache: "reload" }));
return true;
} catch {
return false;
}
}
// Sequential caching with progression — failures reported per-url, never block install
async function cacheListWithProgress(cache, urls, op) {
const total = urls.length;
let done = 0;
await broadcastProgress({ type: "CACHE_START", op, done: 0, total });
for (const url of urls) {
const ok = await cacheOne(cache, url);
done += 1;
await broadcastProgress({ type: "CACHE_PROGRESS", op, done, total, url, ok });
}
await broadcastProgress({ type: "CACHE_DONE", op, done, total });
}
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE);
await cacheListWithProgress(cache, PRECACHE_URLS, "install");
if (API_PRECACHE_URLS.length > 0) {
await cacheListWithProgress(cache, API_PRECACHE_URLS, "warm");
}
self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(Promise.all([clients.claim(), caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))]));
});
// Warm on demand after login: cacheOfflinePack(...) — same progress protocol
self.addEventListener("message", (event) => {
if (event.data?.type === "WARM_API_CACHE" && Array.isArray(event.data.urls)) {
event.waitUntil((async () => {
const cache = await caches.open(CACHE);
await cacheListWithProgress(cache, event.data.urls, "warm");
})());
}
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return; // mutations network-only
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
const isApi = url.pathname.startsWith("/api");
const isPage = request.mode === "navigate" || request.headers.get("accept")?.includes("text/html");
event.respondWith((async () => {
try {
const response = await fetch(request);
if (response.ok && (isApi || isPage || response.type === "basic")) {
const cache = await caches.open(CACHE);
cache.put(request, response.clone()); // online = always refresh
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
if (isPage) { const fallback = await caches.match("/"); if (fallback) return fallback; }
return new Response("Offline", { status: 503, headers: { "Content-Type": "text/plain" } });
}
})());
});CACHE="version:2.7.0"— bump à chaque déploiement pour purgerPRECACHE_URLS+API_PRECACHE_URLS→ cache dèsinstall(tout le shell + tous les GET voulus hors-ligne sans browsing)cacheListWithProgress→ séquentiel + broadcastCACHE_START / CACHE_PROGRESS / CACHE_DONEà chaque onglet (affiché par<CacheProgress />, voir étape 2c)message WARM_API_CACHE→ appelezcacheOfflinePack({ pages, apis })après login pour réchauffer pages + APIs sans recharger (ouwarmApiCache([...])pour des APIs seules)method !== GET→ network-only (écritures bloquées offline)fetchsuccès →cache.put→ online = toujours à jour- Hors-ligne →
caches.match+ fallback"/"
public/sw.js, éditez seulement les 2 listes du haut. Puis après login: import { cacheOfflinePack } from "@ramses1er/cbk-toolkit/client"; cacheOfflinePack({ pages: ["/profil"], apis: ["/api/members",...] }) + <CacheProgress /> dans le layout (voir étape 2c).Une fois en ligne, chaque GET /api/*est automatiquement mis en cache par le SW. Hors-ligne, l'utilisateur navigue sur les données déjà vues. Les POST/PATCH/PUT/DELETEsont bloquées côté client avec un message clair — pas de queue pour l'instant.
import { fetcher, ApiError } from "@ramses1er/cbk-toolkit/client";
try {
await fetcher("/api/users", { method: "POST", body: JSON.stringify(payload) });
} catch (e) {
if (e instanceof ApiError && e.code === "OFFLINE") {
// e.message = "Vous êtes hors ligne — cette action nécessite une connexion internet."
// e.status = 0
toast.error(e.message);
}
}Le guard est dans fetcher : si !navigator.onLine et méthode ≠ GET/HEAD → ApiError code OFFLINE status 0 sans même appeler fetch.
"use client";
import { useOnline, OfflineBanner, OfflineToast, isOnline } from "@ramses1er/cbk-toolkit/client";
// Hook réactif
function MonForm() {
const online = useOnline();
return (
<>
<OfflineBanner />
<button disabled={!online}>Enregistrer</button>
</>
);
}
// Toast flottant global (dans layout)
import { OfflineToast } from "@ramses1er/cbk-toolkit/client";
export default function Layout({ children }) {
return <>{children}<OfflineToast /></>;
}
// Helper hors React
import { isOnline } from "@ramses1er/cbk-toolkit/client";
if (!isOnline()) return;useOnline utilise useSyncExternalStore (pas de mismatch hydration). OfflineBanner = bloc amber,OfflineToast = pastille fixed bottom.
ApiError OFFLINE+ bannière. Pas de sync en arrière-plan pour l'instant.Le worker envoie CACHE_START / CACHE_PROGRESS / CACHE_DONE (op install = precache, warm = réchauffage API) à chaque onglet ouvert. Affichez une barre de progression en un composant :
"use client";
import { CacheProgress } from "@ramses1er/cbk-toolkit/client";
// Une fois près de la racine (layout) — apparaît à chaque mise en cache
export default function Layout({ children }) {
return <>{children}<CacheProgress /></>;
}Pour piloter votre propre UI (ou réchauffer après login avec progression exacte), utilisez le hook et warmApiCache(urls, onProgress) :
"use client";
import { useCacheProgress, warmApiCache } from "@ramses1er/cbk-toolkit/client";
function SyncButton() {
const { active, done, total, percent } = useCacheProgress();
return (
<button
disabled={active}
onClick={() => warmApiCache(["/api/members", "/api/donations"], (p) => console.log(p.done + "/" + p.total, p.url))}
>
{active ? `Sync… ${done}/${total} (${percent}%)` : "Synchroniser"}
</button>
);
}Personnalisez les textes, le style et la durée d'affichage via les props :
<CacheProgress
title="Synchronisation…"
doneText="Données à jour !"
hideAfterMs={5000}
className="max-w-sm"
/>| Prop | Type | Défaut | Description |
|---|---|---|---|
| title | string | "Mise en cache…" | Titre affiché pendant la mise en cache |
| doneText | string | "À jour !" | Texte affiché quand la mise en cache est terminée |
| hideAfterMs | number | 3000 | Délai avant de masquer la barre après terminaison |
| className | string | — | Classes Tailwind additionnelles pour le conteneur |
Le hook useCacheProgress() retourne active, op ("install" | "warm"), done, total, percent (0-100), currentUrl. Et warmApiCache(urls, onProgress?) appelle onProgress({ done, total, url, ok }) après chaque URL — pratique pour votre propre barre ou pour logger la progression.
Pack offline après login (recommandé) : listez chaque parcours — sa page + les GET exacts (query strings incluses) dont elle a besoin — puis un seul appel. La progression unifiée apparaît automatiquement dans <CacheProgress /> :
"use client";
import { cacheOfflinePack } from "@ramses1er/cbk-toolkit/client";
// 1) Déclarez ce qui doit marcher hors-ligne sans visite préalable
const OFFLINE_PACK = {
pages: ["/", "/profil"],
apis: ["/api/members", "/api/donations", "/api/activities"],
};
// 2) Un seul appel après login (les pages/APIs peuvent exiger l'auth)
async function handleLoginSuccess() {
const results = await cacheOfflinePack(OFFLINE_PACK);
const failed = results.filter((r) => !r.ok);
if (failed.length > 0) console.warn("Offline incomplet :", failed.map((r) => r.url));
}install + warmApiCache) en ont une.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 — cache all app + all GET APIs (precache + runtime)
const CACHE = "version:2.7.0";
const PRECACHE_URLS = ["/", "/about", "/profil", "/manifest.webmanifest", "/img/logo.png"];
const API_PRECACHE_URLS = ["/api/members", "/api/donations"]; // add your GETs
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE);
await Promise.allSettled(PRECACHE_URLS.map((url) => cache.add(new Request(url, { cache: "reload" }))));
await Promise.allSettled(API_PRECACHE_URLS.map(async (url) => {
try { const res = await fetch(url, { credentials: "include" }); if (res.ok) await cache.put(url, res.clone()); } catch {}
}));
self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(Promise.all([clients.claim(), caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))]));
});
self.addEventListener("message", (event) => {
if (event.data?.type === "WARM_API_CACHE" && Array.isArray(event.data.urls)) {
event.waitUntil((async () => {
const cache = await caches.open(CACHE);
await Promise.allSettled(event.data.urls.map(async (url) => {
try { const res = await fetch(url, { credentials: "include" }); if (res.ok) await cache.put(url, res.clone()); } catch {}
}));
})());
}
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
const isApi = url.pathname.startsWith("/api");
const isPage = request.mode === "navigate" || request.headers.get("accept")?.includes("text/html");
event.respondWith((async () => {
try {
const response = await fetch(request);
if (response.ok && (isApi || isPage || response.type === "basic")) {
const cache = await caches.open(CACHE);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
if (isPage) { const fallback = await caches.match("/"); if (fallback) return fallback; }
return new Response("Offline", { status: 503, headers: { "Content-Type": "text/plain" } });
}
})());
});
// In your app after login: import { warmApiCache } from "@ramses1er/cbk-toolkit/client"; warmApiCache(["/api/members", "/api/donations"]);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).*)",
],
};