PWA (Progressive Web App): Panduan Implementasi
ID | EN

PWA (Progressive Web App): Panduan Implementasi

Senin, 29 Des 2025

Progressive Web App (PWA) adalah teknologi yang memungkinkan web app kamu berjalan seperti native app. User bisa install langsung dari browser, akses offline, dan dapat push notifications. Di 2025, PWA makin relevan karena semakin banyak bisnis yang butuh solusi cross-platform tanpa develop separate apps.

Kenapa PWA?

Sebelum masuk ke implementasi, ini alasan kenapa PWA worth it:

  • Satu codebase untuk semua platform - Web, Android, iOS dari satu project
  • Installable - User bisa “Add to Home Screen” tanpa app store
  • Offline-capable - Tetap bisa diakses tanpa internet
  • Push notifications - Engage user seperti native app
  • Auto-update - Gak perlu manual update via app store
  • Lebih ringan - Size jauh lebih kecil dari native app
  • SEO-friendly - Tetap indexable oleh search engine

Core PWA Technologies

PWA dibangun di atas 3 teknologi utama:

┌─────────────────────────────────────────┐
│           Progressive Web App            │
├─────────────────────────────────────────┤
│  1. Web App Manifest (manifest.json)    │
│  2. Service Workers (sw.js)             │
│  3. HTTPS (secure origin)               │
└─────────────────────────────────────────┘

Mari kita bahas satu per satu.

1. Web App Manifest

Manifest adalah file JSON yang memberitahu browser tentang PWA kamu. File ini mendefinisikan nama, icon, theme, dan behavior saat di-install.

Buat manifest.json

Letakkan di public/manifest.json:

{
  "name": "My Awesome PWA",
  "short_name": "MyPWA",
  "description": "Aplikasi keren yang bisa diakses offline",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0a0a0a",
  "theme_color": "#a855f7",
  "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",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "640x1136",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ],
  "shortcuts": [
    {
      "name": "Dashboard",
      "short_name": "Dashboard",
      "url": "/dashboard",
      "icons": [{ "src": "/icons/dashboard.png", "sizes": "192x192" }]
    },
    {
      "name": "Settings",
      "short_name": "Settings",
      "url": "/settings",
      "icons": [{ "src": "/icons/settings.png", "sizes": "192x192" }]
    }
  ],
  "categories": ["productivity", "utilities"],
  "lang": "id-ID",
  "dir": "ltr"
}
<head>
  <link rel="manifest" href="/manifest.json" />
  <meta name="theme-color" content="#a855f7" />
  
  <!-- iOS specific -->
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
  <meta name="apple-mobile-web-app-title" content="MyPWA" />
  <link rel="apple-touch-icon" href="/icons/icon-152x152.png" />
  
  <!-- Splash screens untuk iOS -->
  <link rel="apple-touch-startup-image" href="/splash/apple-splash-2048-2732.png" 
        media="(device-width: 1024px) and (device-height: 1366px)" />
</head>

Display Modes

Ada beberapa opsi untuk display:

ModeDeskripsi
fullscreenFull layar, tanpa UI browser sama sekali
standaloneSeperti native app, ada status bar tapi tanpa address bar
minimal-uiStandalone + navigasi minimal (back, reload)
browserBrowser biasa

Untuk kebanyakan kasus, standalone adalah pilihan terbaik.

2. Service Workers

Service Worker adalah JavaScript yang berjalan di background, terpisah dari main thread. Ini yang bikin PWA bisa offline, intercept network requests, dan handle push notifications.

Lifecycle Service Worker

┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│   Register   │ => │   Install    │ => │   Activate   │
└──────────────┘    └──────────────┘    └──────────────┘
                           │                    │
                           ▼                    ▼
                    Cache assets         Claim clients
                                         Clean old cache

Basic Service Worker

Buat public/sw.js:

const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
  '/',
  '/offline.html',
  '/css/style.css',
  '/js/app.js',
  '/icons/icon-192x192.png'
];

// Install event - cache static assets
self.addEventListener('install', (event) => {
  console.log('Service Worker: Installing...');
  
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('Service Worker: Caching static assets');
        return cache.addAll(STATIC_ASSETS);
      })
      .then(() => {
        // Skip waiting untuk langsung aktif
        return self.skipWaiting();
      })
  );
});

