I like to build tools that don’t just work — they get out of your way. My team has been in the trenches of Next.js long enough to know exactly what hurts. One of the latest things I put together is next-pwa-pack — a drop-in package that wires up full PWA support in your Next.js app without you tearing your hair out. get out of your way next-pwa-pack The Backstory (aka: Why I Wrote This Thing) Every time a client mentioned “PWA support,” I braced myself. I tried existing libraries. Too much magic. Tons of config. Or just completely incompatible with App Router — which, by the way, we’ve fully adopted. I wanted: App Router Server-side cache revalidation. App Router integration. Easy sync between tabs. Clean API for managing the cache from the backend. Server-side cache revalidation. App Router integration. Easy sync between tabs. Clean API for managing the cache from the backend. Instead, I ended up writing service workers by hand. Tuning cache TTLs. Dealing with update logic. Managing stale clients. Manually wiping caches every time we shipped. And don’t even get me started on users not seeing updates until they hard-refreshed. Enough was enough. I needed something dead simple, predictable, and battle-tested. So I built it. Building next-pwa-pack: What Went Into It next-pwa-pack Step one was writing a minimal service worker: Caches HTML with TTL. Handles static assets. Works offline, like a real PWA should. Caches HTML with TTL. Handles static assets. Works offline, like a real PWA should. Then I added a messaging system so the client could talk to the service worker — for example, to bust a cache or disable caching entirely. Next, I wrote a few scripts: Auto-copy sw.js, manifest.json, and offline.html into your project. Inject a server action called revalidatePWA that you can use anywhere (API routes, server actions, server components — take your pick). Auto-copy sw.js, manifest.json, and offline.html into your project. sw.js manifest.json offline.html Inject a server action called revalidatePWA that you can use anywhere (API routes, server actions, server components — take your pick). revalidatePWA For full App Router and SSR/Edge support, I wrapped everything in a higher-order function: withPWA. One import, one call — done. withPWA I also built in tab synchronization. Because users will open your app in 3 tabs and expect them to magically update in sync. I solved that via localStorage + storage events. will localStorage storage The result? A package that just works. No config black magic. No rewriting core parts of your app. What You Get with next-pwa-pack next-pwa-pack Once installed, you get: Service worker registration out of the box — no boilerplate. Offline fallback with a customizable offline.html. Auto-copied files you can tweak (manifest, SW, etc.). Cache control API — clear, update, disable, all programmatically. Sync between tabs — no stale content in multi-tab setups. Dev panel for real-time PWA state during local development. Server-side revalidation support via server actions, API routes, or external webhook integrations. Service worker registration out of the box — no boilerplate. Service worker registration Offline fallback with a customizable offline.html. Offline fallback offline.html Auto-copied files you can tweak (manifest, SW, etc.). Auto-copied files Cache control API — clear, update, disable, all programmatically. Cache control API Sync between tabs — no stale content in multi-tab setups. Sync between tabs Dev panel for real-time PWA state during local development. Dev panel Server-side revalidation support via server actions, API routes, or external webhook integrations. Server-side revalidation You can grab the package here: 👉 https://github.com/dev-family/next-pwa-pack https://github.com/dev-family/next-pwa-pack What Happens When You Install It The install script auto-copies PWA boilerplate into /public: /public sw.js – your service worker with cache logic. offline.html – fallback page for offline mode. manifest.json – tweak it to fit your app. sw.js – your service worker with cache logic. sw.js offline.html – fallback page for offline mode. offline.html manifest.json – tweak it to fit your app. manifest.json ⚠️ Existing files won’t be overwritten — it respects your setup. If you want to trigger the copy manually: node node_modules/next-pwa-pack/scripts/copy-pwa-files.mjs # or npx next-pwa-pack/scripts/copy-pwa-files.mjs node node_modules/next-pwa-pack/scripts/copy-pwa-files.mjs # or npx next-pwa-pack/scripts/copy-pwa-files.mjs The server action revalidatePWA is also added to your app/actions.ts or src/app/actions.ts file depending on your folder structure: revalidatePWA app/actions.ts src/app/actions.ts "use server"; export async function revalidatePWA(urls: string[]) { const baseUrl = process.env.NEXT_PUBLIC_HOST || "http://localhost:3000"; const res = await fetch(`${baseUrl}/api/pwa/revalidate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ urls, secret: process.env.REVALIDATION_SECRET, }), }); return res.json(); } "use server"; export async function revalidatePWA(urls: string[]) { const baseUrl = process.env.NEXT_PUBLIC_HOST || "http://localhost:3000"; const res = await fetch(`${baseUrl}/api/pwa/revalidate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ urls, secret: process.env.REVALIDATION_SECRET, }), }); return res.json(); } If that file doesn’t show up, you can run: node node_modules/next-pwa-pack/scripts/copy-pwa-server-actions.mjs node node_modules/next-pwa-pack/scripts/copy-pwa-server-actions.mjs Configuring Your manifest.json manifest.json After install, don’t forget to customize /public/manifest.json: /public/manifest.json { "name": "My App", "short_name": "App", "description": "My amazing PWA app", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#000000", "icons": [ { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } ] } { "name": "My App", "short_name": "App", "description": "My amazing PWA app", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#000000", "icons": [ { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } ] } Drop your icons into public/icons/, or tweak the paths above. Nothing fancy. public/icons/ Quick Start: Wire It Up Wrap your layout in the PWAProvider, and the magic kicks in: PWAProvider import { PWAProvider } from "next-pwa-pack"; export default function layout({ children }) { return <PWAProvider>{children}</PWAProvider>; } import { PWAProvider } from "next-pwa-pack"; export default function layout({ children }) { return <PWAProvider>{children}</PWAProvider>; } If you want revalidation to work from the server side, you’ll also need to update your middleware: // /middleware.ts import { withPWA } from "next-pwa-pack/hoc/withPWA"; function originalMiddleware(request) { // your logic here return response; } export default withPWA(originalMiddleware, { revalidationSecret: process.env.REVALIDATION_SECRET!, sseEndpoint: "/api/pwa/cache-events", webhookPath: "/api/pwa/revalidate", }); export const config = { matcher: ["/", "/(ru|en)/:path*", "/api/pwa/:path*"], }; // /middleware.ts import { withPWA } from "next-pwa-pack/hoc/withPWA"; function originalMiddleware(request) { // your logic here return response; } export default withPWA(originalMiddleware, { revalidationSecret: process.env.REVALIDATION_SECRET!, sseEndpoint: "/api/pwa/cache-events", webhookPath: "/api/pwa/revalidate", }); export const config = { matcher: ["/", "/(ru|en)/:path*", "/api/pwa/:path*"], }; The HOC Options: originalMiddleware: your base middleware (e.g., for i18n or auth). revalidationSecret: secret token to lock down cache revalidation. sseEndpoint: SSE stream path (change if it conflicts). webhookPath: endpoint to hit for triggering cache refresh (used by revalidatePWA). originalMiddleware: your base middleware (e.g., for i18n or auth). originalMiddleware revalidationSecret: secret token to lock down cache revalidation. revalidationSecret sseEndpoint: SSE stream path (change if it conflicts). sseEndpoint webhookPath: endpoint to hit for triggering cache refresh (used by revalidatePWA). webhookPath revalidatePWA Inside the PWAProvider PWAProvider The PWAProvider bundles a bunch of stuff under the hood — and you can cherry-pick components too: PWAProvider RegisterSW RegisterSW Automatically registers the service worker (/sw.js). Handles errors gracefully. You can override the path if needed: /sw.js <PWAProvider swPath="/custom/sw.js">{children}</PWAProvider> <PWAProvider swPath="/custom/sw.js">{children}</PWAProvider> CacheCurrentPage CacheCurrentPage Intercepts navigation (including SPA-style transitions), caches the current page’s HTML. SWRevalidateListener SWRevalidateListener Watches for localStorage events and triggers cache refresh across tabs. SSERevalidateListener SSERevalidateListener Listens to server-sent events from the sseEndpoint. When your backend says “revalidate these URLs,” this listener makes sure clients do it. sseEndpoint DevPWAStatus DevPWAStatus Dev-only panel you can enable like this: <PWAProvider devMode>{children}</PWAProvider> <PWAProvider devMode>{children}</PWAProvider> Shows: Online/offline status Cache version Update availability One-click tools: clear cache, unregister SW, refresh, disable/enable cache Online/offline status Cache version Update availability One-click tools: clear cache, unregister SW, refresh, disable/enable cache What the Service Worker Actually Does The core sw.js handles: sw.js HTML Caching Pages cached with TTL (default: 10 min — tweakable in sw.js) Auto-revalidates when TTL expires Offline fallback if HTML is missing Pages cached with TTL (default: 10 min — tweakable in sw.js) sw.js Auto-revalidates when TTL expires Offline fallback if HTML is missing Static Assets JS, CSS, images are cached forever Only caches GET requests JS, CSS, images are cached forever forever Only caches GET requests Messaging Support Supports these actions from the client: CACHE_CURRENT_HTML REVALIDATE_URL DISABLE_CACHE / ENABLE_CACHE SKIP_WAITING CLEAR_STATIC_CACHE CACHE_CURRENT_HTML CACHE_CURRENT_HTML REVALIDATE_URL REVALIDATE_URL DISABLE_CACHE / ENABLE_CACHE DISABLE_CACHE ENABLE_CACHE SKIP_WAITING SKIP_WAITING CLEAR_STATIC_CACHE CLEAR_STATIC_CACHE Offline Mode Serves offline.html if network and cache both fail Tries to refresh when back online Serves offline.html if network and cache both fail offline.html Tries to refresh when back online Using withPWA in Middleware withPWA This is where next-pwa-pack really earns its keep. It brings cache revalidation to SSR and Edge Middleware — with SSE support and all. next-pwa-pack export default withPWA(originalMiddleware, { revalidationSecret: process.env.REVALIDATION_SECRET!, sseEndpoint: "/api/pwa/cache-events", webhookPath: "/api/pwa/revalidate", }); export default withPWA(originalMiddleware, { revalidationSecret: process.env.REVALIDATION_SECRET!, sseEndpoint: "/api/pwa/cache-events", webhookPath: "/api/pwa/revalidate", }); Params: Params: originalMiddleware: your existing middleware logic (auth, i18n, etc.) revalidationSecret: so nobody else can poke your cache sseEndpoint: override if something else is using this route webhookPath: used by the server or external systems to revalidate specific URLs originalMiddleware: your existing middleware logic (auth, i18n, etc.) originalMiddleware revalidationSecret: so nobody else can poke your cache revalidationSecret sseEndpoint: override if something else is using this route sseEndpoint webhookPath: used by the server or external systems to revalidate specific URLs webhookPath Real-World Use Cases Updating Cache After Data Changes import { updateSWCache } from "next-pwa-pack"; // After creating a blog post: const handleCreatePost = async (data) => { await createPost(data); updateSWCache(["/blog", "/dashboard"]); }; import { updateSWCache } from "next-pwa-pack"; // After creating a blog post: const handleCreatePost = async (data) => { await createPost(data); updateSWCache(["/blog", "/dashboard"]); }; Updating Cache From the Server import { revalidatePWA } from "../actions"; await createPost(data); await revalidatePWA(["/my-page"]); import { revalidatePWA } from "../actions"; await createPost(data); await revalidatePWA(["/my-page"]); Clearing Cache on Logout import { clearAllCache } from "next-pwa-pack"; const handleLogout = async () => { await logout(); await clearAllCache(); router.push("/login"); }; import { clearAllCache } from "next-pwa-pack"; const handleLogout = async () => { await logout(); await clearAllCache(); router.push("/login"); }; All Client Cache Actions import { clearAllCache, reloadServiceWorker, updatePageCache, unregisterServiceWorkerAndClearCache, updateSWCache, disablePWACache, enablePWACache, clearStaticCache, usePWAStatus, } from "next-pwa-pack"; // Examples: await clearAllCache(); await reloadServiceWorker(); await updatePageCache("/about"); await unregisterServiceWorkerAndClearCache(); await clearStaticCache(); updateSWCache(["/page1", "/page2"]); disablePWACache(); enablePWACache(); const { online, hasUpdate, swInstalled, update } = usePWAStatus(); import { clearAllCache, reloadServiceWorker, updatePageCache, unregisterServiceWorkerAndClearCache, updateSWCache, disablePWACache, enablePWACache, clearStaticCache, usePWAStatus, } from "next-pwa-pack"; // Examples: await clearAllCache(); await reloadServiceWorker(); await updatePageCache("/about"); await unregisterServiceWorkerAndClearCache(); await clearStaticCache(); updateSWCache(["/page1", "/page2"]); disablePWACache(); enablePWACache(); const { online, hasUpdate, swInstalled, update } = usePWAStatus(); API Route for External Cache Triggers If you want to trigger cache refreshes externally (e.g., from an admin panel), here’s an API route you can use: // app/api/webhook/revalidate/route.ts import { NextRequest, NextResponse } from "next/server"; import { revalidatePWA } from "@/app/actions"; import { revalidateTag } from "next/cache"; import { FetchTags } from "@/app/api/endpoints/backend"; export async function POST(request: NextRequest) { try { const { tags, secret, urls } = await request.json(); if (secret !== process.env.REVALIDATION_SECRET) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const validTags = Object.values(FetchTags); const invalidTags = tags?.filter((tag) => !validTags.includes(tag)) || []; if (invalidTags.length > 0) { return NextResponse.json( { error: `Invalid tags: ${invalidTags.join(", ")}` }, { status: 400 } ); } let successful = 0; let failed = 0; if (tags?.length) { const tagResults = await Promise.allSettled( tags.map((tag) => revalidateTag(tag)) ); successful = tagResults.filter((r) => r.status === "fulfilled").length; failed = tagResults.filter((r) => r.status === "rejected").length; } if (urls?.length) { await revalidatePWA(urls); } return NextResponse.json({ success: true, message: "Cache revalidation completed", tags, urls, successful, failed, timestamp: new Date().toISOString(), }); } catch (error) { console.error("Webhook revalidation error:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } // app/api/webhook/revalidate/route.ts import { NextRequest, NextResponse } from "next/server"; import { revalidatePWA } from "@/app/actions"; import { revalidateTag } from "next/cache"; import { FetchTags } from "@/app/api/endpoints/backend"; export async function POST(request: NextRequest) { try { const { tags, secret, urls } = await request.json(); if (secret !== process.env.REVALIDATION_SECRET) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const validTags = Object.values(FetchTags); const invalidTags = tags?.filter((tag) => !validTags.includes(tag)) || []; if (invalidTags.length > 0) { return NextResponse.json( { error: `Invalid tags: ${invalidTags.join(", ")}` }, { status: 400 } ); } let successful = 0; let failed = 0; if (tags?.length) { const tagResults = await Promise.allSettled( tags.map((tag) => revalidateTag(tag)) ); successful = tagResults.filter((r) => r.status === "fulfilled").length; failed = tagResults.filter((r) => r.status === "rejected").length; } if (urls?.length) { await revalidatePWA(urls); } return NextResponse.json({ success: true, message: "Cache revalidation completed", tags, urls, successful, failed, timestamp: new Date().toISOString(), }); } catch (error) { console.error("Webhook revalidation error:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } Hit it with: POST https://your-app.com/api/webhook/revalidate { "tags": ["faq"], "secret": "1234567890", "urls": ["/ru/question-answer"] } POST https://your-app.com/api/webhook/revalidate { "tags": ["faq"], "secret": "1234567890", "urls": ["/ru/question-answer"] } Debugging & DevTools Here’s what you can check when debugging: Go to DevTools → Application → Service Workers. Confirm the worker is registered. Check Cache Storage → html-cache-v2 to see if pages are cached. Simulate offline in Network → Offline and reload. You should see offline.html. Console logs from the service worker help too: [PWA] Service Worker registered [SW] Cached: /about [SW] Revalidated and updated cache for: /blog Go to DevTools → Application → Service Workers. DevTools → Application → Service Workers Confirm the worker is registered. Check Cache Storage → html-cache-v2 to see if pages are cached. Cache Storage → html-cache-v2 Simulate offline in Network → Offline and reload. You should see offline.html. Network → Offline offline.html Console logs from the service worker help too: [PWA] Service Worker registered [SW] Cached: /about [SW] Revalidated and updated cache for: /blog [PWA] Service Worker registered [SW] Cached: /about [SW] Revalidated and updated cache for: /blog [PWA] Service Worker registered [PWA] Service Worker registered [SW] Cached: /about [SW] Cached: /about [SW] Revalidated and updated cache for: /blog [SW] Revalidated and updated cache for: /blog Gotchas & Notes A few things you should know before you ship: Security PWA requires HTTPS in production. Only GET requests are cached. Don’t cache sensitive data. PWA requires HTTPS in production. Only GET requests are cached. Don’t cache sensitive data. Performance The package doesn’t touch your app’s performance baseline. It improves repeated loads significantly. The package doesn’t touch your app’s performance baseline. It improves repeated loads significantly. Config TTL is set in sw.js (default: 10 minutes). You can exclude URLs from caching via CACHE_EXCLUDE. manifest.json needs to be tailored to your app. revalidatePWA action is editable — customize it as needed. withPWA and PWAProvider both accept options: TTL is set in sw.js (default: 10 minutes). sw.js You can exclude URLs from caching via CACHE_EXCLUDE. CACHE_EXCLUDE manifest.json needs to be tailored to your app. manifest.json revalidatePWA action is editable — customize it as needed. revalidatePWA withPWA and PWAProvider both accept options: withPWA PWAProvider export default function PWAProvider({ children, swPath, devMode = false, serverRevalidation = { enabled: true, sseEndpoint: "/api/pwa/cache-events" }, }: PWAProviderProps) { export default function PWAProvider({ children, swPath, devMode = false, serverRevalidation = { enabled: true, sseEndpoint: "/api/pwa/cache-events" }, }: PWAProviderProps) { What’s Next next-pwa-pack is written for Next.js 15. It should work on Next.js 13 App Router as well — just not tested extensively. next-pwa-pack Next.js 15 Next.js 13 App Router Planned features: TTL config via options (no editing sw.js) Push notifications Pattern-based cache control Performance metrics for cache efficiency TTL config via options (no editing sw.js) sw.js Push notifications Pattern-based cache control Performance metrics for cache efficiency That’s it. If you’re tired of wrangling service workers manually, give next-pwa-pack a shot. You’ll go from zero to full PWA support in one coffee break. next-pwa-pack Questions, bugs, or feedback? Open an issue or hit us up. 👉 github.com/dev-family/next-pwa-pack github.com/dev-family/next-pwa-pack