Et si ton site web pouvait s’installer sur un téléphone comme une vraie application, avec son icône, son écran plein écran… et fonctionner sans connexion ? C’est exactement ce que permet une PWA (Progressive Web App). Dans ce tutoriel, tu crées LM Notes, une application de notes installable, en HTML, CSS et JavaScript pur : manifeste, service worker, cache hors ligne et installation sur Android.

📱 PWA installable🧩 HTML · CSS · JS natif📴 Mode hors ligne🤖 Android, Windows, macOS⏱️ 30 min

Publier une application sur le Play Store demande un compte développeur, un outil comme Android Studio et un processus de validation. Pour beaucoup de projets (une liste de tâches, un outil interne, un catalogue, un carnet de notes), c’est beaucoup d’efforts. Une PWA (application web progressive) est une alternative simple : c’est un site web classique, enrichi de deux fichiers, qui devient installable et utilisable hors ligne.

À la fin de ce tutoriel, tu auras une vraie PWA installable : une application de notes qui s’ouvre en plein écran depuis l’écran d’accueil, enregistre tes notes sur l’appareil et continue de fonctionner en mode avion.

PWA installable LM Notes en ligne et en mode avion hors ligne
LM Notes, la PWA de ce tutoriel : à gauche en ligne, à droite en mode avion. L’application fonctionne toujours.
💡
Ce que tu vas apprendre

Écrire un manifeste (manifest.webmanifest), créer un service worker qui met l’application en cache, choisir une stratégie de cache, afficher un bouton « Installer » personnalisé, tester avec Chrome DevTools et Lighthouse, puis installer la PWA sur Android.

C’est quoi une PWA installable ?

Une Progressive Web App est une application web qui utilise des API modernes du navigateur pour offrir une expérience proche d’une application native. Concrètement, une PWA réunit trois ingrédients :

🔒 HTTPSObligatoire en production. Seule exception : localhost, pour développer.
📄 Un manifesteUn fichier JSON qui décrit l’application : nom, icônes, couleurs, mode d’affichage.
⚙️ Un service workerUn script en arrière-plan qui intercepte les requêtes et met les fichiers en cache.
Site web classiquePWAApplication native
Installation❌✅ depuis le navigateur✅ depuis un store
Fonctionne hors ligne❌✅✅
Un seul code pour tous les appareils✅✅❌ (sauf multiplateforme)
Mises à jourInstantanéesInstantanéesVia le store
Accès matériel avancé (Bluetooth, NFC…)LimitéPartielComplet
Coût de développementFaibleFaibleÉlevé

Les PWA sont utilisées par de grands services (Twitter/X Lite, Starbucks, Spotify Web, Pinterest…), car elles sont légères, rapides à charger et ne demandent aucun passage par un store.

🍏
Et sur iPhone ?

iOS supporte les PWA depuis Safari (menu Partager → Sur l’écran d’accueil), avec un service worker et les notifications depuis iOS 16.4. Le bouton « Installer » automatique, lui, n’existe que sur Android et sur ordinateur (Chrome, Edge).

La structure du projet

Pas de framework, pas de npm : six fichiers suffisent.

lm-notes/ ├── index.html La page de l’application ├── style.css Le design (mobile d’abord) ├── app.js Notes, recherche, installation ├── sw.js Le service worker (hors ligne) ├── manifest.webmanifest La carte d’identité de la PWA └── icons/ ├── icon-192.png ├── icon-512.png └── icon-512-maskable.png

Étape 1 : la page HTML

La page est classique. Les lignes importantes pour la PWA sont dans le <head> : le lien vers le manifeste, la couleur de thème (qui colore la barre d’état d’Android) et l’icône pour iOS.

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>LM Notes</title>
  <meta name="theme-color" content="#4f46e5">
  <meta name="description" content="Une application de notes installable qui fonctionne hors ligne.">
  <link rel="manifest" href="manifest.webmanifest">
  <link rel="icon" href="icons/icon-192.png">
  <link rel="apple-touch-icon" href="icons/icon-192.png">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header class="barre">
    <h1>📝 LM Notes</h1>
    <span id="etat-reseau" class="pastille">En ligne</span>
    <button id="bouton-installer" class="installer" hidden>Installer</button>
  </header>

  <main>
    <form id="formulaire" class="carte">
      <input id="titre" placeholder="Titre de la note" maxlength="80" required>
      <textarea id="contenu" placeholder="Écris ta note…" rows="3"></textarea>
      <button type="submit">Ajouter la note</button>
    </form>

    <input id="recherche" class="recherche" type="search" placeholder="🔍 Rechercher une note…">
    <ul id="liste" class="liste"></ul>
    <p id="vide" class="vide" hidden>Aucune note pour l'instant ✨</p>
  </main>

  <script src="app.js"></script>