// Activate event - cleanup old caches
self.addEventListener('activate', (event) => {
  console.log('Service Worker: Activating...');
  
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => {
            console.log('Service Worker: Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    }).then(() => {
      // Claim semua clients
      return self.clients.claim();
    })
  );
});

// Fetch event - intercept requests
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        // Return cached version atau fetch dari network
        return cachedResponse || fetch(event.request);
      })
      .catch(() => {
        // Fallback ke offline page untuk navigation requests
        if (event.request.mode === 'navigate') {
          return caches.match('/offline.html');
        }
      })
  );
});

Register Service Worker

Di main JavaScript file atau component:

// register-sw.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/'
      });
      
      console.log('SW registered:', registration.scope);
      
      // Check for updates
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            // New version available
            showUpdateNotification();
          }
        });
      });
    } catch (error) {
      console.error('SW registration failed:', error);
    }
  });
}

function showUpdateNotification() {
  // Tampilkan UI untuk reload
  const updateBanner = document.createElement('div');
  updateBanner.innerHTML = `
    <div class="update-banner">
      <p>Update tersedia!</p>
      <button onclick="window.location.reload()">Refresh</button>
    </div>
  `;
  document.body.appendChild(updateBanner);
}

3. Caching Strategies

Strategi caching menentukan bagaimana PWA handle requests. Pilih berdasarkan jenis content.

Cache First (Cache Falling Back to Network)

Best untuk: Static assets (CSS, JS, images, fonts)

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        if (cachedResponse) {
          return cachedResponse;
        }
        
        return fetch(event.request).then((response) => {
          // Clone response karena bisa dibaca sekali
          const responseClone = response.clone();
          
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseClone);
          });
          
          return response;
        });
      })
  );
});

Network First (Network Falling Back to Cache)

Best untuk: API calls, dynamic content yang harus fresh

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // Cache the fresh response
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => {
        // Network failed, fallback to cache
        return caches.match(event.request);
      })
  );
});

Stale While Revalidate

Best untuk: Content yang boleh stale sebentar (news feed, social posts)

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cachedResponse) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        
        // Return cached version immediately, update in background
        return cachedResponse || fetchPromise;
      });
    })
  );
});

Strategy Router

Implementasi yang lebih advanced dengan routing:

// sw.js dengan strategy router
const strategies = {
  cacheFirst: async (request, cacheName) => {
    const cache = await caches.open(cacheName);
    const cached = await cache.match(request);
    if (cached) return cached;
    
    const response = await fetch(request);
    cache.put(request, response.clone());
    return response;
  },
  
  networkFirst: async (request, cacheName, timeout = 3000) => {
    const cache = await caches.open(cacheName);
    
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);
      
      const response = await fetch(request, { signal: controller.signal });
      clearTimeout(timeoutId);
      
      cache.put(request, response.clone());
      return response;
    } catch {
      return cache.match(request);
    }
  },
  
  staleWhileRevalidate: async (request, cacheName) => {
    const cache = await caches.open(cacheName);
    const cached = await cache.match(request);
    
    const fetchPromise = fetch(request).then((response) => {
      cache.put(request, response.clone());
      return response;
    });
    
    return cached || fetchPromise;
  }
};

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);
  
  // Static assets - Cache First
  if (request.destination === 'style' || 
      request.destination === 'script' || 
      request.destination === 'image') {
    event.respondWith(strategies.cacheFirst(request, 'static-v1'));
    return;
  }
  
  // API calls - Network First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(strategies.networkFirst(request, 'api-v1'));
    return;
  }
  
  // HTML pages - Stale While Revalidate
  if (request.mode === 'navigate') {
    event.respondWith(strategies.staleWhileRevalidate(request, 'pages-v1'));
    return;
  }
});

4. Offline Functionality

Buat halaman offline yang user-friendly:

<!-- public/offline.html -->
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Offline - MyPWA</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%);
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #fff;
    }
    
    .container {
      text-align: center;
      padding: 2rem;
    }
    
    .icon {
      font-size: 4rem;
      margin-bottom: 1rem;
    }
    
    h1 {
      font-size: 1.5rem;
      margin-bottom: 0.5rem;
    }
    
    p {
      color: #888;
      margin-bottom: 1.5rem;
    }
    
    button {
      background: linear-gradient(135deg, #a855f7, #3b82f6);
      border: none;
      padding: 0.75rem 2rem;
      border-radius: 8px;
      color: white;
      font-weight: 600;
      cursor: pointer;
      transition: transform 0.2s;
    }
    
    button:hover {
      transform: scale(1.05);
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="icon">📡</div>
    <h1>Kamu sedang offline</h1>
    <p>Cek koneksi internet dan coba lagi</p>
    <button onclick="window.location.reload()">Coba Lagi</button>
  </div>
  
  <script>
    // Auto retry ketika online
    window.addEventListener('online', () => {
      window.location.reload();
    });
  </script>
</body>
</html>

Background Sync untuk Offline Actions

// Di service worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-posts') {
    event.waitUntil(syncPendingPosts());
  }
});

async function syncPendingPosts() {
  const db = await openDB('pending-posts', 1);
  const posts = await db.getAll('posts');
  
  for (const post of posts) {
    try {
      await fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(post),
        headers: { 'Content-Type': 'application/json' }
      });
      
      await db.delete('posts', post.id);
    } catch (error) {
      console.error('Sync failed for post:', post.id);
    }
  }
}

// Di main app - queue offline action
async function createPost(data) {
  if (!navigator.onLine) {
    // Save to IndexedDB
    const db = await openDB('pending-posts', 1, {
      upgrade(db) {
        db.createObjectStore('posts', { keyPath: 'id', autoIncrement: true });
      }
    });
    
    await db.add('posts', { ...data, createdAt: Date.now() });
    
    // Register sync
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-posts');
    
    return { queued: true };
  }
  
  // Online - send directly
  return fetch('/api/posts', {
    method: 'POST',
    body: JSON.stringify(data),
    headers: { 'Content-Type': 'application/json' }
  });
}

5. Install Prompt

Customize kapan dan bagaimana install prompt muncul:

// install-prompt.js
let deferredPrompt;
const installButton = document.getElementById('install-btn');

// Intercept the install prompt
window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent default prompt
  e.preventDefault();
  
  // Store for later use
  deferredPrompt = e;
  
  // Show custom install button
  installButton.style.display = 'block';
  
  // Analytics
  trackEvent('pwa_install_available');
});

// Handle install button click
installButton.addEventListener('click', async () => {
  if (!deferredPrompt) return;
  
  // Show native prompt
  deferredPrompt.prompt();
  
  // Wait for user response
  const { outcome } = await deferredPrompt.userChoice;
  
  console.log('User choice:', outcome);
  trackEvent('pwa_install_prompt', { outcome });
  
  // Clear the prompt
  deferredPrompt = null;
  installButton.style.display = 'none';
});

// Detect successful installation
window.addEventListener('appinstalled', () => {
  console.log('PWA installed successfully');
  trackEvent('pwa_installed');
  
  // Hide install button
  installButton.style.display = 'none';
  deferredPrompt = null;
});

// Check if already installed
function isInstalled() {
  // Check display-mode
  if (window.matchMedia('(display-mode: standalone)').matches) {
    return true;
  }
  
  // iOS Safari
  if (window.navigator.standalone === true) {
    return true;
  }
  
  return false;
}

// Conditionally show install prompt
if (!isInstalled()) {
  // Show install promotion after some engagement
  setTimeout(() => {
    if (deferredPrompt) {
      showInstallPromotion();
    }
  }, 30000); // 30 seconds
}

Install Prompt UI Component (React)

// components/InstallPrompt.tsx
import { useState, useEffect } from 'react';

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

export function InstallPrompt() {
  const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault();
      setDeferredPrompt(e as BeforeInstallPromptEvent);
      setIsVisible(true);
    };

    window.addEventListener('beforeinstallprompt', handler);
    
    return () => {
      window.removeEventListener('beforeinstallprompt', handler);
    };
  }, []);

  const handleInstall = async () => {
    if (!deferredPrompt) return;
    
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    
    if (outcome === 'accepted') {
      setIsVisible(false);
    }
    setDeferredPrompt(null);
  };

  const handleDismiss = () => {
    setIsVisible(false);
    // Don't show again for a while
    localStorage.setItem('pwa-prompt-dismissed', Date.now().toString());
  };

  if (!isVisible) return null;

  return (
    <div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80 
                    bg-gray-900 border border-gray-700 rounded-xl p-4 shadow-xl z-50">
      <div className="flex items-start gap-3">
        <div className="text-3xl">📲</div>
        <div className="flex-1">
          <h3 className="font-semibold text-white">Install App</h3>
          <p className="text-sm text-gray-400 mt-1">
            Install untuk akses lebih cepat dan fitur offline
          </p>
          <div className="flex gap-2 mt-3">
            <button
              onClick={handleInstall}
              className="px-4 py-2 bg-purple-600 hover:bg-purple-700 
                       text-white text-sm font-medium rounded-lg transition"
            >
              Install
            </button>
            <button
              onClick={handleDismiss}
              className="px-4 py-2 text-gray-400 hover:text-white 
                       text-sm transition"
            >
              Nanti saja
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

6. Push Notifications

Push notifications butuh 3 komponen: permission, subscription, dan handling.

Request Permission

// notifications.js
async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    console.log('Browser tidak support notifications');
    return false;
  }
  
  if (Notification.permission === 'granted') {
    return true;
  }
  
  if (Notification.permission === 'denied') {
    console.log('User sudah block notifications');
    return false;
  }
  
  // Request permission
  const permission = await Notification.requestPermission();
  return permission === 'granted';
}

Subscribe to Push

// VAPID keys - generate dengan web-push library
const VAPID_PUBLIC_KEY = 'your-vapid-public-key';

async function subscribeToPush() {
  const permission = await requestNotificationPermission();
  if (!permission) return null;
  
  const registration = await navigator.serviceWorker.ready;
  
  // Check existing subscription
  let subscription = await registration.pushManager.getSubscription();
  
  if (!subscription) {
    // Create new subscription
    subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
    });
    
    // Send subscription to backend
    await fetch('/api/push/subscribe', {
      method: 'POST',
      body: JSON.stringify(subscription),
      headers: { 'Content-Type': 'application/json' }
    });
  }
  
  return subscription;
}

// Helper function
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) => {
  if (!event.data) return;
  
  const data = event.data.json();
  
  const options = {
    body: data.body,
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    image: data.image,
    vibrate: [100, 50, 100],
    data: {
      url: data.url,
      dateOfArrival: Date.now()
    },
    actions: [
      { action: 'open', title: 'Buka' },
      { action: 'close', title: 'Tutup' }
    ],
    tag: data.tag || 'default',
    renotify: true
  };
  
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Handle notification click
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  
  if (event.action === 'close') return;
  
  const url = event.notification.data?.url || '/';
  
  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true })
      .then((clientList) => {
        // Check if already open
        for (const client of clientList) {
          if (client.url === url && 'focus' in client) {
            return client.focus();
          }
        }
        // Open new window
        if (clients.openWindow) {
          return clients.openWindow(url);
        }
      })
  );
});

Backend Push Service (Node.js)

// server/push.js
const webpush = require('web-push');

// Generate VAPID keys sekali: npx web-push generate-vapid-keys
webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

// Store subscriptions (gunakan database di production)
const subscriptions = new Map();

async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(subscription, JSON.stringify(payload));
    return { success: true };
  } catch (error) {
    if (error.statusCode === 410) {
      // Subscription expired, remove from database
      subscriptions.delete(subscription.endpoint);
    }
    return { success: false, error };
  }
}

// Example: Send to all subscribers
async function broadcastNotification(payload) {
  const results = await Promise.allSettled(
    [...subscriptions.values()].map(sub => 
      sendPushNotification(sub, payload)
    )
  );
  
  return results;
}

// API endpoint
app.post('/api/push/subscribe', (req, res) => {
  const subscription = req.body;
  subscriptions.set(subscription.endpoint, subscription);
  res.json({ success: true });
});

app.post('/api/push/send', async (req, res) => {
  const { title, body, url } = req.body;
  
  const results = await broadcastNotification({
    title,
    body,
    url,
    tag: 'broadcast'
  });
  
  res.json({ sent: results.length });
});

7. PWA dengan Next.js (next-pwa)

Next.js punya integration yang bagus dengan PWA lewat next-pwa package.

Setup next-pwa

npm install next-pwa

