Web Development

Next.js Offline PWA: The No-Package Service Worker Guide

Build a fully offline-capable Next.js PWA without extra packages. This guide covers manual service worker setup, caching strategies, and manifest creation.

A

Alex Ivanov

A frontend developer passionate about performance, accessibility, and modern web APIs.

7 min read12 views

Tired of PWA packages? Let's build a Next.js offline app from scratch.

Progressive Web Apps (PWAs) are incredible. They blur the line between web and native, offering features like offline access, push notifications, and an installable app icon. When building with Next.js, it’s tempting to reach for a package like next-pwa to handle the magic for you. And for many, that's a great choice!

But what if you want more control? What if you want to understand exactly what’s happening under the hood? Or maybe you just want to avoid adding another dependency to your project. If that sounds like you, you've come to the right place. This guide will walk you through creating a fully offline-capable PWA in Next.js using nothing but the browser's native APIs. No packages, no magic, just code.

The Core Components of a Manual PWA

Building a PWA from the ground up involves two key files you'll create yourself:

  1. manifest.json: A simple JSON file that tells the browser about your web app and how it should behave when 'installed' on a user's device.
  2. sw.js (Service Worker): The real powerhouse. This is a JavaScript file that acts as a proxy between your app and the network. It's what allows you to intercept requests, manage caches, and make your app work offline.

We'll place both of these in the /public directory of our Next.js project. This is crucial because they need to be served from the root of your domain.

Step 1: Crafting the Web App Manifest

Let's start with the easy part. The manifest is metadata. It describes your app to the browser.

Create a file at public/manifest.json and add the following content. Be sure to replace the placeholder values with your own and create some simple icons.

{
  "name": "My Awesome Next.js App",
  "short_name": "AwesomeApp",
  "description": "An amazing application built with Next.js that works offline.",
  "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"
    }
  ]
}

Linking the Manifest

Now, we need to tell our Next.js app to use this manifest. In the App Router (Next.js 13+), you do this in your root app/layout.tsx file. Just add a <link> tag to the head.

Advertisement
// app/layout.tsx

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <link rel="manifest" href="/manifest.json" />
      </head>
      <body>{children}</body>
    </html>
  );
}

That's it! Your app is now technically "installable," though it won't do anything offline yet.

Step 2: Creating the Service Worker

This is where the real fun begins. The service worker is a script that your browser runs in the background, separate from your web page. It intercepts network requests and can serve cached files when the user is offline.

Create a file at public/sw.js. We'll start by defining a cache name and the essential files to cache on installation.

// public/sw.js

const CACHE_NAME = 'my-app-cache-v1';

// Add a list of assets to cache on installation
const assetsToCache = [
  '/', // Cache the root page
  '/offline.html', // An offline fallback page
  '/styles/globals.css', // Example: cache global styles
  '/icons/icon-192x192.png'
];

// 1. Install Event
self.addEventListener('install', (event) => {
  console.log('[Service Worker] Install');
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('[Service Worker] Caching all: app shell and content');
      return cache.addAll(assetsToCache);
    })
  );
});

// 2. Activate Event
self.addEventListener('activate', (event) => {
  console.log('[Service Worker] Activate');
  event.waitUntil(
    caches.keys().then((keyList) => {
      return Promise.all(
        keyList.map((key) => {
          if (key !== CACHE_NAME) {
            console.log('[Service Worker] Removing old cache', key);
            return caches.delete(key);
          }
        })
      );
    })
  );
  return self.clients.claim();
});

// 3. Fetch Event
self.addEventListener('fetch', (event) => {
  // We only want to cache GET requests
  if (event.request.method !== 'GET') {
    return;
  }

  event.respondWith(
    caches.match(event.request).then((response) => {
      // Cache hit - return response from cache
      if (response) {
        return response;
      }

      // Not in cache - fetch from network
      return fetch(event.request).catch(() => {
        // Network request failed, serve the offline page
        return caches.match('/offline.html');
      });
    })
  );
});

Understanding the Service Worker Lifecycle

  • Install: This event fires once when the service worker is first registered. We use event.waitUntil() to tell the browser to wait until our promise (opening the cache and adding our assets) is resolved before considering the installation complete.
  • Activate: This event fires after installation. It's the perfect place to clean up old, unused caches. Our code iterates through all cache keys and deletes any that don't match our current CACHE_NAME.
  • Fetch: This is the most important event for offline functionality. It fires for *every single network request* made by your app. Our code implements a simple "Cache First" strategy: check the cache for a matching response. If found, serve it. If not, try to fetch it from the network. If the network fails, serve a fallback offline page.

Don't forget to create that public/offline.html file! It can be a very simple page letting the user know they are offline.

Step 3: Registering the Service Worker in Next.js

Creating the sw.js file isn't enough. We need to tell our application to actually register and use it. Since this is a browser-only action, we'll use a client component and a useEffect hook.

A good practice is to create a dedicated component for this.

// components/PWAInstaller.tsx

'use client';

import { useEffect } from 'react';

const PWAInstaller = () => {
  useEffect(() => {
    if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker
          .register('/sw.js')
          .then((registration) => {
            console.log('SW registered: ', registration);
          })
          .catch((registrationError) => {
            console.log('SW registration failed: ', registrationError);
          });
      });
    }
  }, []);

  return null; // This component doesn't render anything.
};

export default PWAInstaller;

Now, simply include this component in your root app/layout.tsx.

// app/layout.tsx

import PWAInstaller from '@/components/PWAInstaller';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <link rel="manifest" href="/manifest.json" />
      </head>
      <body>
        {children}
        <PWAInstaller />
      </body>
    </html>
  );
}

Why the useEffect and 'use client'? Service worker registration can only happen in the browser (the client), not on the server during server-side rendering. This setup ensures the code only runs when and where it's supposed to.

Debugging Your PWA

Working with service workers can be tricky because they are heavily cached. Your best friend here is the Application tab in Chrome DevTools.

Chrome DevTools Application tab showing Service Worker status.
The Application tab is essential for PWA debugging.

Here you can:

  • See the status of your service worker.
  • Manually unregister and update it.
  • Use the "Update on reload" checkbox, which is a lifesaver during development.
  • Inspect your Cache Storage to see exactly what's being saved.
  • View your manifest and test the "Add to Home Screen" functionality.

A hard refresh (Ctrl+Shift+R or Cmd+Shift+R) will often bypass the service worker, which is useful for ensuring you're getting the latest code from the server, not the cache.

The Power Is Yours

And there you have it! You've successfully implemented an offline-first PWA in Next.js without a single external package. You created a manifest, wrote a service worker from scratch, and registered it correctly in your app.

This manual approach gives you ultimate flexibility. You can now implement more complex caching strategies (like "Network First, then Cache" for dynamic content), manage cache versions precisely, and truly understand how your app behaves in any network condition. It's a powerful skill to have in your modern web developer toolkit. Happy coding!

Tags

You May Also Like