Skip to main content
Back to Blog
Tutorials
3 min read
January 10, 2025

How to Implement Service Workers for Offline Support in Next.js

Add offline support to your Next.js app with service workers, cache strategies, background sync, and an offline fallback page.

Ryel Banfield

Founder & Lead Developer

Service workers let your app work offline and load faster with smart caching. Here is how to implement them properly.

Register the Service Worker

// lib/register-sw.ts
export function registerServiceWorker() {
  if (typeof window === "undefined" || !("serviceWorker" in navigator)) return;

  window.addEventListener("load", async () => {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js", {
        scope: "/",
      });

      registration.addEventListener("updatefound", () => {
        const newWorker = registration.installing;
        if (!newWorker) return;

        newWorker.addEventListener("statechange", () => {
          if (
            newWorker.state === "installed" &&
            navigator.serviceWorker.controller
          ) {
            // New version ready β€” notify user
            dispatchEvent(new CustomEvent("sw-update-available"));
          }
        });
      });
    } catch (error) {
      console.error("SW registration failed:", error);
    }
  });
}

Service Worker With Cache Strategies

// public/sw.js
const CACHE_NAME = "app-cache-v1";
const STATIC_ASSETS = [
  "/offline",
  "/favicon.ico",
];

// Install: pre-cache static assets
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
  );
  self.skipWaiting();
});

// Activate: clean old caches
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((key) => caches.delete(key)),
      ),
    ),
  );
  self.clients.claim();
});

// Fetch: apply strategy per request type
self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Skip non-GET and external requests
  if (request.method !== "GET" || url.origin !== self.location.origin) return;

  // API calls: network first, no cache fallback
  if (url.pathname.startsWith("/api/")) {
    event.respondWith(networkFirst(request));
    return;
  }

  // Static assets: cache first
  if (
    url.pathname.match(/\.(js|css|png|jpg|jpeg|svg|woff2?)$/) ||
    url.pathname.startsWith("/_next/static/")
  ) {
    event.respondWith(cacheFirst(request));
    return;
  }

  // Pages: stale-while-revalidate
  event.respondWith(staleWhileRevalidate(request));
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    return new Response("Offline", { status: 503 });
  }
}

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    const cached = await caches.match(request);
    return cached ?? new Response(JSON.stringify({ error: "Offline" }), {
      status: 503,
      headers: { "Content-Type": "application/json" },
    });
  }
}

async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);

  const fetchPromise = fetch(request)
    .then((response) => {
      if (response.ok) {
        cache.put(request, response.clone());
      }
      return response;
    })
    .catch(() => null);

  if (cached) {
    // Return cached, update in background
    fetchPromise; // fire and forget
    return cached;
  }

  // No cache: wait for network
  const response = await fetchPromise;
  if (response) return response;

  // Offline fallback page
  return caches.match("/offline") ?? new Response("Offline", { status: 503 });
}

Background Sync for Forms

// In sw.js β€” add background sync for form submissions
self.addEventListener("sync", (event) => {
  if (event.tag === "form-sync") {
    event.waitUntil(syncPendingForms());
  }
});

async function syncPendingForms() {
  const db = await openDB();
  const tx = db.transaction("pending-forms", "readonly");
  const store = tx.objectStore("pending-forms");
  const entries = await store.getAll();

  for (const entry of entries) {
    try {
      const response = await fetch(entry.url, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(entry.data),
      });

      if (response.ok) {
        const deleteTx = db.transaction("pending-forms", "readwrite");
        deleteTx.objectStore("pending-forms").delete(entry.id);
      }
    } catch {
      // Will retry on next sync
    }
  }
}

Offline Hook

"use client";

import { useSyncExternalStore } from "react";

function subscribe(callback: () => void) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

function getSnapshot() {
  return navigator.onLine;
}

function getServerSnapshot() {
  return true;
}

export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

Offline Banner Component

"use client";

import { useOnlineStatus } from "@/hooks/use-online-status";

export function OfflineBanner() {
  const isOnline = useOnlineStatus();

  if (isOnline) return null;

  return (
    <div
      role="alert"
      className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 bg-amber-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm font-medium"
    >
      You are offline. Some features may be unavailable.
    </div>
  );
}

Offline Fallback Page

// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div className="flex min-h-screen items-center justify-center p-4">
      <div className="text-center max-w-md">
        <h1 className="text-2xl font-bold mb-4">You are offline</h1>
        <p className="text-muted-foreground mb-6">
          Check your internet connection and try again. Previously visited pages
          may still be available.
        </p>
        <button
          onClick={() => window.location.reload()}
          className="px-4 py-2 bg-primary text-primary-foreground rounded-lg"
        >
          Try Again
        </button>
      </div>
    </div>
  );
}

Need Offline-Capable Apps?

We build progressive web apps that work reliably offline. Contact us to discuss your project.

service workersofflinePWAcachingNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles