Cara Membuat Aplikasi PWA (Progressive Web App)
Progressive Web App (PWA) menggabungkan best features dari web dan native apps. Mari pelajari cara membuatnya.
Apa itu PWA?
Karakteristik PWA
Progressive: Work untuk semua browser
Responsive: Fit di semua screen sizes
Connectivity Independent: Work offline
App-like: Feel seperti native app
Fresh: Always up-to-date
Safe: HTTPS required
Discoverable: SEO friendly
Re-engageable: Push notifications
Installable: Bisa di-install ke device
Linkable: Easy to share via URL
PWA vs Native App
PWA:
+ Single codebase
+ No app store needed
+ Smaller size
+ Auto updates
+ SEO benefits
- Limited device access
- Belum semua feature
Native App:
+ Full device access
+ Better performance
+ App store visibility
- Platform specific
- Larger size
- App store approval
Requirements
Core Components
1. Web App Manifest
- App metadata
- Icons
- Display mode
2. Service Worker
- Offline functionality
- Caching
- Push notifications
3. HTTPS
- Required untuk service worker
- Security
Web App Manifest
manifest.json
{
"name": "My PWA App",
"short_name": "MyPWA",
"description": "A sample Progressive Web App",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "New Post",
"short_name": "New",
"description": "Create a new post",
"url": "/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
}
]
}
Link Manifest di HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- PWA Meta Tags -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#2196F3" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="My PWA" />
<!-- iOS Icons -->
<link rel="apple-touch-icon" href="/icons/icon-152x152.png" />
<title>My PWA App</title>
</head>
<body>
<!-- App content -->
<script src="/js/app.js"></script>
</body>
</html>
Service Worker
Register Service Worker
// app.js
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js");
console.log("ServiceWorker registered:", registration.scope);
} catch (error) {
console.log("ServiceWorker registration failed:", error);
}
});
}
Basic Service Worker
// sw.js
const CACHE_NAME = "my-pwa-cache-v1";
const urlsToCache = [
"/",
"/index.html",
"/css/styles.css",
"/js/app.js",
"/icons/icon-192x192.png",
"/offline.html",
];
// Install event - cache assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log("Opened cache");
return cache.addAll(urlsToCache);
})
);
// Activate immediately
self.skipWaiting();
});
// Activate event - clean old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log("Deleting old cache:", cacheName);
return caches.delete(cacheName);
}
})
);
})
);
// Take control immediately
self.clients.claim();
});
// Fetch event - serve from cache or network
self.addEventListener("fetch", (event) => {
event.respondWith(
caches
.match(event.request)
.then((response) => {
// Cache hit - return response
if (response) {
return response;
}
// Clone request
const fetchRequest = event.request.clone();
return fetch(fetchRequest).then((response) => {
// Check valid response
if (
!response ||
response.status !== 200 ||
response.type !== "basic"
) {
return response;
}
// Clone response
const responseToCache = response.clone();
// Cache new response
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
.catch(() => {
// Offline fallback
if (event.request.mode === "navigate") {
return caches.match("/offline.html");
}
})
);
});
Caching Strategies
Cache First
// Good for: static assets (CSS, JS, images)
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
});
Network First
// Good for: API calls, frequently updated content
self.addEventListener("fetch", (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
// Update cache
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
})
.catch(() => caches.match(event.request))
);
});
Stale While Revalidate
// Good for: balanced freshness and performance
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
})
);
});
Advanced Cache Strategy
// sw.js - Different strategies for different requests
const CACHE_NAME = "my-pwa-v1";
const STATIC_ASSETS = ["/", "/index.html", "/css/styles.css", "/js/app.js"];
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Static assets - Cache First
if (STATIC_ASSETS.includes(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
// API calls - Network First
if (url.pathname.startsWith("/api/")) {
event.respondWith(networkFirst(request));
return;
}
// Images - Cache First with fallback
if (request.destination === "image") {
event.respondWith(cacheFirst(request));
return;
}
// Default - Stale While Revalidate
event.respondWith(staleWhileRevalidate(request));
});
async function cacheFirst(request) {
const cached = await caches.match(request);
return cached || fetch(request);
}
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch {
return caches.match(request);
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
Push Notifications
Request Permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === "granted") {
console.log("Notification permission granted");
await subscribeUserToPush();
} else {
console.log("Notification permission denied");
}
}
Subscribe to Push
async function subscribeUserToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
});
// Send subscription to server
await fetch("/api/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
headers: {
"Content-Type": "application/json",
},
});
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
Handle Push in Service Worker
// sw.js
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
const options = {
body: data.body || "New notification",
icon: "/icons/icon-192x192.png",
badge: "/icons/badge-72x72.png",
vibrate: [100, 50, 100],
data: {
url: data.url || "/",
},
actions: [
{ action: "open", title: "Open" },
{ action: "dismiss", title: "Dismiss" },
],
};
event.waitUntil(
self.registration.showNotification(data.title || "Notification", options)
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
if (event.action === "open" || !event.action) {
event.waitUntil(clients.openWindow(event.notification.data.url));
}
});
Install Prompt
Custom Install Button
let deferredPrompt;
window.addEventListener("beforeinstallprompt", (e) => {
// Prevent default prompt
e.preventDefault();
// Store event for later
deferredPrompt = e;
// Show install button
document.getElementById("installBtn").style.display = "block";
});
document.getElementById("installBtn").addEventListener("click", async () => {
if (!deferredPrompt) return;
// Show prompt
deferredPrompt.prompt();
// Wait for user response
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response: ${outcome}`);
// Clear stored prompt
deferredPrompt = null;
// Hide install button
document.getElementById("installBtn").style.display = "none";
});
window.addEventListener("appinstalled", () => {
console.log("PWA was installed");
deferredPrompt = null;
});
Background Sync
Register Sync
// app.js
async function sendData(data) {
try {
await fetch("/api/data", {
method: "POST",
body: JSON.stringify(data),
});
} catch {
// Store for later
await saveToIndexedDB(data);
// Register sync
const registration = await navigator.serviceWorker.ready;
await registration.sync.register("sync-data");
}
}
Handle Sync in Service Worker
// sw.js
self.addEventListener("sync", (event) => {
if (event.tag === "sync-data") {
event.waitUntil(syncData());
}
});
async function syncData() {
const data = await getFromIndexedDB();
for (const item of data) {
try {
await fetch("/api/data", {
method: "POST",
body: JSON.stringify(item),
});
await removeFromIndexedDB(item.id);
} catch {
// Will retry later
throw new Error("Sync failed");
}
}
}
PWA dengan Framework
Next.js PWA
npm install next-pwa
// next.config.js
const withPWA = require("next-pwa")({
dest: "public",
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === "development",
});
module.exports = withPWA({
// Next.js config
});
Vue PWA
vue add pwa
// vue.config.js
module.exports = {
pwa: {
name: "My PWA",
themeColor: "#4DBA87",
msTileColor: "#000000",
manifestOptions: {
start_url: "/",
},
workboxOptions: {
skipWaiting: true,
},
},
};
Create React App PWA
npx create-react-app my-pwa --template cra-template-pwa
// src/index.js
import * as serviceWorkerRegistration from "./serviceWorkerRegistration";
serviceWorkerRegistration.register();
Testing PWA
Lighthouse Audit
Chrome DevTools:
1. Open DevTools (F12)
2. Go to Lighthouse tab
3. Select "Progressive Web App"
4. Generate report
Check:
- Installable
- PWA Optimized
- Fast and reliable
PWA Checklist
□ HTTPS enabled
□ manifest.json linked
□ Service worker registered
□ Icons (all sizes)
□ Offline page works
□ Fast load time (< 3s)
□ Responsive design
□ Valid manifest
□ Start URL works offline
Kesimpulan
PWA memberikan user experience seperti native app dengan reach dari web. Focus pada core features: manifest, service worker, dan offline support.
Artikel Terkait
Link Postingan: https://www.tirinfo.com/cara-membuat-aplikasi-pwa/