December 19, 2025 · 12 min

Offline Recipe Box with Sync and Install using Nuxt 4 + TypeScript + PWA

Build an installable offline-first Recipe Box with background-safe sync using Nuxt 4, TypeScript, IndexedDB, Firebase, and @vite-pwa/nuxt.

Offline Recipe Box with Sync and Install using Nuxt 4 + TypeScript + PWA

offline-recipe-box

We’ll build an installable, offline-first Recipe Box using Nuxt 4, TypeScript, IndexedDB for local storage, Firestore for cloud sync, and the latest @vite-pwa/nuxt to get a first-class PWA experience with dev service worker support and easy asset handling.

Tags: Nuxt 4, TypeScript, PWA, @vite-pwa/nuxt, Firebase, IndexedDB

Time to read: 12 min

What’s new we’ll use:

  • @vite-pwa/nuxt with devOptions.enabled for service worker in dev (faster iteration)
  • Modern Workbox runtime caching for robust app-shell + media caching
  • Clean install UX and update prompts via virtual:pwa-register/vue

Prerequisites

  • Node.js 18+ recommended
  • A Google Firebase project (Firestore enabled)
  • Basic familiarity with Nuxt 4 and TypeScript

1) Scaffold a fresh Nuxt 4 app

npx nuxi@latest init nuxt4-recipe-box
cd nuxt4-recipe-box
npm install

Run it:

npm run dev

Visit http://localhost:3000 to confirm it’s working.

2) Install dependencies

We’ll use:

  • @vite-pwa/nuxt for PWA
  • idb for IndexedDB
  • firebase (modular SDK)
  • @vueuse/core for online/offline status helpers
npm i @vite-pwa/nuxt idb firebase @vueuse/core

3) Configure the PWA (Nuxt 4 + @vite-pwa/nuxt)

Open nuxt.config.ts and add the module + a solid PWA configuration with service worker enabled in dev for quick testing and good offline fallbacks.

