Progressive Web App (PWA) di 2026: Install Website seperti Aplikasi Native
PWA memungkinkan website Anda diinstall di HP pengguna seperti aplikasi biasa, tanpa perlu Google Play atau App Store. Offline support, push notification, dan performa native — semua dari website.
Muhamad Putra Aulia Hidayat
Progressive Web App: Website yang Berasa Seperti Native App
Bayangkan website toko online Anda bisa di-install di HP pelanggan, muncul di home screen mereka, dan bisa diakses bahkan saat offline. Itu yang bisa dilakukan PWA.
Kenapa PWA Relevan di 2026?
- Tidak perlu App Store — tidak ada review process, tidak ada 30% fee
- Satu codebase — tidak perlu tim iOS dan Android terpisah
- Update instan — tidak perlu user update manual
- Biaya lebih rendah — develop dan maintain jauh lebih murah dari native app
Komponen Utama PWA
1. Web App Manifest
// public/manifest.json
{
"name": "Toko Digital Uptime",
"short_name": "DigiUpti",
"description": "Platform layanan digital terpercaya",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#2563eb",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}
2. Service Worker
// public/sw.js
const CACHE_NAME = "digiupti-v1"
const STATIC_ASSETS = [
"/",
"/offline",
"/icons/icon-192x192.png"
]
// Install: cache static assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
)
self.skipWaiting()
})
// Activate: hapus cache lama
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
)
)
)
self.clients.claim()
})
// Fetch: Network first, fallback to cache
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return
event.respondWith(
fetch(event.request)
.then(response => {
// Cache successful responses
const cloned = response.clone()
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, cloned)
})
return response
})
.catch(() => {
// Offline fallback
return caches.match(event.request)
.then(cached => cached || caches.match("/offline"))
})
)
})
// Push notifications
self.addEventListener("push", (event) => {
const data = event.data?.json()
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: "/icons/icon-192x192.png",
badge: "/icons/badge-72x72.png",
data: { url: data.url },
actions: [
{ action: "open", title: "Lihat" },
{ action: "dismiss", title: "Tutup" }
]
})
)
})
3. Register Service Worker di Next.js
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#2563eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
</head>
<body>
{children}
<ServiceWorkerRegistration />
</body>
</html>
)
}
// components/ServiceWorkerRegistration.tsx
"use client"
import { useEffect } from "react"
export function ServiceWorkerRegistration() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js")
.then(reg => console.log("SW registered:", reg.scope))
.catch(err => console.log("SW error:", err))
}
}, [])
return null
}
Push Notifications
// lib/push-notifications.ts
export async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
})
// Kirim subscription ke server
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription)
})
return subscription
}
// app/api/push/send/route.ts
import webpush from "web-push"
webpush.setVapidDetails(
"mailto:hello@digiupti.com",
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
export async function POST(req: Request) {
const { userId, title, body, url } = await req.json()
const subscription = await getSubscription(userId)
await webpush.sendNotification(
subscription,
JSON.stringify({ title, body, url })
)
return Response.json({ sent: true })
}
Install Prompt
"use client"
import { useState, useEffect } from "react"
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
const [showBanner, setShowBanner] = useState(false)
useEffect(() => {
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault()
setDeferredPrompt(e)
setShowBanner(true)
})
}, [])
const handleInstall = async () => {
if (!deferredPrompt) return
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === "accepted") setShowBanner(false)
}
if (!showBanner) return null
return (
<div className="fixed bottom-4 left-4 right-4 bg-primary text-primary-foreground p-4 rounded-xl shadow-lg">
<p className="font-semibold">Install aplikasi kami</p>
<p className="text-sm opacity-80">Akses lebih cepat langsung dari home screen</p>
<div className="flex gap-2 mt-3">
<button onClick={handleInstall} className="px-4 py-2 bg-white text-primary rounded-lg text-sm font-medium">
Install
</button>
<button onClick={() => setShowBanner(false)} className="px-4 py-2 opacity-70 text-sm">
Nanti saja
</button>
</div>
</div>
)
}
PWA adalah sweet spot antara web dan native app — terutama untuk pasar Indonesia di mana pengguna sensitif terhadap storage HP dan kuota internet.
Newsletter Digital Uptime
Tips teknologi & bisnis mingguan
Bergabung dengan 2,500+ subscriber yang mendapatkan insight teknologi, tutorial development, dan tips bisnis digital langsung ke inbox mereka setiap minggu.