Salin dan Bagikan
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/
Editor : Hendra WIjaya
Publisher :
Tirinfo
Read : 6 minutes.
Update : 7 January 2026