// nuxt.config.ts
import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({
  modules: ['@vite-pwa/nuxt'],

  // Expose Firebase config via public runtime config (read from .env)
  runtimeConfig: {
    public: {
      firebase: {
        apiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY,
        authDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
        projectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID,
        appId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID,
        storageBucket: process.env.NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
        messagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
      }
    }
  },

  typescript: {
    strict: true
  },

  pwa: {
    // Update SW automatically and show update prompt quickly
    registerType: 'autoUpdate',

    // In-dev SW support (newer pattern for fast PWA dev)
    devOptions: {
      enabled: true,
      suppressWarnings: true,
      navigateFallback: '/offline'
    },

    // Generate and manage web app assets automatically
    pwaAssets: {
      disabled: false
    },

    manifest: {
      name: 'Nuxt 4 Offline Recipe Box',
      short_name: 'RecipeBox',
      description: 'Installable offline-first recipe box with sync.',
      theme_color: '#10B981',
      background_color: '#FFFFFF',
      display: 'standalone',
      start_url: '/',
      scope: '/',
      icons: [
        { src: '/pwa-icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
        { src: '/pwa-icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
        { src: '/pwa-icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
        { src: '/pwa-icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
      ]
    },

    workbox: {
      navigateFallback: '/offline',
      globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
      runtimeCaching: [
        // Cache-first for images (recipe photos, thumbnails)
        {
          urlPattern: ({ request }) => request.destination === 'image',
          handler: 'CacheFirst',
          options: {
            cacheName: 'images',
            expiration: {
              maxEntries: 200,
              maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
            }
          }
        },
        // Google Fonts (if you use them)
        {
          urlPattern: /^https:\/\/fonts\.(?:gstatic|googleapis)\.com\/.*/i,
          handler: 'CacheFirst',
          options: {
            cacheName: 'google-fonts',
            expiration: {
              maxEntries: 30,
              maxAgeSeconds: 60 * 60 * 24 * 365
            }
          }
        }
      ]
    }
  }
})

Place simple icons in public/ (or keep your own):

  • public/pwa-icon-192.png
  • public/pwa-icon-512.png
  • public/pwa-icon-maskable-192.png
  • public/pwa-icon-maskable-512.png

4) Add an Offline fallback page

The service worker will route navigations to this page when offline and the route isn’t cached yet.

Create pages/offline.vue:

<script setup lang="ts">
useHead({ title: 'Offline • RecipeBox' })
</script>

<template>
  <main class="p-6 max-w-xl mx-auto text-center">
    <h1 class="text-2xl font-semibold">You’re Offline</h1>
    <p class="mt-2 text-gray-600">
      Don’t worry — your recipes are available offline.
      Some features (like cloud sync) will resume when you’re online again.
    </p>
    <NuxtLink class="mt-6 inline-block text-green-600 underline" to="/">Back Home</NuxtLink>
  </main>
</template>

<style scoped>
main { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
</style>

5) Firebase setup (client-only Firestore with offline persistence)

Create a Firebase project (console.firebase.google.com), enable Firestore (in test mode for local dev), and grab your web app config.

Add a .env.local file at the project root:

NUXT_PUBLIC_FIREBASE_API_KEY=your_api_key
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_auth_domain
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
NUXT_PUBLIC_FIREBASE_APP_ID=your_app_id
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_bucket
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_sender_id

Create plugins/firebase.client.ts:

// plugins/firebase.client.ts
import { defineNuxtPlugin, useRuntimeConfig } from '#app'
import { initializeApp, getApps } from 'firebase/app'
import { getFirestore, enableIndexedDbPersistence } from 'firebase/firestore'

export default defineNuxtPlugin(async () => {
  const { public: { firebase } } = useRuntimeConfig()

  // Avoid re-initialization in HMR/dev
  if (!getApps().length) {
    initializeApp({
      apiKey: firebase.apiKey,
      authDomain: firebase.authDomain,
      projectId: firebase.projectId,
      appId: firebase.appId,
      storageBucket: firebase.storageBucket,
      messagingSenderId: firebase.messagingSenderId
    })
  }

  const db = getFirestore()

  try {
    await enableIndexedDbPersistence(db)
  } catch (e) {
    // Fallback silently if persistence is already enabled in another tab or blocked
    console.warn('Firestore persistence not enabled:', e)
  }

  return {
    provide: {
      firestore: db
    }
  }
})

This gives us a Firestore instance with built-in offline persistence — any writes you make while offline will be queued and synced automatically when connectivity is restored.

6) IndexedDB composable for local source of truth

We’ll keep recipes in IndexedDB for fast access and control over the sync state. Firestore is used as the cloud source we push to/merge from when we’re online.

Create composables/useRecipes.ts:

// composables/useRecipes.ts
import { ref, computed, onMounted } from 'vue'
import { openDB, type IDBPDatabase } from 'idb'
import { useOnline } from '@vueuse/core'
import type { Firestore } from 'firebase/firestore'
import { collection, writeBatch, doc, getDocs, setDoc, serverTimestamp, query, orderBy } from 'firebase/firestore'

export interface Recipe {
  id: string
  title: string
  ingredients: string
  steps: string
  updatedAt: number // epoch ms
  synced: boolean   // false if needs push to cloud
}

type RecipeDB = IDBPDatabase<unknown>

let dbPromise: Promise<RecipeDB> | null = null

async function getDB(): Promise<RecipeDB> {
  if (!dbPromise) {
    dbPromise = openDB('recipe-box', 1, {
      upgrade(db) {
        const store = db.createObjectStore('recipes', { keyPath: 'id' })
        store.createIndex('updatedAt', 'updatedAt')
        store.createIndex('synced', 'synced')
      }
    }) as Promise<RecipeDB>
  }
  return dbPromise
}

export function useRecipes() {
  const recipes = ref<Recipe[]>([])
  const online = useOnline()
  const pendingCount = computed(() => recipes.value.filter(r => !r.synced).length)

  async function loadAll() {
    const db = await getDB()
    const tx = db.transaction('recipes')
    const store = tx.objectStore('recipes')
    const index = store.index('updatedAt')
    const all = await index.getAll()
    // newest first
    recipes.value = (all as Recipe[]).sort((a, b) => b.updatedAt - a.updatedAt)
  }

  async function upsertRecipe(r: Omit<Recipe, 'updatedAt' | 'synced'> & Partial<Pick<Recipe, 'synced'>>) {
    const db = await getDB()
    const now = Date.now()
    const next: Recipe = {
      id: r.id,
      title: r.title,
      ingredients: r.ingredients,
      steps: r.steps,
      updatedAt: now,
      synced: r.synced ?? false
    }
    await (await db).put('recipes', next)
    await loadAll()
  }

  async function addRecipe(title: string, ingredients: string, steps: string) {
    const id = crypto.randomUUID()
    await upsertRecipe({ id, title, ingredients, steps, synced: false })
  }

  async function removeRecipe(id: string) {
    const db = await getDB()
    await db.delete('recipes', id)
    await loadAll()
  }

  // Push unsynced local recipes to Firestore
  async function pushToCloud(dbCloud: Firestore) {
    const db = await getDB()
    const tx = db.transaction('recipes')
    const store = tx.objectStore('recipes')
    const idx = store.index('synced')
    const unsynced = (await idx.getAll(IDBKeyRange.only(false))) as Recipe[]

    if (!unsynced.length) return

    const batch = writeBatch(dbCloud)
    for (const r of unsynced) {
      batch.set(doc(collection(dbCloud, 'recipes'), r.id), {
        title: r.title,
        ingredients: r.ingredients,
        steps: r.steps,
        updatedAt: r.updatedAt,
        updatedAtServer: serverTimestamp()
      })
    }
    await batch.commit()

    // Mark as synced locally
    for (const r of unsynced) {
      await upsertRecipe({ ...r, synced: true })
    }
  }

  // Pull latest from Firestore and merge into local
  async function pullFromCloud(dbCloud: Firestore) {
    const q = query(collection(dbCloud, 'recipes'), orderBy('updatedAt', 'desc'))
    const snap = await getDocs(q)
    const updates: Recipe[] = []
    const now = Date.now()

    snap.forEach(d => {
      const data = d.data() as any
      updates.push({
        id: d.id,
        title: data.title ?? '',
        ingredients: data.ingredients ?? '',
        steps: data.steps ?? '',
        updatedAt: typeof data.updatedAt === 'number' ? data.updatedAt : now,
        synced: true
      })
    })

    const db = await getDB()
    const tx = db.transaction('recipes', 'readwrite')
    const store = tx.objectStore('recipes')
    for (const r of updates) {
      await store.put(r)
    }
    await tx.done
    await loadAll()
  }

  // High-level sync: push first, then pull to get any remote edits
  async function sync(dbCloud: Firestore) {
    await pushToCloud(dbCloud)
    await pullFromCloud(dbCloud)
  }

  onMounted(loadAll)

  return {
    recipes,
    pendingCount,
    online,
    addRecipe,
    removeRecipe,
    upsertRecipe,
    loadAll,
    pushToCloud,
    pullFromCloud,
    sync
  }
}

This gives us:

  • A local authoritative store in IndexedDB
  • Push/pull sync with Firestore
  • A pending indicator to signal unsynced changes

Because Firestore has its own offline queue, you could rely on it alone; we keep explicit IndexedDB to demonstrate robust offline-first patterns and future-proof for additional features (like conflict resolution or multi-user).

7) Install prompt + SW update UX

Create composables/usePWAInstall.ts:

// composables/usePWAInstall.ts
import { ref, onMounted, onBeforeUnmount } from 'vue'

export function usePWAInstall() {
  const deferred = ref<BeforeInstallPromptEvent | null>(null)
  const canInstall = ref(false)

  function onBeforeInstallPrompt(e: Event) {
    e.preventDefault()
    deferred.value = e as BeforeInstallPromptEvent
    canInstall.value = true
  }

  async function promptInstall() {
    if (!deferred.value) return
    const res = await deferred.value.prompt()
    // userChoice outcome can be 'accepted' or 'dismissed'
    deferred.value = null
    canInstall.value = false
    return res.outcome
  }

  onMounted(() => {
    window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt)
  })
  onBeforeUnmount(() => {
    window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt)
  })

  return { canInstall, promptInstall }
}

