I usually build things that I'd actually want to use myself — and I love to share them with everyone who might need it. One of those tools is the shiny new package: next-pwa-pack
. It lets you bolt on solid PWA support into your Next.js app in no time.
Why I Built This Thing
Sometimes clients ask us to "just add PWA support" — which is usually code for “lose two days of your life fiddling with service workers and wondering why stuff randomly breaks.” I tried existing packages. Most were bloated, over-engineered, or just didn’t play nice with the App Router in Next.js.
Also, there was always something missing — like support for server actions to control cache, integration with SSR, or even just the ability to plug it cleanly into App Router without needing a spirit guide.
So I’d end up hand-rolling service workers, configuring cache logic from scratch, debugging offline mode across tabs… and every code update meant manually nuking the cache. Plus, no update notifications for users. Fun.
What we needed was a dead-simple, batteries-included PWA solution. No deep dives into service worker specs. No yak shaving.
Building the Package
Step one: I listed every annoying thing I had to do each time someone asked for a PWA. The mission: make it so a developer could get from zero to working PWA with minimal ceremony.
Started with a basic service worker that:
- Caches HTML pages with TTL
- Caches static assets
- Handles offline mode
Then I added a messaging system between the client and the service worker so we could control the cache programmatically. Wrote a couple scripts to auto-copy the required files (sw.js
, manifest.json
, offline.html
) into your project on install.
Also auto-injected a server action called revalidatePWA
so you can revalidate cached pages from the server — via server actions, API routes, or server components.
For SSR/Edge Middleware and App Router integration, I built a HOC called withPWA
. Now you can plug in server-driven revalidation and cache updates even in gnarly routing setups.
Bonus headache: syncing cache across tabs in SPA-mode. That’s in too — via localStorage
+ storage
events.
In the end, we got a package that just works out of the box. No black magic. No footguns.
Why Use next-pwa-pack
?
Installing this package gives you:
- Auto service worker registration — no need to DIY that boilerplate
- Project-specific files auto-copied — tweak them however you want
- Cache control utilities — update, nuke, or disable cache easily
- Tab sync — keeps caches aligned across browser tabs
- Offline mode — yes, your app works without internet
- Dev tools — built-in debug panel for dev-mode
- Server-side revalidation — works with server actions, API routes, and external systems
Grab it here: https://github.com/dev-family/next-pwa-pack
What Happens on Install
The following files are auto-copied into your public
folder:
-
sw.js
– service worker with all the logic you need -
offline.html
– fallback for when the user’s offline -
manifest.json
– your PWA config file
⚠️ Already got those filenames? We won’t overwrite them. You can copy manually using:
node node_modules/next-pwa-pack/scripts/copy-pwa-files.mjs
# or
npx next-pwa-pack/scripts/copy-pwa-files.mjs
Also, we auto-create (or update) a server action:
// app/actions.ts OR 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();
}
Didn’t get the file? Run:
node node_modules/next-pwa-pack/scripts/copy-pwa-server-actions.mjs
Configuring manifest.json
After install, tweak public/manifest.json
for your project:
{
"name": "My App",
"short_name": "My App",
"description": "Description of my 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"
}
]
}
Put your icons into public/icons/
or update paths in the manifest.
Quick Start
Wrap your app in the PWAProvider
. That’s it. It wires up the rest.
import { PWAProvider } from "next-pwa-pack";
export default function layout({ children }) {
return <PWAProvider>{children}</PWAProvider>;
}
For cache revalidation on the server, wrap your middleware in the HOC:
// /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*"],
};
HOC Arguments:
-
originalMiddleware
– your middleware (e.g., auth, i18n) -
revalidationSecret
– keeps strangers out of your revalidation route -
sseEndpoint
– server-sent events endpoint (change it if needed) -
webhookPath
– POST endpoint to trigger cache revalidation manually
Now you can call revalidatePWA
from anywhere — server actions, server components, API routes. The rest is handled for you.
Need a PWA app built? Hit us up.
What’s Inside PWAProvider
This is where the magic happens. Here’s what it wires up:
RegisterSW
Auto-registers your service worker. Checks support, registers /sw.js
, logs errors if something explodes.
CacheCurrentPage
Intercepts navigation (SPA included) and sends the HTML to the service worker for caching. Supports offline mode and faster reloads.
SWRevalidateListener
Listens for localStorage
events and syncs cache across tabs. When one tab updates, others follow.
SSERevalidateListener
Listens for server-sent events at /api/pwa/cache-events
. When the server says “revalidate,” the client updates the cache. Crucial for SSR + server action integration.
DevPWAStatus
Built-in dev panel. Enable with devMode
:
<PWAProvider devMode>{children}</PWAProvider>
Gives you:
- Online/offline status
- Update notifications
-
Buttons to:
- Clear cache
- Reload SW
- Update page cache
- Unregister SW
- Toggle caching
What the Service Worker Does
TL;DR: It’s a background script that manages cache, network, and updates.
HTML Caching
- TTL defaults to 10 minutes (change in
/sw.js
) - Auto-refreshes cache when TTL expires
- Offline? Serves cached version
Want to use a custom SW path?
<PWAProvider swPath="/some-path/sw.js">{children}</PWAProvider>
Static Asset Caching
- Caches CSS, JS, images forever
- Improves load speed on repeat visits
- Only works with GET requests (because security)
Message Handling
SW listens for 6 types of messages:
-
CACHE_CURRENT_HTML
– cache current page -
REVALIDATE_URL
– force refresh a specific URL -
DISABLE_CACHE
/ENABLE_CACHE
– toggle caching -
SKIP_WAITING
– activate new SW version -
CLEAR_STATIC_CACHE
– drop static/API cache (useful after SSE updates)
Offline Mode
- Shows
offline.html
when offline and no cache available - Tries to update content once you're back online
The withPWA
HOC
Adds server-side revalidation support via SSR or middleware. Server can broadcast cache updates via SSE, which the client listens for and responds to.
export default withPWA(originalMiddleware, {
revalidationSecret: process.env.REVALIDATION_SECRET!,
sseEndpoint: "/api/pwa/cache-events",
webhookPath: "/api/pwa/revalidate",
});
Usage Examples
Update Cache After Posting Data
import { updateSWCache } from "next-pwa-pack";
const handleCreatePost = async (data) => {
await createPost(data);
updateSWCache(["/blog", "/dashboard"]);
};
Revalidate on Server
import { revalidatePWA } from "../actions";
await createPost(data);
await revalidatePWA(["/my-page"]);
Clear Cache on Logout
import { clearAllCache } from "next-pwa-pack";
const handleLogout = async () => {
await logout();
await clearAllCache();
router.push("/login");
};
All Exported Client Actions
import {
clearAllCache,
reloadServiceWorker,
updatePageCache,
unregisterServiceWorkerAndClearCache,
updateSWCache,
disablePWACache,
enablePWACache,
clearStaticCache,
usePWAStatus,
} from "next-pwa-pack";
await clearAllCache();
await reloadServiceWorker();
await updatePageCache("/about");
await unregisterServiceWorkerAndClearCache();
await clearStaticCache();
updateSWCache(["/page1", "/page2"]);
disablePWACache();
enablePWACache();
const { online, hasUpdate, swInstalled, update } = usePWAStatus();
External Revalidation API Route
Sometimes you want to nuke cache from outside — like after a CMS update. Create an API route:
// app/api/webhook/revalidate/route.ts
...
Then hit it like this:
POST https://your-app/api/webhook/revalidate
body:
{
"tags": ["faq"],
"secret": "1234567890",
"urls": ["/ru/question-answer"]
}
You’ll get a nice JSON with success stats.
Debugging Tips
Cache Verification
- Open DevTools → Application → Service Workers
- Check registration
- Look in Cache Storage →
html-cache-v2
Test Offline Mode
- Enable
devMode
- Kill your internet (DevTools → Network → Offline)
- Refresh page — you should see
offline.html
Logs
Look out for these:
[PWA] Service Worker registered
[SW] Cached: /about
[SW] Revalidated and updated cache for: /blog
Limitations and Gotchas
Security
- HTTPS only in prod
- Only GET requests are cached
- Sensitive data = keep it out of cache
Performance
- Doesn’t slow things down
- Speeds up repeat visits
Config Stuff
- TTL is hardcoded in
sw.js
- Exclude URLs via
CACHE_EXCLUDE
- You’ll need to hand-edit
manifest.json
PWAProvider
Props
export default function PWAProvider({
children,
swPath,
devMode = false,
serverRevalidation = { enabled: true, sseEndpoint: "/api/pwa/cache-events" },
}: PWAProviderProps) {
Final Word
next-pwa-pack
is a fast, zero-hassle way to turn your Next.js app into a proper PWA. It takes care of all the annoying stuff and gives you clean APIs to control your service worker and cache.
Coming soon:
- TTL config via a proper config file
- Push notifications
- Smarter URL-based cache rules
- Cache performance metrics
Built for Next.js 15, but should work fine with App Router in v13+.
Questions? Bugs? Weird use cases? Ping me!
Top comments (0)