Progressive Web Apps (PWA)
Transform your Nuxt application into a Progressive Web App with offline support, installability, and native-like features.
Why PWA
- ✅ Work offline or on slow networks
- ✅ Install on device home screen
- ✅ Fast loading with service worker caching
- ✅ Push notifications (where supported)
- ✅ Improved mobile experience
PWA Module Setup
Install Vite PWA
npm install -D @vite-pwa/nuxt
Configure Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@vite-pwa/nuxt'],
pwa: {
registerType: 'autoUpdate',
manifest: {
name: 'My Nuxt App',
short_name: 'NuxtApp',
description: 'My awesome Nuxt application',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
start_url: '/',
icons: [
{
src: 'icons/icon-72x72.png',
sizes: '72x72',
type: 'image/png'
},
{
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'
}
]
},
workbox: {
navigateFallback: '/',
globPatterns: ['**/*.{js,css,html,png,svg,ico}']
},
client: {
installPrompt: true,
periodicSyncForUpdates: 20
},
devOptions: {
enabled: true,
type: 'module'
}
}
})
Caching Strategies
Network First (Dynamic Content)
// nuxt.config.ts
export default defineNuxtConfig({
pwa: {
workbox: {
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*$/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 300 // 5 minutes
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
}
}
})
Cache First (Static Assets)
runtimeCaching: [
{
urlPattern: /^https:\/\/cdn\.example\.com\/.*$/,
handler: 'CacheFirst',
options: {
cacheName: 'static-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 86400 // 24 hours
}
}
}
]
Stale While Revalidate (Best of Both)
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/posts$/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'posts-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 3600 // 1 hour
}
}
}
]
Offline Mode
Offline Page
<!-- pages/offline.vue -->
<script setup lang="ts">
definePageMeta({
layout: false
})
</script>
<template>
<div class="offline-page">
<h1>You're offline</h1>
<p>Please check your internet connection and try again.</p>
<button @click="$router.go(-1)">
Go Back
</button>
<button @click="location.reload()">
Retry
</button>
</div>
</template>
<style scoped>
.offline-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
padding: 2rem;
}
</style>
Configure Offline Fallback
// nuxt.config.ts
export default defineNuxtConfig({
pwa: {
workbox: {
navigateFallback: '/offline',
navigateFallbackDenylist: [/^\/api\//]
}
}
})
Online/Offline Detection
Composable for Network Status
// app/composables/useOnlineStatus.ts
export function useOnlineStatus() {
const isOnline = ref(true)
if (import.meta.client) {
isOnline.value = navigator.onLine
const updateOnlineStatus = () => {
isOnline.value = navigator.onLine
}
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
})
}
return {
isOnline: readonly(isOnline)
}
}
Using Online Status
<script setup lang="ts">
const { isOnline } = useOnlineStatus()
watch(isOnline, (online) => {
if (online) {
console.log('Back online!')
// Sync pending changes
} else {
console.log('Gone offline')
}
})
</script>
<template>
<div>
<div v-if="!isOnline" class="offline-banner">
You're currently offline. Some features may be unavailable.
</div>
<main>
<!-- Your content -->
</main>
</div>
</template>
App Installation
Install Prompt Component
<!-- components/InstallPrompt.vue -->
<script setup lang="ts">
const showInstallPrompt = ref(false)
const deferredPrompt = ref<any>(null)
onMounted(() => {
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent default mini-infobar
e.preventDefault()
// Save the event for later
deferredPrompt.value = e
// Show custom install prompt
showInstallPrompt.value = true
})
window.addEventListener('appinstalled', () => {
console.log('PWA installed')
showInstallPrompt.value = false
deferredPrompt.value = null
})
})
async function installApp() {
if (!deferredPrompt.value) return
// Show the install prompt
deferredPrompt.value.prompt()
// Wait for user choice
const { outcome } = await deferredPrompt.value.userChoice
console.log(`User ${outcome === 'accepted' ? 'accepted' : 'dismissed'} the install prompt`)
// Clear the deferred prompt
deferredPrompt.value = null
showInstallPrompt.value = false
}
function dismissPrompt() {
showInstallPrompt.value = false
}
</script>
<template>
<div v-if="showInstallPrompt" class="install-prompt">
<div class="install-content">
<h3>Install App</h3>
<p>Install this app on your device for a better experience</p>
<div class="install-actions">
<button @click="installApp">Install</button>
<button @click="dismissPrompt">Not now</button>
</div>
</div>
</div>
</template>
<style scoped>
.install-prompt {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 1rem;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.install-content {
max-width: 600px;
margin: 0 auto;
}
.install-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
</style>
Service Worker Updates
Update Notification
<!-- components/UpdatePrompt.vue -->
<script setup lang="ts">
import { useRegisterSW } from 'virtual:pwa-register/vue'
const {
needRefresh,
updateServiceWorker
} = useRegisterSW()
async function update() {
await updateServiceWorker()
}
</script>
<template>
<div v-if="needRefresh" class="update-prompt">
<p>New version available!</p>
<button @click="update">Update</button>
</div>
</template>
<style scoped>
.update-prompt {
position: fixed;
top: 1rem;
right: 1rem;
background: #4caf50;
color: white;
padding: 1rem;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 1000;
}
</style>
Background Sync
Queue Failed Requests
// app/composables/useOfflineQueue.ts
interface QueuedRequest {
id: string
url: string
method: string
body?: any
timestamp: number
}
export function useOfflineQueue() {
const queue = useLocalStorage<QueuedRequest[]>('offline-queue', [])
const { isOnline } = useOnlineStatus()
function addToQueue(url: string, method: string, body?: any) {
queue.value.push({
id: crypto.randomUUID(),
url,
method,
body,
timestamp: Date.now()
})
}
async function processQueue() {
if (!isOnline.value || queue.value.length === 0) return
const requests = [...queue.value]
queue.value = []
for (const req of requests) {
try {
await $fetch(req.url, {
method: req.method,
body: req.body
})
console.log('Synced:', req.id)
} catch (error) {
console.error('Failed to sync:', req.id, error)
// Re-queue if failed
queue.value.push(req)
}
}
}
// Process queue when coming online
watch(isOnline, (online) => {
if (online) {
processQueue()
}
})
return {
addToQueue,
processQueue,
queue: readonly(queue)
}
}
Using Offline Queue
const { isOnline } = useOnlineStatus()
const { addToQueue } = useOfflineQueue()
async function saveData(data: any) {
if (!isOnline.value) {
addToQueue('/api/data', 'POST', data)
// Show success message
return
}
try {
await $fetch('/api/data', {
method: 'POST',
body: data
})
} catch (error) {
if (!isOnline.value) {
addToQueue('/api/data', 'POST', data)
} else {
throw error
}
}
}
IndexedDB for Offline Storage
Database Wrapper
// utils/db.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb'
interface MyDB extends DBSchema {
posts: {
key: string
value: {
id: string
title: string
content: string
createdAt: number
}
indexes: { 'by-date': number }
}
}
let db: IDBPDatabase<MyDB> | null = null
export async function getDB() {
if (db) return db
db = await openDB<MyDB>('my-database', 1, {
upgrade(db) {
const postStore = db.createObjectStore('posts', {
keyPath: 'id'
})
postStore.createIndex('by-date', 'createdAt')
}
})
return db
}
export async function saveToDB(storeName: keyof MyDB, data: any) {
const db = await getDB()
await db.put(storeName, data)
}
export async function getFromDB(storeName: keyof MyDB, key: string) {
const db = await getDB()
return await db.get(storeName, key)
}
export async function getAllFromDB(storeName: keyof MyDB) {
const db = await getDB()
return await db.getAll(storeName)
}
export async function deleteFromDB(storeName: keyof MyDB, key: string) {
const db = await getDB()
await db.delete(storeName, key)
}
Using IndexedDB
// app/composables/usePosts.ts
export function usePosts() {
const posts = ref<any[]>([])
const { isOnline } = useOnlineStatus()
async function fetchPosts() {
if (isOnline.value) {
try {
posts.value = await $fetch('/api/posts')
// Save to IndexedDB
for (const post of posts.value) {
await saveToDB('posts', post)
}
} catch (error) {
// Fallback to IndexedDB
posts.value = await getAllFromDB('posts')
}
} else {
// Load from IndexedDB when offline
posts.value = await getAllFromDB('posts')
}
}
return {
posts: readonly(posts),
fetchPosts
}
}
Best Practices
✅ Do's
- ✅ Provide offline fallback pages
- ✅ Cache critical assets and API responses
- ✅ Show network status to users
- ✅ Queue failed requests for background sync
- ✅ Use IndexedDB for offline data storage
- ✅ Test on real devices with slow/offline connections
- ✅ Provide visual feedback during updates
❌ Don'ts
- ❌ Don't cache sensitive user data
- ❌ Don't make the cache too large
- ❌ Don't forget to version your cache
- ❌ Don't cache authentication endpoints
- ❌ Don't ignore service worker errors
- ❌ Don't force installation prompts immediately
Advanced Features
Push Notifications (Optional)
// app/composables/usePushNotifications.ts
export function usePushNotifications() {
const subscription = ref<PushSubscription | null>(null)
async function subscribe() {
if (!('serviceWorker' in navigator)) return
if (!('PushManager' in window)) return
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'YOUR_PUBLIC_VAPID_KEY'
})
subscription.value = sub
// Send subscription to server
await $fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(sub)
})
}
async function unsubscribe() {
if (!subscription.value) return
await subscription.value.unsubscribe()
subscription.value = null
}
return {
subscription: readonly(subscription),
subscribe,
unsubscribe
}
}
Share API Integration
async function shareContent(data: { title: string, text: string, url: string }) {
if (navigator.share) {
try {
await navigator.share(data)
} catch (error) {
console.log('Share cancelled')
}
} else {
// Fallback to copy link
await navigator.clipboard.writeText(data.url)
}
}
PWAs enhance web applications with offline capabilities, installability, and native-like features. Implement these patterns to provide a superior user experience across all network conditions.