declare global {
  interface BeforeInstallPromptEvent extends Event {
    prompt: () => Promise<{ outcome: 'accepted' | 'dismissed' }>
  }
}

Add update prompt with virtual:pwa-register/vue:

<!-- components/UpdatePrompt.client.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRegisterSW } from 'virtual:pwa-register/vue'

const needRefresh = ref(false)
const offlineReady = ref(false)

const { updateServiceWorker } = useRegisterSW({
  onRegistered() {},
  onRegisterError(error) {
    console.error('SW registration error', error)
  },
  onNeedRefresh() {
    needRefresh.value = true
  },
  onOfflineReady() {
    offlineReady.value = true
    setTimeout(() => (offlineReady.value = false), 2500)
  }
})

function reload() {
  updateServiceWorker(true)
}
</script>

<template>
  <div class="fixed bottom-3 left-1/2 -translate-x-1/2 space-y-2 z-50">
    <div v-if="offlineReady" class="bg-emerald-600 text-white px-3 py-2 rounded">Offline ready âś”</div>
    <div v-if="needRefresh" class="bg-amber-500 text-black px-3 py-2 rounded flex items-center gap-3">
      <span>New version available</span>
      <button class="underline" @click="reload">Update</button>
    </div>
  </div>
</template>

8) App shell with install + sync UI

