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