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.
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.