Create app.vue:

<script setup lang="ts">
import UpdatePrompt from '~/components/UpdatePrompt.client.vue'
import { usePWAInstall } from '~/composables/usePWAInstall'
import { useRecipes } from '~/composables/useRecipes'
import { inject } from 'vue'

const { canInstall, promptInstall } = usePWAInstall()
const { pendingCount, online } = useRecipes()
const firestore = inject('firestore')
</script>

<template>
  <div>
    <header class="border-b bg-white/80 sticky top-0 backdrop-blur">
      <div class="max-w-3xl mx-auto px-4 h-14 flex items-center justify-between">
        <NuxtLink to="/" class="font-semibold">RecipeBox</NuxtLink>
        <div class="flex items-center gap-3 text-sm">
          <span v-if="!online" class="text-amber-600">Offline</span>
          <span v-else class="text-emerald-600">Online</span>
          <span v-if="pendingCount" class="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded">
            {{ pendingCount }} pending
          </span>
          <button
            v-if="canInstall"
            class="px-3 py-1 rounded bg-emerald-600 text-white"
            @click="promptInstall"
          >
            Install
          </button>
        </div>
      </div>
    </header>

    <NuxtPage />

    <UpdatePrompt />
  </div>
</template>

<style>
html, body, #__nuxt { height: 100%; background: #f7faf9; }
</style>

9) Build the Recipe page

Create pages/index.vue:

<script setup lang="ts">
import { ref, inject } from 'vue'
import type { Firestore } from 'firebase/firestore'
import { useRecipes } from '~/composables/useRecipes'

useHead({ title: 'RecipeBox' })

const title = ref('')
const ingredients = ref('')
const steps = ref('')

const { recipes, addRecipe, removeRecipe, upsertRecipe, online, sync } = useRecipes()
const firestore = inject('firestore') as Firestore

async function save() {
  if (!title.value.trim()) return
  await addRecipe(title.value.trim(), ingredients.value.trim(), steps.value.trim())
  title.value = ''
  ingredients.value = ''
  steps.value = ''
}

async function toggleCloudSync() {
  if (!firestore) return
  await sync(firestore)
}
</script>

