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 = "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 de CACHE) pour éviter qu'ils s'accumulent à chaque déploiement
É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

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