
Building Offline Apps with Next.js and Serwist
LocallyTools has been live for a while now. Although the product uses browser capabilities to process data locally, users kept questioning its credibility—"Are you sure my files won't be uploaded?" "How can I prove that data won't leak?"
These concerns pushed me to implement complete offline functionality. The idea was simple: if the app works perfectly when disconnected from the internet, that's definitive proof that data never leaves the user's device.
During implementation, I discovered the benefits went far beyond my expectations:
Building User Trust: When the product works offline, it directly proves data security. Users no longer need to "trust" my promises—they can verify it themselves.
Reducing Server Load: With resources cached locally, most requests hit the local cache instead of the server, dramatically reducing server pressure and bandwidth costs.
Avoiding SEO Competition: The toolbox market is fiercely competitive, with everyone fighting for SEO rankings. But by making LocallyTools a PWA, users can save it directly to their desktop and use it without searching every time. This sidesteps the intense SEO competition while significantly increasing user retention.
Of course, it was more complex to implement than I initially thought. Here's what I learned about implementing offline functionality in Next.js apps.
Tech Stack
I'm using:
Next.js 15 (app router)
Pnpm
TypeScript
TailwindCSS 4.0
@serwist/next
I tried Workbox first but found Serwist easier to configure, especially with Next.js. It's also one of the methods recommended in the Next.js official documentation.
Configuring Serwist
Installation is straightforward:
pnpm add @serwist/next serwist
Configure Serwist in next.config.ts
:
// next.config.ts
import withSerwistInit from '@serwist/next';
import { execSync } from 'child_process';
// Use git commit hash as cache version
const revision = execSync('git rev-parse HEAD', { encoding: 'utf8' })
.trim()
.slice(0, 7);
const withSerwist = withSerwistInit({
cacheOnNavigation: true,
reloadOnOnline: false,
swSrc: 'app/sw.ts',
swDest: 'public/sw.js',
disable: process.env.NODE_ENV === 'development',
additionalPrecacheEntries: [
{ url: '/', revision },
// Optional
{ url: '/offline', revision },
],
});
export default withSerwist({
// Other configurations...
});
Serwist Core Implementation
This is the core of offline applications. Choose the wrong caching strategy, and users will either see stale content or nothing loads when offline.
Caching Strategy Selection
Comparison of four main caching strategies:
Strategy | How it Works | Use Case | Pros | Cons | Typical Examples |
CacheFirst | Read from cache first, request network if miss | Static assets | Extremely fast loading | May show stale content | Images, fonts, videos |
NetworkFirst | Request network first, fallback to cache | Dynamic data | Always fresh data | Network dependent | APIs, user data |
StaleWhileRevalidate | Return cache immediately, update in background | Balanced | Fast response + background update | Higher complexity | CSS, JS files |
NetworkOnly | Always request network, never use cache | Sensitive ops | Absolutely fresh data | Unavailable offline | Login, payment APIs |
Service Worker Configuration
The app/sw.ts
file is where you define caching strategies:
// app/sw.ts
import {
Serwist,
StaleWhileRevalidate,
ExpirationPlugin,
type RuntimeCaching,
type PrecacheEntry,
type SerwistGlobalConfig,
} from "serwist";
import { defaultCache } from "@serwist/next/worker";
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
// Change this attribute's name to your `injectionPoint`.
// `injectionPoint` is an InjectManifest option.
// See https://serwist.pages.dev/docs/build/configuring
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
declare const self: ServiceWorkerGlobalScope;
// Custom caching strategies
const cacheStrategies: RuntimeCaching[] = [
{
matcher: ({ request, url: { pathname }, sameOrigin }) =>
request.headers.get("RSC") === "1" &&
request.headers.get("Next-Router-Prefetch") === "1" &&
sameOrigin &&
!pathname.startsWith("/api/"),
handler: new StaleWhileRevalidate({
cacheName: "pages-rsc-prefetch",
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
maxAgeFrom: "last-used",
}),
],
}),
},
{
matcher: ({ request, url: { pathname }, sameOrigin }) =>
request.headers.get("RSC") === "1" &&
sameOrigin &&
!pathname.startsWith("/api/"),
handler: new StaleWhileRevalidate({
cacheName: "pages-rsc",
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
maxAgeFrom: "last-used",
}),
],
}),
},
{
matcher: ({ request, url: { pathname }, sameOrigin }) =>
request.headers.get("Content-Type")?.includes("text/html") &&
sameOrigin &&
!pathname.startsWith("/api/"),
handler: new StaleWhileRevalidate({
cacheName: "pages",
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
maxAgeFrom: "last-used",
}),
],
}),
},
// Other resource caching strategies
// {
// matcher: /\.(?:mp4|webm)$/i,
// handler: new StaleWhileRevalidate({
// cacheName: 'static-video-assets',
// plugins: [
// new ExpirationPlugin({
// maxEntries: 32,
// maxAgeSeconds: 7 * 24 * 60 * 60,
// maxAgeFrom: 'last-used',
// }),
// new RangeRequestsPlugin(),
// ],
// }),
// },
...
];
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: [...cacheStrategies, ...defaultCache],
// Optional
fallbacks: {
entries: [
{
url: "/offline",
matcher({ request }) {
return request.destination === "document";
},
},
],
},
});
serwist.addEventListeners();
In the above configuration, I specially handle navigation-related requests:
RSC Prefetch:
RSC: 1
+Next-Router-Prefetch: 1
, triggered when hovering over links, Next.js preloads in the background.RSC Navigation:
RSC: 1
, triggered when users actually click links for page navigation.HTML Document (Page Shell):
Content-Type: text/html
, triggered on first visit or hard refresh.
Using StaleWhileRevalidate
better ensures pages load quickly while keeping content updated.
Plugin Explanation:
ExpirationPlugin: Manages cache expiration, prevents unlimited storage growth
RangeRequestsPlugin: Supports range requests for large files (like videos, audio)
Setting up Manifest and Metadata
Setting up Manifest
Define your web app information in app/manifest.ts
. It's like your web app's ID card. Only with this manifest file will browsers consider your site "installable" and show users the "Add to Home Screen" prompt.
You can check if the setup is correct by visiting http://localhost:3000/manifest.webmanifest
locally.
// app/manifest.ts
import type { MetadataRoute } from 'next';
export default async function manifest(): Promise<MetadataRoute.Manifest> {
return {
name: 'Your App Name',
short_name: 'Your App Name',
description: 'Your App Description',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#ffffff',
icons: [
{
src: '/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
orientation: 'portrait',
};
}
Setting up Metadata
Update the metadata
object in your root layout file app/layout.tsx
and add the manifest file location:
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Your App Name',
description: 'Your App Description',
// This line is key
manifest: '/manifest.webmanifest',
};
Service Worker Debugging
Service Worker debugging is mainly done through Chrome DevTools (or Edge DevTools).
Application
-> Service Workers
This panel has options and buttons that are helpful for debugging Service Workers.

1. Top Checkboxes
Offline
: When checked, Chrome simulates a disconnected state. All network requests fail immediately, like unplugging your internet cable. Very helpful for testing PWA offline capabilities.Update on reload
: When checked, the browser forces installation and activation of the latest SW on every page refresh, avoiding "code not updating" cache issues. Recommended during development.Bypass for network
: When checked, temporarily bypasses SW completely, loading all resources directly from network. Equivalent to quickly "disabling" SW. Very helpful for troubleshooting—commonly used to determine if issues are from SW cache or the app itself.Push
,Sync
,Periodic sync
: If you use push notifications or background sync, you can manually trigger these events here for testing.
2. Action Buttons
Update
: Triggers the browser to check the server forsw.js
file updates. If there are updates, the browser starts downloading and installing the new version.Unregister
: Unregisters the current Service Workerstart/stop
: Start/stop the current Service WorkerskipWaiting
: When a new Service Worker version is in waiting state, this button appears. Clicking it forces the waiting new Service Worker to immediately enter activate state and take control of the page.
Application
-> Cache Storage
This panel is used to inspect and manage cache:

Check if
runtimeCaching
is working: ThecacheName
defined inapp/sw.ts
(e.g.,apis
,pages
) will appear here as independent entries.View cache content
: Click any cache entry to see if cached content appears correctly here.Manual cleanup
: Right-click the "Delete" button to delete any cache entry.
Network
Use this panel to confirm resource sources, mainly from the key Size
column:

Size: (ServiceWorker)
: Indicates this resource was provided from Service Worker cache without network requests. This is direct evidence that your caching strategies (especiallyCacheFirst
andStaleWhileRevalidate
) are working as expected.Size: (from memory cache)
or(from disk cache)
indicates the resource came from browser HTTP cache, not Service Worker cache.
Checking Disable cache
in the Network
panel only disables browser HTTP cache, not Service Worker cache. To disable Service Worker, use Bypass for network
in the Application panel.
Potential Pitfalls
HTTPS Limitation: Service Workers only work in HTTPS environments, but
localhost
is an exception. If you access via local IP (e.g., 192.168.31.10), SW won't register.Code Not Updating Issue: 99% of cases are due to old SW problems. Solutions:
Check if DevTools
Update on reload
is checked.Go to Service Workers panel and manually skipWaiting or Unregister old SW.
Last resort: In Application -> Storage panel, click
Clear site data
. This clears all cache, SW, IndexedDB, etc., returning your site to "factory settings".
Development Environment Recommendation: After developing PWA features, strongly recommend disabling
serwist
. Only enable it when specifically debugging PWA functionality to avoid cache hell. Nothing is more frustrating than "I clearly changed the code, why didn't the page change?!"
// next.config.ts
const withSerwist = withSerwistInit({
disable: process.env.NODE_ENV === "development",
...
});
Avoid Forced Refresh: Set
reloadOnOnline: false
. If set toreloadOnOnline: true
, when users go from offline to online, it triggerslocation.reload()
and the page gets forcibly refreshed! If users are filling out forms and your site doesn't have real-time saving, their hard work disappears instantly. Therefore, recommend settingreloadOnOnline
tofalse
.
// next.config.ts
const withSerwist = withSerwistInit({
reloadOnOnline: false,
...
});
Start Your Offline Journey
If you want to make your app work offline, start with these steps:
Evaluate your existing app: Which features can be localized?
Start with static resources: Use CacheFirst for images, CSS, JS first
Gradually add API caching: Choose strategies based on data importance
Test various network conditions: Don't forget offline testing
Offline functionality isn't about showing off—it's about respecting users' time. When users are on the subway, on planes, or dealing with unstable networks, being able to use your app smoothly is invaluable.
Want to experience what a real offline app feels like? Try LocallyTools
Related Reading
If you're interested in offline-first development, recommended reading:
What pitfalls have you encountered implementing offline functionality? Or do you have questions about caching strategies? Share your experiences in the comments—let's improve offline app best practices together.
If this article helped you, don't forget to like and share! 👍