</body>
</html>
index.html

Étape 2 : le style, pensé mobile d’abord

Le CSS n’a rien de spécifique aux PWA, mais une application installée doit ressembler à une application : barre supérieure fixe, cartes, gros boutons faciles à toucher. Les variables CSS permettent de changer la couleur principale en une ligne.

:root { --accent: #4f46e5; --fond: #f5f7fb; --carte: #fff; --texte: #0f172a; --doux: #64748b; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--fond); color: var(--texte); }

.barre { position: sticky; top: 0; display: flex; align-items: center; gap: 10px; padding: 14px 18px;
  background: var(--accent); color: #fff; box-shadow: 0 4px 16px rgba(79,70,229,.3); }
.barre h1 { font-size: 1.2rem; margin: 0; flex: 1; }
.pastille { font-size: .75rem; font-weight: 700; padding: 4px 10px; border-radius: 999px; background: #22c55e; }
.pastille.horsligne { background: #f97316; }
.installer { border: 0; border-radius: 10px; padding: 7px 12px; font-weight: 700; background: #fff; color: var(--accent); cursor: pointer; }

main { max-width: 720px; margin: 0 auto; padding: 18px; }
.carte { background: var(--carte); border-radius: 16px; padding: 16px; box-shadow: 0 6px 20px rgba(15,23,42,.08); display: grid; gap: 10px; }
input, textarea { width: 100%; font: inherit; padding: 11px 12px; border: 1px solid #e2e8f0; border-radius: 10px; }
form button { border: 0; border-radius: 10px; padding: 12px; font-weight: 700; color: #fff; background: var(--accent); cursor: pointer; }
.recherche { margin: 18px 0 12px; }

.liste { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; }
.note { background: var(--carte); border-radius: 14px; padding: 14px 16px; box-shadow: 0 4px 14px rgba(15,23,42,.06); border-left: 5px solid var(--accent); }
.note h2 { font-size: 1rem; margin: 0 0 4px; display: flex; justify-content: space-between; gap: 8px; }
.note p { margin: 0; color: #334155; white-space: pre-wrap; }
.note small { color: var(--doux); }
.note button { border: 0; background: none; cursor: pointer; font-size: 1rem; }
.vide { text-align: center; color: var(--doux); }
style.css

Étape 3 : le manifeste, la carte d’identité de la PWA

Le manifeste est un fichier JSON qui indique au navigateur comment afficher l’application une fois installée. C’est lui qui rend la PWA installable.

{
  "name": "LM Notes – Notes hors ligne",
  "short_name": "LM Notes",
  "description": "Une application de notes installable qui fonctionne hors ligne.",
  "start_url": "./",
  "scope": "./",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#f5f7fb",
  "theme_color": "#4f46e5",
  "lang": "fr",
  "icons": [
    { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}
manifest.webmanifest
PropriétéRôle
name / short_nameNom complet (écran de démarrage) et nom court (sous l’icône)
start_urlLa page ouverte au lancement de l’application
scopeLes URL qui font partie de l’application
displaystandalone = plein écran sans barre d’adresse, comme une app native. Autres valeurs : fullscreen, minimal-ui, browser
background_colorCouleur de l’écran de démarrage (splash screen)
theme_colorCouleur de la barre d’état et de la barre de titre
iconsAu minimum 192×192 et 512×512 px. La version maskable s’adapte aux formes d’icônes d’Android (rond, carré arrondi…)
🎨
Les icônes « maskable »

Android découpe les icônes selon le thème du téléphone. Une icône maskable garde son dessin important dans une zone sûre centrale (80 % du carré) et remplit tout le fond. Tu peux vérifier la tienne sur maskable.app.

Étape 4 : la logique de l’application en JavaScript

Les notes sont stockées dans le localStorage du navigateur : elles restent sur l’appareil, même sans connexion. La fonction echapper() protège l’affichage contre les injections HTML (faille XSS), un réflexe à garder dès qu’on affiche du texte saisi par l’utilisateur.

// ---------- Données : stockées dans localStorage ----------
const CLE = 'lm-notes';
let notes = JSON.parse(localStorage.getItem(CLE) || '[]');

const sauvegarder = () => localStorage.setItem(CLE, JSON.stringify(notes));

const echapper = (texte) => texte.replace(/[&<>"']/g, (c) => (
  { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
));

// ---------- Affichage ----------
const liste = document.getElementById('liste');
const recherche = document.getElementById('recherche');

function afficher() {
  const filtre = recherche.value.trim().toLowerCase();
  const visibles = notes.filter((n) => (n.titre + n.contenu).toLowerCase().includes(filtre));

  liste.innerHTML = visibles.map((n) => `
    <li class="note">
      <h2>${echapper(n.titre)} <button data-id="${n.id}" title="Supprimer">🗑️</button></h2>
      <p>${echapper(n.contenu)}</p>
      <small>${new Date(n.date).toLocaleString('fr-BE')}</small>
    </li>`).join('');

  document.getElementById('vide').hidden = visibles.length > 0;
}

// ---------- Ajout / suppression ----------
document.getElementById('formulaire').addEventListener('submit', (e) => {
  e.preventDefault();
  notes.unshift({
    id: crypto.randomUUID(),
    titre: document.getElementById('titre').value.trim(),
    contenu: document.getElementById('contenu').value.trim(),
    date: Date.now(),
  });
  sauvegarder();
  e.target.reset();
  afficher();
});

liste.addEventListener('click', (e) => {
  const id = e.target.dataset.id;
  if (!id) return;
  notes = notes.filter((n) => n.id !== id);
  sauvegarder();
  afficher();
});

recherche.addEventListener('input', afficher);
afficher();
app.js — données et affichage

On ajoute ensuite un petit indicateur « En ligne / Hors ligne » grâce aux événements online et offline du navigateur, puis l’enregistrement du service worker :

// ---------- Indicateur en ligne / hors ligne ----------
const pastille = document.getElementById('etat-reseau');
function majReseau() {
  pastille.textContent = navigator.onLine ? 'En ligne' : 'Hors ligne';
  pastille.classList.toggle('horsligne', !navigator.onLine);
}
window.addEventListener('online', majReseau);
window.addEventListener('offline', majReseau);
majReseau();

// ---------- Service worker ----------
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('sw.js')
      .then((reg) => console.log('Service worker actif :', reg.scope))
      .catch((err) => console.error('Échec du service worker :', err));
  });
}
app.js — réseau et service worker
📦
localStorage ou IndexedDB ?

localStorage est parfait pour quelques centaines de notes : simple et synchrone. Pour des milliers d’éléments, des images ou des recherches avancées, passe à IndexedDB (par exemple avec la petite bibliothèque idb), asynchrone et beaucoup plus volumineux.

Étape 5 : le service worker, le cœur de la PWA hors ligne

Le service worker est un script JavaScript qui s’exécute en arrière-plan, séparément de la page. Il se place entre l’application et le réseau, comme un proxy : chaque requête passe par lui, et il décide de répondre depuis le cache ou depuis Internet.

Cycle de vie d'un service worker de PWA : register, install, activate, fetch
Le cycle de vie d’un service worker : enregistrement, installation, activation, puis interception des requêtes.
// Change la version à chaque mise à jour des fichiers : l'ancien cache sera supprimé.
const VERSION = 'lm-notes-v1';
const FICHIERS = [
  './',
  './index.html',
  './style.css',
  './app.js',
  './manifest.webmanifest',
  './icons/icon-192.png',
  './icons/icon-512.png',
];

// 1. Installation : on met en cache l'« app shell »
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(VERSION).then((cache) => cache.addAll(FICHIERS))
  );
  self.skipWaiting();
});

// 2. Activation : on supprime les anciens caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cles) =>
      Promise.all(cles.filter((c) => c !== VERSION).map((c) => caches.delete(c)))
    )
  );
  self.clients.claim();
});

// 3. Requêtes : « cache d'abord, réseau ensuite »
self.addEventListener('fetch', (event) => {
  if (event.request.method !== 'GET') return;

  event.respondWith(
    caches.match(event.request).then((enCache) => {
      if (enCache) return enCache;

      return fetch(event.request)
        .then((reponse) => {
          const copie = reponse.clone();
          caches.open(VERSION).then((cache) => cache.put(event.request, copie));
          return reponse;
        })
        .catch(() => caches.match('./index.html')); // hors ligne : on renvoie l'app
    })
  );
});
sw.js

Décortiquons les trois événements :

  • install : déclenché une seule fois par version. On y télécharge l’app shell, c’est-à-dire tous les fichiers nécessaires pour afficher l’application. event.waitUntil() empêche l’installation de se terminer avant la fin de la mise en cache.
  • activate : la nouvelle version prend le relais. C’est le bon moment pour supprimer les anciens caches, sinon ils s’accumulent sur l’appareil.
  • fetch : déclenché pour chaque requête. On répond depuis le cache si possible, sinon on va sur le réseau et on garde une copie. En cas d’échec (pas de réseau), on renvoie la page d’accueil.
🔁
Le piège n°1 : oublier de changer la version

Si tu modifies style.css sans changer VERSION, tes utilisateurs garderont l’ancien fichier en cache indéfiniment. Change le nom (lm-notes-v2) à chaque déploiement : le service worker est réinstallé et l’ancien cache supprimé.

Choisir la bonne stratégie de cache

Notre application utilise la stratégie « cache d’abord », idéale pour des fichiers qui changent rarement. Mais il en existe d’autres, à choisir selon le type de contenu :

Stratégies de cache d'une PWA : cache first, network first, stale-while-revalidate
Les trois stratégies de cache les plus utilisées dans une PWA.

Voici par exemple une stratégie « réseau d’abord », adaptée aux appels d’API : on veut des données fraîches, mais on accepte la dernière version connue en cas de coupure.

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((reponse) => {
          const copie = reponse.clone();
          caches.open('api-v1').then((cache) => cache.put(event.request, copie));
          return reponse;
        })
        .catch(() => caches.match(event.request))   // hors ligne : dernière version connue
    );
  }
});
sw.js — Network First pour une API
🧰
Workbox, pour les projets plus gros

