Salin dan Bagikan
Cara Membuat Aplikasi PWA (Progressive Web App) - Panduan lengkap membuat Progressive Web App (PWA) dari awal

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" }]
    }
  ]
}
<!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/

Hendra WIjaya
Tirinfo
6 minutes.
7 January 2026