Skip to main content
Engineering7 min readMarch 3, 2026

Building a PWA With Nuxt: Offline Support and App-Like Features

How to build a production-ready Progressive Web App with Nuxt — service workers, offline support, push notifications, install prompts, and the @vite-pwa/nuxt module.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Progressive Web Apps occupy an interesting position. They are not as capable as native apps, but they are dramatically more capable than regular websites — and the installation and distribution story is far simpler than app stores. For the right use cases, a PWA is the best of both worlds.

I have built production PWAs with Nuxt for clients who needed mobile-app-like experiences without the overhead of maintaining separate iOS and Android codebases. The tooling has matured enough that the implementation is no longer painful — but there are still decisions to make carefully.

What Makes a PWA

A Progressive Web App must satisfy three criteria:

Served over HTTPS. Security requirement for service workers. Every hosting platform worth using enables this by default.

A web app manifest. A JSON file that tells browsers how to present the app when installed: name, icon, colors, display mode.

A service worker. A JavaScript worker that intercepts network requests, enables offline support, and handles background sync and push notifications.

That is the technical minimum. In practice, a good PWA also has fast performance (Lighthouse PWA audit should pass), responsive design that works on mobile, and icons at multiple sizes.

Setting Up @vite-pwa/nuxt

The @vite-pwa/nuxt module (a Nuxt adapter for Vite PWA plugin) handles service worker generation and manifest configuration:

npm install --save-dev @vite-pwa/nuxt

Add it to your Nuxt config:

// nuxt.config.ts
modules: ['@vite-pwa/nuxt'],

pwa: {
  registerType: 'autoUpdate',
  manifest: {
    name: 'My Application',
    short_name: 'MyApp',
    description: 'My app description',
    theme_color: '#2563eb',
    background_color: '#ffffff',
    display: 'standalone',
    orientation: 'portrait',
    icons: [
      {
        src: '/icons/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icons/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
      },
      {
        src: '/icons/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
        purpose: 'maskable',
      },
    ],
  },
  workbox: {
    navigateFallback: '/',
    cleanupOutdatedCaches: true,
    globPatterns: ['**/*.{js,css,html,png,svg,ico,txt}'],
  },
  client: {
    installPrompt: true,
    periodicSyncForUpdates: 3600,
  },
  devOptions: {
    enabled: true,
    suppressWarnings: true,
    navigateFallback: '/',
    type: 'module',
  },
},

Caching Strategies

The most important part of your service worker configuration is the caching strategy. Different content types need different strategies.

Cache First for static assets (JS, CSS, fonts, images): serve from cache immediately, refresh in background. The best strategy for assets that change only on deployment.

Network First for dynamic content (API responses, user data): try the network first, fall back to cache on failure. Ensures freshness while providing offline fallback.

Stale While Revalidate for content that can be slightly stale: serve cache immediately, refresh in background. Best for content where a slightly outdated version is acceptable.

Configure runtime caching in your workbox options:

workbox: {
  runtimeCaching: [
    {
      urlPattern: ({ request }) => request.destination === 'image',
      handler: 'CacheFirst',
      options: {
        cacheName: 'images-cache',
        expiration: {
          maxEntries: 100,
          maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
        },
      },
    },
    {
      urlPattern: /^https:\/\/api\.yourdomain\.com\//,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api-cache',
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 60 * 60, // 1 hour fallback
        },
        networkTimeoutSeconds: 5,
      },
    },
    {
      urlPattern: /^https:\/\/fonts\.googleapis\.com\//,
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'google-fonts-stylesheets',
      },
    },
  ],
},

Offline UI

Detecting offline state and showing appropriate UI is essential for a good PWA experience. Users need to know when they are offline and understand what functionality is limited:

// composables/useNetwork.ts
export function useNetwork() {
  const isOnline = ref(navigator.onLine)

  window.addEventListener('online', () => { isOnline.value = true })
  window.addEventListener('offline', () => { isOnline.value = false })

  onUnmounted(() => {
    window.removeEventListener('online', () => {})
    window.removeEventListener('offline', () => {})
  })

  return { isOnline: readonly(isOnline) }
}
<!-- components/OfflineBanner.client.vue -->
<script setup lang="ts">
const { isOnline } = useNetwork()
</script>

