Manga/Comic Reader with Smart Caching in Nuxt 4 + TypeScript + PWA

We’ll build a slick offline-first Manga/Comic reader using Nuxt 4, TypeScript, and @vite-pwa/nuxt with Workbox-powered smart caching. You’ll get:
- Installable PWA with auto-updates
- Smart runtime caching for chapter JSON and images
- Offline fallback and background sync for reading progress
- Gentle update prompt using virtual:pwa-register/vue
What’s new: Since the latest @vite-pwa/nuxt releases, Workbox v7 features like navigation preload are easy to enable, and the Vue composable from virtual:pwa-register/vue makes update prompts trivial. We’ll use both.
Tags: Nuxt 4, TypeScript, PWA, @vite-pwa/nuxt, Workbox
Time to read: 12 min
Prerequisites
- Node.js 18+
- Basic Nuxt 4 + Vue 3 + TypeScript experience
- Familiarity with service workers and caching is helpful
1) Create the Nuxt 4 app
npx nuxi@latest init manga-pwa
cd manga-pwa
npm install
Run it:
npm run dev
Open http://localhost:3000.
2) Install PWA module
npm install @vite-pwa/nuxt
We’ll also use the virtual PWA register helper for the update prompt (bundled with the plugin).
3) App icons and offline page
Create icons in public/ (you can use any image tool or generator):
- public/pwa-192x192.png
- public/pwa-512x512.png
- public/maskable-icon-512x512.png (optional but recommended)
Create a simple offline page in public/offline.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Offline • Manga Reader</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; display: grid; place-items: center; height: 100vh; background: #0b1220; color: #e6edf3; }
.box { text-align: center; padding: 2rem; max-width: 32rem; }
a { color: #71c7ff; }
</style>
</head>
<body>
<div class="box">
<h1>Offline</h1>
<p>You’re offline. Previously visited chapters and images still work.</p>
<p><a href="/">Back to Home</a></p>
</div>
</body>
</html>
4) Configure Nuxt + PWA (with smart caching)
Edit nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@vite-pwa/nuxt'],
typescript: {
strict: true,
},
app: {
head: {
title: 'Manga Reader',
meta: [
{ name: 'theme-color', content: '#111827' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'Offline-first Manga/Comic Reader built with Nuxt 4 + TypeScript + PWA' },
],
},
},
pwa: {
// auto-update the service worker when new build is available
registerType: 'autoUpdate',
// use Workbox generateSW under the hood
strategies: 'generateSW',
injectRegister: 'auto',
devOptions: {
enabled: true, // enable PWA in dev for easier testing
navigateFallbackAllowlist: [/^\/$/], // keep dev nav fallback sane
},
manifest: {
name: 'Manga Reader',
short_name: 'Manga',
description: 'Offline-first Manga/Comic Reader',
theme_color: '#111827',
background_color: '#0b1220',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/pwa-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: '/maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
],
},
workbox: {
// New in Workbox v7: navigation preload is reliable and speeds up first nav
navigationPreload: true,
// serve this when a navigation request fails offline
navigateFallback: '/offline.html',
runtimeCaching: [
// Cache chapter lists/details from our Nuxt server API
{
urlPattern: /^\/api\/chapters(\/.*)?$/,
handler: 'StaleWhileRevalidate',
method: 'GET',
options: {
cacheName: 'manga-api',
cacheableResponse: { statuses: [0, 200] },
expiration: {
maxEntries: 100,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
// Cache external placeholder images we use as page content
{
urlPattern: /^https:\/\/(picsum\.photos|placehold\.co)\/.*/i,
handler: 'CacheFirst',
method: 'GET',
options: {
cacheName: 'manga-images',
cacheableResponse: { statuses: [0, 200] },
expiration: {
maxEntries: 300,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
},
},
// Queue reading progress updates when offline, sync later
{
urlPattern: /^\/api\/progress$/,
handler: 'NetworkOnly',
method: 'POST',
options: {
backgroundSync: {
name: 'reading-progress-queue',
options: {
maxRetentionTime: 24 * 60, // minutes
},
},
},
},
],
},
// Optional: PWA asset generator (newer releases)
// Uncomment to auto-generate icons from a single source image.
// pwaAssets: {
// disabled: false,
// image: 'public/pwa-512x512.png',
// },
},
});
Notes
- navigationPreload is part of Workbox v7 and gives faster first navigations.
- We’re caching our Nuxt API responses and external images. For real apps, proxy images via server routes to control headers and CORS.
- Background sync queues POST /api/progress when offline, then replays when back online.
5) Types and sample API
Add shared types.
// types/manga.ts
export interface ChapterSummary {
id: string;
title: string;
pages: number;
}
export interface ChapterDetail {
id: string;
title: string;
pages: string[]; // absolute URLs to images
}
Create a tiny data source to keep server routes in sync.
// server/utils/chapterData.ts
import type { ChapterDetail, ChapterSummary } from '~/types/manga';
const TOTAL_CHAPTERS = 3;
const PAGES_PER_CHAPTER = 12;
// deterministic image generator for demo
const pageUrl = (chapterId: string, page: number) =>
// change to your CDN or proxy if needed
`https://picsum.photos/seed/ch${chapterId}-p${page}/800/1200`;
export const getChapters = (): ChapterSummary[] => {
return Array.from({ length: TOTAL_CHAPTERS }, (_, i) => ({
id: `${i + 1}`,
title: `Chapter ${i + 1}`,
pages: PAGES_PER_CHAPTER,
}));
};
export const getChapter = (id: string): ChapterDetail | null => {
const idx = Number(id);
if (!Number.isInteger(idx) || idx < 1 || idx > TOTAL_CHAPTERS) return null;
const pages = Array.from({ length: PAGES_PER_CHAPTER }, (_, i) => pageUrl(id, i + 1));
return {
id,
title: `Chapter ${id}`,
pages,
};
};
Server API routes:
// server/api/chapters.get.ts
import { defineEventHandler } from 'h3';
import { getChapters } from '~/server/utils/chapterData';
export default defineEventHandler(() => {
return getChapters();
});
// server/api/chapters/[id].get.ts
import { defineEventHandler, getRouterParam } from 'h3';
import { getChapter } from '~/server/utils/chapterData';
export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id');
if (!id) return { error: 'Missing id' };
const chapter = getChapter(id);
if (!chapter) {
event.node.res.statusCode = 404;
return { error: 'Not found' };
}
return chapter;
});
// server/api/progress.post.ts
import { defineEventHandler, readBody } from 'h3';
interface ProgressBody {
id: string; // chapter id
page: number; // current page (1-based)
}
export default defineEventHandler(async (event) => {
const body = (await readBody(event)) as ProgressBody;
// In a real app, persist to DB. Here we just echo it back.
if (!body || !body.id || !Number.isFinite(body.page)) {
event.node.res.statusCode = 400;
return { ok: false, message: 'Invalid payload' };
}
return { ok: true, saved: body };
});
6) UI: Chapter list and reader
Index page:
<!-- pages/index.vue -->
<script setup lang="ts">
import type { ChapterSummary } from '~/types/manga';
const { data: chapters, status } = await useFetch<ChapterSummary[]>('/api/chapters');
useHead({
title: 'Manga Reader',
});
</script>
<template>
<main class="mx-auto max-w-3xl p-6 text-slate-100">
<h1 class="text-2xl font-semibold mb-4">Manga Reader</h1>
<p class="mb-6 text-slate-400">Installable, offline-first, with smart caching.</p>
<div v-if="status === 'pending'">Loading…</div>
<div v-else-if="!chapters?.length">No chapters yet.</div>
<ul v-else class="space-y-3">
<li v-for="c in chapters" :key="c.id" class="rounded bg-slate-800/60 border border-slate-700">
<NuxtLink :to="`/chapter/${c.id}`" class="block p-4 hover:bg-slate-800">
<div class="font-medium">{{ c.title }}</div>
<div class="text-sm text-slate-400">{{ c.pages }} pages</div>
</NuxtLink>
</li>
</ul>
<PwaReloadPrompt />
</main>
</template>
<style>
html, body { background: #0b1220; }
</style>
Reader page with lazy-loaded images and simple read-ahead prefetch:
<!-- pages/chapter/[id].vue -->
<script setup lang="ts">
import type { ChapterDetail } from '~/types/manga';
const route = useRoute();
const id = route.params.id as string;
const { data: chapter, status, error } = await useFetch<ChapterDetail>(`/api/chapters/${id}`);
const currentPage = useState<number>(() => 1);
// Prefetch the next couple images using the browser cache & our Workbox CacheFirst handler.
const prefetchNext = async (idx: number) => {
const pages = chapter.value?.pages ?? [];
const nextToPrefetch = [idx + 1, idx + 2].filter(i => i < pages.length);
await Promise.all(
nextToPrefetch.map(async (i) => {
try {
const img = new Image();
img.decoding = 'async';
img.loading = 'eager';
img.src = pages[i];
// decode helps ensure it's actually fetched/decoded
await img.decode().catch(() => void 0);
} catch {
// ignore
}
})
);
};
// send progress, will background-sync if offline
const saveProgress = async (page: number) => {
currentPage.value = page;
try {
await $fetch('/api/progress', {
method: 'POST',
body: { id, page },
});
} catch {
// will be queued by Background Sync
}
};
// watch page changes
watch(currentPage, (p) => {
prefetchNext(p);
});
// when chapter loads, prefetch page 2 and 3
watchEffect(() => {
if (chapter.value) prefetchNext(0);
});
useHead({
title: chapter.value?.title ? `${chapter.value.title} • Manga Reader` : 'Manga Reader',
});
</script>
<template>
<main class="mx-auto max-w-3xl p-2 sm:p-4 text-slate-100">
<NuxtLink to="/" class="text-sky-300">← Back</NuxtLink>
<div v-if="status === 'pending'" class="mt-6">Loading chapter…</div>
<div v-else-if="error || !chapter">Failed to load chapter.</div>
<div v-else>
<h1 class="text-xl font-semibold my-4">{{ chapter.title }}</h1>
<div class="space-y-4">
<figure v-for="(src, idx) in chapter.pages" :key="src" class="bg-slate-900/40 rounded overflow-hidden border border-slate-800">
<img
:src="src"
:alt="`${chapter.title} - Page ${idx + 1}`"
loading="lazy"
class="w-full h-auto block"
@load="() => { if (currentPage < idx + 1) currentPage = idx + 1; saveProgress(idx + 1); }"
/>
<figcaption class="px-3 py-2 text-xs text-slate-400">Page {{ idx + 1 }}</figcaption>
</figure>
</div>
<div class="py-6 text-sm text-slate-400">
Viewing page {{ currentPage }} / {{ chapter.pages.length }}
</div>
</div>
<PwaReloadPrompt />
</main>
</template>
<style scoped>
html, body { background: #0b1220; }
</style>
Minimal update prompt component:
<!-- components/PwaReloadPrompt.client.vue -->
<script setup lang="ts">
import { useRegisterSW } from 'virtual:pwa-register/vue';
const { needRefresh, updateServiceWorker } = useRegisterSW({
immediate: true,
});
</script>
<template>
<div v-if="needRefresh" class="fixed inset-x-0 bottom-0 mx-auto max-w-md mb-4 rounded bg-slate-800 text-slate-100 border border-slate-700 p-3 shadow-lg">
<div class="flex items-center justify-between gap-3">
<span>A new version is available.</span>
<button class="bg-sky-500 hover:bg-sky-600 text-white text-sm font-medium px-3 py-1 rounded"
@click="updateServiceWorker()">
Update
</button>
</div>
</div>
</template>
7) Smart caching: how it works
- Chapter JSON (GET /api/chapters and /api/chapters/:id)
- Stale-While-Revalidate for fast loads and silent background refresh.
- 1 day expiration, capped entries.
- Page images (picsum/photos or placehold.co)
- CacheFirst for best offline/low-latency reading after first view.
- 30 days expiration, plenty of entries.
- Reading progress (POST /api/progress)
- NetworkOnly with Background Sync queue reading-progress-queue.
- If offline, the request is queued and replayed later.
- Navigation Preload
- Enabled to speed up initial navigations while the SW is booting.
- Offline fallback
- If a navigation fails offline and the route isn’t cached, Workbox serves public/offline.html.
- Read-ahead prefetch
- The reader page proactively loads the next couple of images (prefetchNext) so they’re in cache by the time you reach them.
8) Run, build, and test
Dev:
npm run dev
Build + preview (to test the SW in a production-like setup):
npm run build
npm run preview
Test PWA:
- Open Chrome DevTools > Application:
- Check Manifest and Service Workers are present.
- “Install” the app (Add to Home Screen on mobile).
- Test offline:
- In DevTools > Network, set Offline.
- Navigate through previously visited chapters/pages. Images and JSON should still serve from cache.
- Test background sync:
- Go offline, open a chapter and scroll a few pages.
- Go online, then check Network tab for POST /api/progress replay.
9) Optional: auto-generate PWA assets (newer @vite-pwa/nuxt)
Newer versions of @vite-pwa/nuxt can generate icons and manifest entries from a single source image. Uncomment the pwaAssets block in nuxt.config.ts and point image to your base icon file. This reduces manual asset handling and keeps manifest in sync.
10) Hardening and extensions
- Proxy images via server routes for control over headers and cache keys.
- Add IndexedDB for bookmarks, download-to-read-later, or multi-source mirror support.
- Use route rules to prerender chapter index for even faster loads.
- Add Next/Prev buttons and keyboard navigation for accessibility.
Conclusion
You’ve built a Nuxt 4 + TypeScript Manga/Comic reader that’s installable, fast, and resilient offline. With Workbox v7 features via @vite-pwa/nuxt, navigation preload, background sync, and smart runtime caching make the reading experience buttery smooth—online or offline.
Happy building and happy reading!





