PWA

Documentation de cbk-toolkit

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.

Prérequis
  • Next.js 14+ avec App Router (les fichiers manifest.ts et layout.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)
Étape 1 — Le manifest (src/app/manifest.ts)

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'accueil
  • display: "standalone" — ouvre l'app sans barre du navigateur
  • purpose: "maskable" — permet aux icônes de s'adapter au masque de l'écran d'accueil Android
Étape 2 — Le service worker (public/sw.js)

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 purger
  • PRECACHE_URLS + API_PRECACHE_URLS → cache dès install (tout le shell + tous les GET voulus hors-ligne sans browsing)
  • cacheListWithProgress → séquentiel + broadcast CACHE_START / CACHE_PROGRESS / CACHE_DONE à chaque onglet (affiché par <CacheProgress />, voir étape 2c)
  • message WARM_API_CACHE → appelez cacheOfflinePack({ pages, apis }) après login pour réchauffer pages + APIs sans recharger (ou warmApiCache([...]) pour des APIs seules)
  • method !== GET → network-only (écritures bloquées offline)
  • fetch succès → cache.put → online = toujours à jour
  • Hors-ligne → caches.match + fallback "/"
Exact config: copiez ce fichier tel quel dans 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).
Étape 2b — Hors-ligne : lecture en cache, écriture bloquée

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.

fetcher — guard offline (packages/utils/src/fetcher.ts)
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.

useOnline + OfflineBanner (packages/ui/src/pwa)
"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.

Comportement : En ligne → GET mis en cache automatiquement. Hors-ligne → cache servi, navigation fallback vers "/". Toute tentative POST/PATCH/PUT/DELETE → ApiError OFFLINE+ bannière. Pas de sync en arrière-plan pour l'instant.
Étape 2c — Voir la progression de la mise en cache

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"
/>
PropTypeDéfautDescription
titlestring"Mise en cache…"Titre affiché pendant la mise en cache
doneTextstring"À jour !"Texte affiché quand la mise en cache est terminée
hideAfterMsnumber3000Délai avant de masquer la barre après terminaison
classNamestring—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));
}
Automatique ensuite :chaque page visitée en ligne + chaque GET qu'elle déclenche sont conservés par le worker (network-first) sans aucun code. Le pack ne couvre que le garanti — le reste s'accumule au fil de la navigation.
Note : le cache continu au fil de la navigation (fetch handler) n'a pas de progression globale — seules les opérations bornées (precache install + warmApiCache) en ont une.
Étape 3 — Les headers Next.js (next.config.ts)

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;
Étape 4 — Le layout racine (src/app/layout.tsx)

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>
  );
}
Bon à savoir : 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.
Étape 5 (optionnelle) — Middleware d'authentification

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).*)",
  ],
};
InstallPrompt

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"
      />
    </>
  );
}
PropTypeDéfautDescription
appNamestringrequisNom de l'application affiché dans la bannière et les instructions
storageKeystringcbk_install_prompt_dismissedClé localStorage utilisée pour ne plus afficher la bannière après fermeture
installTextstringInstallerLibellé 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
SwRegistration

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" />
    </>
  );
}
PropTypeDéfautDescription
swUrlstring/sw.jsURL 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
Code complet — copier-coller

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).*)",
  ],
};
Test :lancez l'app en local (ou en HTTPS), ouvrez DevTools → Application → Service Workers, puis rechargement → la bannière apparaît et le SW est actif. Coupez le réseau pour vérifier le mode hors-ligne.