<template>
  <Transition name="slide-down">
    <div
      v-if="!isOnline"
      class="fixed top-0 left-0 right-0 z-50 bg-yellow-500 text-white text-center py-2 text-sm font-medium"
      role="alert"
      aria-live="polite"
    >
      You are offline. Some features may not be available.
    </div>
  </Transition>
</template>

Include this in your layout and it will automatically appear when connectivity is lost.

Update Prompts

When a new version of your app deploys, users with the old version cached need to know an update is available. The module provides hooks for this:

<!-- components/UpdatePrompt.client.vue -->
<script setup lang="ts">
const { needRefresh, updateServiceWorker } = useRegisterSW({
  onRegisteredSW(swUrl) {
    console.log(`Service Worker at: ${swUrl}`)
  },
})

async function update() {
  await updateServiceWorker(true)
}
</script>

<template>
  <Transition name="slide-up">
    <div
      v-if="needRefresh"
      class="fixed bottom-4 right-4 bg-white border border-gray-200 rounded-xl shadow-lg p-4 z-50 flex items-center gap-4"
      role="alert"
    >
      <div>
        <p class="font-medium text-gray-900">Update available</p>
        <p class="text-sm text-gray-500">A new version of the app is ready.</p>
      </div>
      <button
        @click="update"
        class="bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-blue-700"
      >
        Refresh
      </button>
    </div>
  </Transition>
</template>

Install Prompt

Browsers show an install prompt when your PWA meets the install criteria. You can intercept this prompt and show your own UI at a better time:

// composables/usePWAInstall.ts
export function usePWAInstall() {
  const installPrompt = ref<BeforeInstallPromptEvent | null>(null)
  const isInstalled = ref(window.matchMedia('(display-mode: standalone)').matches)

  window.addEventListener('beforeinstallprompt', (e) => {
    e.preventDefault()
    installPrompt.value = e as BeforeInstallPromptEvent
  })

  window.addEventListener('appinstalled', () => {
    isInstalled.value = true
    installPrompt.value = null
  })

  async function promptInstall() {
    if (!installPrompt.value) return

    installPrompt.value.prompt()
    const { outcome } = await installPrompt.value.userChoice

    if (outcome === 'accepted') {
      installPrompt.value = null
    }
  }

  return {
    canInstall: computed(() => !isInstalled.value && installPrompt.value !== null),
    isInstalled: readonly(isInstalled),
    promptInstall,
  }
}

Show the install button contextually — after a user has interacted with the app meaningfully, not immediately on first visit. First-visit install prompts are ignored almost universally.

Push Notifications

Push notifications require server-side integration through the Web Push API. Generate VAPID keys and store them securely:

npx web-push generate-vapid-keys

Subscribe users in the browser:

async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(
      useRuntimeConfig().public.vapidPublicKey
    ),
  })

  await $fetch('/api/push/subscribe', {
    method: 'POST',
    body: subscription.toJSON(),
  })
}

Send from your server:

// server/api/push/send.post.ts
import webpush from 'web-push'

webpush.setVapidDetails(
  'mailto:admin@yourdomain.com',
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
)

export default defineEventHandler(async (event) => {
  const { subscription, payload } = await readBody(event)
  await webpush.sendNotification(subscription, JSON.stringify(payload))
  return { success: true }
})

Performance and Lighthouse

A PWA should score 90+ on the Lighthouse PWA audit. The module handles most requirements automatically, but verify:

  • The manifest is valid and includes required fields
  • Icons are present at 192x192 and 512x512 minimum
  • The service worker is registered and active
  • The app works offline (test with DevTools > Network > Offline)
  • The install prompt appears in Chrome after meeting criteria

PWAs work best for applications users return to repeatedly — productivity tools, reference apps, social platforms. For single-visit content sites, the PWA overhead is not worth the complexity. Match the investment to the use case.


Building a PWA with Nuxt and need help with the service worker strategy or push notification setup? Book a call and we can work through the architecture together: calendly.com/jamesrossjr.


Keep Reading