Écrire son service worker à la main est idéal pour comprendre. Sur un projet plus important, Workbox (une bibliothèque de Google) fournit ces stratégies prêtes à l’emploi, la gestion des versions et l’expiration automatique du cache.

Étape 6 : un bouton « Installer » personnalisé

Sur Android et sur ordinateur, Chrome et Edge déclenchent l’événement beforeinstallprompt quand la PWA remplit les critères d’installation. On peut l’intercepter pour afficher notre propre bouton au moment opportun, plutôt que de laisser le navigateur décider :

let invite = null;
const boutonInstaller = document.getElementById('bouton-installer');

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();          // on garde l'invitation pour plus tard
  invite = e;
  boutonInstaller.hidden = false;
});

boutonInstaller.addEventListener('click', async () => {
  if (!invite) return;
  invite.prompt();
  const { outcome } = await invite.userChoice;
  console.log('Installation :', outcome);   // 'accepted' ou 'dismissed'
  invite = null;
  boutonInstaller.hidden = true;
});

window.addEventListener('appinstalled', () => console.log('LM Notes installée 🎉'));
app.js — installation
PWA LM Notes sur ordinateur avec le bouton Installer l'application dans Chrome
Sur ordinateur, Chrome propose l’installation dans la barre d’adresse, et notre bouton « Installer » apparaît dans l’application.