Konfigurasi next.config.js

// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
  disable: process.env.NODE_ENV === 'development',
  
  // Runtime caching
  runtimeCaching: [
    {
      urlPattern: /^https:\/\/fonts\.(?:gstatic|googleapis)\.com\/.*/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'google-fonts',
        expiration: {
          maxEntries: 10,
          maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
        }
      }
    },
    {
      urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'static-fonts',
        expiration: {
          maxEntries: 10,
          maxAgeSeconds: 60 * 60 * 24 * 7 // 1 week
        }
      }
    },
    {
      urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'static-images',
        expiration: {
          maxEntries: 100,
          maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
        }
      }
    },
    {
      urlPattern: /\/_next\/image\?url=.+$/i,
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'next-images',
        expiration: {
          maxEntries: 100,
          maxAgeSeconds: 60 * 60 * 24 * 30
        }
      }
    },
    {
      urlPattern: /\.(?:js|css)$/i,
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'static-resources',
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 60 * 60 * 24 * 7
        }
      }
    },
    {
      urlPattern: /^https:\/\/api\.yoursite\.com\/.*/i,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api-cache',
        networkTimeoutSeconds: 10,
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 60 * 60 // 1 hour
        },
        cacheableResponse: {
          statuses: [0, 200]
        }
      }
    },
    {
      urlPattern: /.*/i,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'others',
        networkTimeoutSeconds: 10,
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 60 * 60 * 24
        }
      }
    }
  ]
});

module.exports = withPWA({
  reactStrictMode: true,
  // other Next.js config
});

Manifest di Next.js

Letakkan manifest.json di public/:

{
  "name": "My Next.js PWA",
  "short_name": "NextPWA",
  "description": "PWA built with Next.js",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0a0a0a",
  "theme_color": "#a855f7",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

Metadata di layout.tsx

// app/layout.tsx
import type { Metadata, Viewport } from 'next';

export const viewport: Viewport = {
  themeColor: '#a855f7',
  width: 'device-width',
  initialScale: 1,
  maximumScale: 1,
  userScalable: false,
};

export const metadata: Metadata = {
  title: 'My Next.js PWA',
  description: 'PWA built with Next.js',
  manifest: '/manifest.json',
  appleWebApp: {
    capable: true,
    statusBarStyle: 'black-translucent',
    title: 'NextPWA',
  },
  formatDetection: {
    telephone: false,
  },
  openGraph: {
    type: 'website',
    siteName: 'My Next.js PWA',
    title: 'My Next.js PWA',
    description: 'PWA built with Next.js',
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="id">
      <head>
        <link rel="apple-touch-icon" href="/icons/icon-152x152.png" />
      </head>
      <body>{children}</body>
    </html>
  );
}

8. Testing PWA dengan Lighthouse

Lighthouse adalah tool terbaik untuk audit PWA. Bisa diakses via Chrome DevTools.

Menjalankan Lighthouse Audit

  1. Buka Chrome DevTools (F12)
  2. Tab “Lighthouse”
  3. Pilih category “Progressive Web App”
  4. Click “Analyze page load”

PWA Checklist

Lighthouse akan check:

RequirementDeskripsi
✅ HTTPSSite harus served via HTTPS
✅ Service WorkerSW terdaftar dan controlling page
✅ Web App ManifestManifest valid dengan required fields
✅ IconsMinimal 192x192 dan 512x512
✅ Start URLstart_url di manifest
✅ Splash ScreenBackground color + icons
✅ Theme ColorTheme color di manifest
✅ ViewportProper viewport meta tag
✅ Content SizedNo horizontal scroll
✅ OfflineWorks offline (200 response)

Programmatic Lighthouse Testing

// lighthouse.config.js
module.exports = {
  ci: {
    collect: {
      url: ['http://localhost:3000'],
      startServerCommand: 'npm run start',
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:pwa': ['error', { minScore: 0.9 }],
        'categories:performance': ['warn', { minScore: 0.8 }],
        'categories:accessibility': ['error', { minScore: 0.9 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};
# Install Lighthouse CI
npm install -g @lhci/cli

# Run audit
lhci autorun

9. iOS Considerations

iOS Safari punya beberapa keterbatasan untuk PWA:

Limitasi iOS

  • No Push Notifications (iOS 16.4+ finally support, tapi limited)
  • No Background Sync
  • Storage limit ~50MB per origin
  • No install prompt - harus manual “Add to Home Screen”
  • Separate WebView - tidak share cookies/storage dengan Safari

Workarounds

<!-- iOS-specific meta tags -->
<head>
  <!-- Enable standalone mode -->
  <meta name="apple-mobile-web-app-capable" content="yes" />
  
  <!-- Status bar style -->
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
  
  <!-- App title -->
  <meta name="apple-mobile-web-app-title" content="MyPWA" />
  
  <!-- Disable auto-detection -->
  <meta name="format-detection" content="telephone=no" />
  
  <!-- Touch icons -->
  <link rel="apple-touch-icon" href="/icons/icon-180x180.png" />
  
  <!-- Splash screens -->
  <link rel="apple-touch-startup-image" 
        href="/splash/apple-splash-1170-2532.png"
        media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3)" />
</head>

Detect iOS Standalone Mode

function isIOSStandalone() {
  return (
    window.navigator.standalone === true || 
    window.matchMedia('(display-mode: standalone)').matches
  );
}

// Show iOS install instructions
function showIOSInstallGuide() {
  const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
  const isInStandalone = isIOSStandalone();
  
  if (isIOS && !isInStandalone) {
    showModal({
      title: 'Install App',
      content: `
        <ol>
          <li>Tap tombol Share di Safari</li>
          <li>Scroll dan tap "Add to Home Screen"</li>
          <li>Tap "Add" untuk confirm</li>
        </ol>
      `
    });
  }
}

10. App Store Alternatives dengan PWABuilder

PWABuilder memungkinkan kamu package PWA untuk berbagai store.

Generate Store Packages

  1. Kunjungi pwabuilder.com
  2. Masukkan URL PWA kamu
  3. PWABuilder akan analyze dan score PWA
  4. Download packages untuk:
    • Microsoft Store (Windows)
    • Google Play Store (TWA - Trusted Web Activity)
    • Apple App Store (via PWA wrapper)
    • Meta Quest Store (VR)

Generate TWA untuk Play Store

# Install Bubblewrap CLI
npm install -g @anthropic/bubblewrap-cli

# Initialize TWA project
bubblewrap init --manifest https://yoursite.com/manifest.json

# Build APK
bubblewrap build

Buat /.well-known/assetlinks.json:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.yoursite.twa",
    "sha256_cert_fingerprints": [
      "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX"
    ]
  }
}]

Real-World PWA Examples

Beberapa PWA sukses untuk inspirasi:

1. Twitter Lite

  • 65% increase di pages per session
  • 75% increase di tweets sent
  • 20% decrease di bounce rate

2. Starbucks

  • PWA 99.84% lebih kecil dari iOS app
  • 2x daily active users

3. Pinterest

  • 40% more time spent
  • 44% more ad revenue
  • 60% increase di engagement

4. Spotify

  • Web player full-featured
  • Offline playlists
  • Background playback

Best Practices Summary

✅ DO:
- Cache critical assets saat install
- Implement proper caching strategies
- Handle offline gracefully
- Use HTTPS everywhere
- Test di berbagai devices dan browsers
- Monitor performance dengan analytics
- Provide clear install instructions untuk iOS

❌ DON'T:
- Cache everything (storage limit!)
- Ignore service worker updates
- Forget about iOS users
- Skip Lighthouse audits
- Use localStorage untuk large data
- Assume push notification support

Conclusion

PWA adalah cara powerful untuk deliver app-like experience tanpa kompleksitas native development. Di 2025, dengan semakin banyaknya support dari browsers dan platforms, PWA makin jadi pilihan yang masuk akal untuk banyak use cases.

Key takeaways:

  1. Manifest + Service Worker + HTTPS = PWA foundation
  2. Pilih caching strategy yang tepat untuk tiap resource type
  3. Offline experience harus user-friendly
  4. Install prompt yang strategic meningkatkan adoption
  5. Test dengan Lighthouse untuk ensure compliance
  6. iOS butuh extra attention karena limitasi Safari
  7. PWABuilder bisa bantu distribute ke app stores

Mulai dari yang basic, dan iteratively tambah fitur sesuai kebutuhan user. Happy building! 🚀