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.
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.

É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 :
localhost, pour développer.| Site web classique | PWA | Application native | |
|---|---|---|---|
| Installation | ❌ | ✅ depuis le navigateur | ✅ depuis un store |
| Fonctionne hors ligne | ❌ | ✅ | ✅ |
| Un seul code pour tous les appareils | ✅ | ✅ | ❌ (sauf multiplateforme) |
| Mises à jour | Instantanées | Instantanées | Via le store |
| Accès matériel avancé (Bluetooth, NFC…) | Limité | Partiel | Complet |
| Coût de développement | Faible | Faible | É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.
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.
É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_name | Nom complet (écran de démarrage) et nom court (sous l’icône) |
start_url | La page ouverte au lancement de l’application |
scope | Les URL qui font partie de l’application |
display | standalone = plein écran sans barre d’adresse, comme une app native. Autres valeurs : fullscreen, minimal-ui, browser |
background_color | Couleur de l’écran de démarrage (splash screen) |
theme_color | Couleur de la barre d’état et de la barre de titre |
icons | Au minimum 192×192 et 512×512 px. La version maskable s’adapte aux formes d’icônes d’Android (rond, carré arrondi…) |
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 affichageOn 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 workerlocalStorage 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.

// 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.jsDé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.
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 :

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É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
É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-notesTerminalOuvre 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-v1et ses fichiers ; - Lighthouse : lance un audit pour vérifier les performances et les bonnes pratiques.
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 :

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
- Servie en HTTPS (ou
localhost). - Un manifeste lié dans le
<head>avecnameoushort_name,start_url,display(standalone,fullscreenouminimal-ui). - Des icônes de 192×192 et 512×512 pixels.
- Un service worker enregistré, avec un gestionnaire
fetchpour que l’application réponde hors ligne. - Une balise
meta viewportpour 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.

