Lewati ke konten
Kembali ke Blog

Cara Membuat Aplikasi PWA (Progressive Web App)

· · 10 menit baca

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
  1. Service Worker
    • Offline functionality
    • Caching
    • Push notifications
  1. 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" />
&lt;!-- PWA Meta Tags --&gt;
&lt;link rel=&quot;manifest&quot; href=&quot;/manifest.json&quot; /&gt;
&lt;meta name=&quot;theme-color&quot; content=&quot;#2196F3&quot; /&gt;
&lt;meta name=&quot;apple-mobile-web-app-capable&quot; content=&quot;yes&quot; /&gt;
&lt;meta name=&quot;apple-mobile-web-app-status-bar-style&quot; content=&quot;default&quot; /&gt;
&lt;meta name=&quot;apple-mobile-web-app-title&quot; content=&quot;My PWA&quot; /&gt;

&lt;!-- iOS Icons --&gt;
&lt;link rel=&quot;apple-touch-icon&quot; href=&quot;/icons/icon-152x152.png&quot; /&gt;

&lt;title&gt;My PWA App&lt;/title&gt;

</head>
<body>
<!-- App content -->

&lt;script src=&quot;/js/app.js&quot;&gt;&lt;/script&gt;

</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) =&gt; {
      // Check valid response
      if (
        !response ||
        response.status !== 200 ||
        response.type !== &quot;basic&quot;
      ) {
        return response;
      }

      // Clone response
      const responseToCache = response.clone();

      // Cache new response
      caches.open(CACHE_NAME).then((cache) =&gt; {
        cache.put(event.request, responseToCache);
      });

      return response;
    });
  })
  .catch(() =&gt; {
    // Offline fallback
    if (event.request.mode === &quot;navigate&quot;) {
      return caches.match(&quot;/offline.html&quot;);
    }
  })

);
});

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(&quot;sync-data&quot;);

}
}

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.

Ditulis oleh

Hendra Wijaya

Tinggalkan Komentar

Email tidak akan ditampilkan.