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

  1. ✅ Provide offline fallback pages
  2. ✅ Cache critical assets and API responses
  3. ✅ Show network status to users
  4. ✅ Queue failed requests for background sync
  5. ✅ Use IndexedDB for offline data storage
  6. ✅ Test on real devices with slow/offline connections
  7. ✅ Provide visual feedback during updates

❌ Don'ts

  1. ❌ Don't cache sensitive user data
  2. ❌ Don't make the cache too large
  3. ❌ Don't forget to version your cache
  4. ❌ Don't cache authentication endpoints
  5. ❌ Don't ignore service worker errors
  6. ❌ 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.

Backend-for-Frontend (BFF) Pattern
Learn how to implement the BFF pattern in Nuxt to aggregate multiple APIs, transform data, and provide a unified interface for your frontend.
ESLint and Code Conventions
Establish consistent code quality and style across your Nuxt project with ESLint, TypeScript rules, and automated formatting.
Files
Editor
Initializing WebContainer
Mounting files
Installing dependencies
Starting Nuxt server
Waiting for Nuxt to ready
Terminal