Cerca eines

Cerca ràpida d'eines

Building Offline Apps with Next.js and Serwist
Tech Insights
07/23/2025
12 min

Building Offline Apps with Next.js and Serwist

OfflinePerformanceBest Practices

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:

StrategyHow it WorksUse CaseProsConsTypical Examples
CacheFirstRead from cache first, request network if missStatic assetsExtremely fast loadingMay show stale contentImages, fonts, videos
NetworkFirstRequest network first, fallback to cacheDynamic dataAlways fresh dataNetwork dependentAPIs, user data
StaleWhileRevalidateReturn cache immediately, update in backgroundBalancedFast response + background updateHigher complexityCSS, JS files
NetworkOnlyAlways request network, never use cacheSensitive opsAbsolutely fresh dataUnavailable offlineLogin, 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.

Application 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 for sw.js file updates. If there are updates, the browser starts downloading and installing the new version.

  • Unregister: Unregisters the current Service Worker

  • start/stop: Start/stop the current Service Worker

  • skipWaiting: 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:

Application Cache Storage
  • Check if runtimeCaching is working: The cacheName defined in app/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:

Network Status
  • Size: (ServiceWorker): Indicates this resource was provided from Service Worker cache without network requests. This is direct evidence that your caching strategies (especially CacheFirst and StaleWhileRevalidate) 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:

    1. Check if DevTools Update on reload is checked.

    2. Go to Service Workers panel and manually skipWaiting or Unregister old SW.

    3. 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 to reloadOnOnline: true, when users go from offline to online, it triggers location.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 setting reloadOnOnline to false.

// 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:

  1. Evaluate your existing app: Which features can be localized?

  2. Start with static resources: Use CacheFirst for images, CSS, JS first

  3. Gradually add API caching: Choose strategies based on data importance

  4. 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! 👍