<template>
  <main class="max-w-3xl mx-auto p-4">
    <section class="bg-white rounded-lg shadow-sm p-4 mb-6">
      <h2 class="text-lg font-semibold mb-3">Add Recipe</h2>
      <div class="grid gap-3">
        <input v-model="title" placeholder="Title e.g. Pancakes" class="border rounded p-2" />
        <textarea v-model="ingredients" rows="3" placeholder="Ingredients (one per line)" class="border rounded p-2" />
        <textarea v-model="steps" rows="4" placeholder="Steps" class="border rounded p-2" />
        <div class="flex items-center gap-3">
          <button @click="save" class="bg-emerald-600 text-white px-4 py-2 rounded">Save</button>
          <button
            @click="toggleCloudSync"
            class="px-4 py-2 rounded border"
            :disabled="!online"
            :class="online ? 'border-emerald-600 text-emerald-700' : 'opacity-50 cursor-not-allowed'"
            title="Push/pull with Firestore"
          >
            Sync {{ online ? '' : '(offline)' }}
          </button>
        </div>
      </div>
    </section>

    <section class="bg-white rounded-lg shadow-sm p-4">
      <h2 class="text-lg font-semibold mb-3">Recipes</h2>
      <div v-if="!recipes.length" class="text-gray-500">No recipes yet. Add your first above.</div>

      <ul v-else class="grid gap-3">
        <li
          v-for="r in recipes"
          :key="r.id"
          class="border rounded p-3"
        >
          <div class="flex items-center justify-between">
            <h3 class="font-semibold">{{ r.title }}</h3>
            <div class="flex items-center gap-2">
              <span
                class="text-xs px-2 py-0.5 rounded"
                :class="r.synced ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-800'"
              >
                {{ r.synced ? 'Synced' : 'Pending' }}
              </span>
              <button class="text-red-600 text-sm" @click="removeRecipe(r.id)">Delete</button>
            </div>
          </div>
          <div class="mt-2">
            <h4 class="text-sm font-medium">Ingredients</h4>
            <pre class="whitespace-pre-wrap text-sm text-gray-700">{{ r.ingredients }}</pre>
          </div>
          <div class="mt-2">
            <h4 class="text-sm font-medium">Steps</h4>
            <pre class="whitespace-pre-wrap text-sm text-gray-700">{{ r.steps }}</pre>
          </div>
        </li>
      </ul>
    </section>
  </main>
</template>

This page:

  • Adds recipes locally (works offline)
  • Shows a Sync button to push local changes and pull remote ones when online
  • Displays a badge when items are pending sync

10) Test the PWA and offline behavior

  • Start dev with SW enabled:
    npm run dev
    
    In Chrome DevTools Application tab:
    • Manifest is present
    • Service Worker is active (thanks to devOptions.enabled)
  • Click the Install button in the header to install the app
  • Add some recipes
  • Go offline (DevTools > Network > Offline)
  • Add more recipes — you’ll see “Pending” badges
  • Go back online and press Sync — badges will flip to Synced. You can also reload; Firestore persistence and our manual sync handle continuity.

To test a production build with a full service worker:

npm run build
npm run preview

Open the preview URL, install, and try offline navigation. Uncached routes will fall back to /offline, while the app shell and visited pages work offline.

11) Optional: Deploy

Any static hosting + SSR target supported by Nuxt’s Nitro will work. If you’re serving a static preview (npm run preview), an Nginx, Docker, or Firebase Hosting setup works great. Ensure:

  • The service worker and manifest are served with correct MIME types
  • All assets in .output/public are accessible

Wrap-up

You’ve built an installable, offline-first Recipe Box with:

  • Nuxt 4 + TypeScript
  • @vite-pwa/nuxt with SW in dev and solid runtime caching
  • IndexedDB for local source-of-truth
  • Firestore for cloud sync when online
  • A user-friendly install and SW update experience

This pattern is a strong base for real-world offline-first apps: add auth, conflict resolution, images, and background tasks as you grow. Enjoy shipping fast PWAs with Nuxt 4!