Étape 7 : tester la PWA installable en local

Un service worker ne fonctionne qu’en HTTPS ou sur localhost. Ouvrir index.html en double-cliquant (file://) ne marchera pas. Lance un petit serveur dans le dossier du projet :

# Avec Python
python -m http.server 8080

# Ou avec Node.js
npx serve .

# Ou avec WampServer : place le dossier dans C:\wamp64\www\lm-notes
Terminal

Ouvre ensuite http://localhost:8080 dans Chrome, puis les DevTools (F12) :

  • Onglet Application → Manifest : vérifie le nom, les icônes et les éventuels avertissements ;
  • Application → Service workers : le statut doit être activated and is running. La case Offline simule une coupure réseau ;
  • Application → Cache storage : tu vois le cache lm-notes-v1 et ses fichiers ;
  • Lighthouse : lance un audit pour vérifier les performances et les bonnes pratiques.
🧪
Astuce de développement

Pendant que tu modifies le code, coche Update on reload dans Application → Service workers. Sinon le cache te servira l’ancienne version et tu croiras que tes modifications ne fonctionnent pas.

Étape 8 : installer la PWA sur Android

Pour tester sur ton téléphone, la PWA doit être accessible en HTTPS. Le plus simple est de la publier gratuitement sur un hébergeur statique (GitHub Pages, Netlify, Cloudflare Pages) ou dans un sous-dossier de ton site. Ensuite :

Installer une PWA sur Android depuis Chrome : ajouter à l'écran d'accueil
Une fois installée, la PWA a sa propre icône et s’ouvre en plein écran, comme une application native.

L’application apparaît dans le tiroir d’applications, dans le sélecteur d’applications récentes, et peut même être désinstallée comme une app classique. Grâce au service worker, elle s’ouvre instantanément, même en mode avion.

Checklist : les critères pour qu’une PWA soit installable

  1. Servie en HTTPS (ou localhost).
  2. Un manifeste lié dans le <head> avec name ou short_name, start_url, display (standalone, fullscreen ou minimal-ui).
  3. Des icônes de 192×192 et 512×512 pixels.
  4. Un service worker enregistré, avec un gestionnaire fetch pour que l’application réponde hors ligne.
  5. Une balise meta viewport pour l’affichage mobile.

Aller plus loin

  • Notifications push avec l’API Push et les clés VAPID (un serveur est nécessaire pour les envoyer).
  • Synchronisation en arrière-plan (Background Sync) : envoyer les notes au serveur dès que la connexion revient.
  • Raccourcis dans le manifeste (shortcuts) : « Nouvelle note » directement depuis un appui long sur l’icône.
  • Publier sur le Play Store : une PWA peut être empaquetée avec Bubblewrap (TWA, Trusted Web Activity) et distribuée comme une application Android.
  • Ajouter un backend : synchroniser les notes entre appareils avec une petite API, par exemple en PHP et MySQL.

Tu hésites entre une PWA et une application mobile « native » multiplateforme ? Notre comparatif React Native ou Flutter en 2026 t’aidera à choisir.

FAQ : PWA installable

Qu’est-ce qu’une PWA ?

Une Progressive Web App est un site web qui, grâce à un manifeste et à un service worker, peut être installé sur un téléphone ou un ordinateur comme une application, s’ouvrir en plein écran et fonctionner hors ligne.

Une PWA fonctionne-t-elle sur iPhone ?

Oui. Sur iOS, on l’installe depuis Safari avec Partager puis Sur l’écran d’accueil. Le service worker et le mode hors ligne fonctionnent, et les notifications push sont disponibles depuis iOS 16.4 pour les PWA installées.

Faut-il un framework pour créer une PWA ?

Non. Du HTML, du CSS et du JavaScript suffisent, avec un fichier manifeste et un service worker. React, Vue ou Angular proposent des outils pour générer une PWA, mais ils ne sont pas obligatoires.

Pourquoi mon bouton Installer n’apparaît-il pas ?

Vérifie que le site est en HTTPS ou sur localhost, que le manifeste est valide avec des icônes 192 et 512 pixels, que le service worker est actif et que l’application n’est pas déjà installée. L’onglet Application des DevTools de Chrome liste les erreurs.

Peut-on publier une PWA sur le Google Play Store ?

Oui, en l’empaquetant comme Trusted Web Activity avec l’outil Bubblewrap ou le service PWABuilder. L’application reste ton site web, affiché en plein écran.

Conclusion

Avec six fichiers et aucune dépendance, tu as transformé un simple site en PWA installable : un manifeste pour l’installation, un service worker pour le mode hors ligne, et un bouton « Installer » pour guider l’utilisateur. C’est la façon la plus rapide de proposer une « application » sur Android, iPhone et ordinateur, avec un seul code et des mises à jour instantanées.

La référence pour approfondir reste la documentation de MDN sur les applications web progressives et le guide Learn PWA de web.dev. Et si tu veux transformer un de tes projets existants en PWA, commence par l’app shell : c’est 80 % du résultat.

Comments

No comments yet. Why don’t you start the discussion?

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *