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

Hanya hamba Allah Ta'ala yang berusaha berbuat baik..

Tinggalkan Komentar

Email tidak akan ditampilkan.