Next.js PWA offline capability with Service Worker, no extra package
In this tutorial, we’ll add offline support to a Next.js PWA with service worker and cache without extra packages, so no next-pwa, no next-offline, no Swiss, just plain ts.
Why you may want to do it
First, because this is not a super complex task and so is a perfect opportunity to play with service workers and cache, hopefully removing a little of the overwhelming feeling that usually arises when talking about the subject.
Second, you’ll own it. Which means that once you understand a couple of key concepts, you should be able to totally customise all the tiny details you want.
But most importantly, you can reduce the package surface of your application, improving stability and mitigating cover fire.
All you need to know about service workers
Service workers scare a lot of people, so let’s try to make it quick.
A service worker can be in 4 possible states: download
, install
, waiting
, and activate
.
This is like when you have friends for dinner and you need to cook some dish: you’ll go buy some groceries (download), you’ll cook (install), and then you’ll serve the dish to your friends so they can enjoy it (activate).
Waiting, well, that’s all the time between finishing cooking and your friends getting the dish.
Now, there is a function between waiting
and activate
called skipWaiting
.
To understand skipWaiting
let’s get back to when you have your friends for dinner.
Usually, you would wait for your friends to finish one dish before serving the next one. Well, skipWaiting
is when you are rude and force your friends to eat the next dish because you’re late for a movie.
Avoid Service Worker Trauma: AKA why my service worker is not active
After you deploy a new service worker, you may see it installed and think, “I’ll reload the page and I’ll see it working”, but then it doesn’t work and you start crying.
Now, remember this:
- you need to close all the tabs with your app, even better, close the browser
- a mere refresh (
ctrl + r
) won’t do it, use a hard refreshctrl + shift + r
Assumptions and the plan
We’re going to use a Next.js setup with TypeScript and output: "export",
which means we won’t use server side or alike.
Good old static files only.
We will then cache the static files using a Cache first strategy, which means that on install we’ll save the files and on activate we’ll use the cached files instead of getting from the server.
Also, we want to use some sort of APIs for the data, which it means that while we’re caching the static files we want to be able to reach the network for all the rest.
One last bit is that we’ll scope the cache using the version number in package.json
, so that when the service worker active we’ll delete the old cache.
To get the package version and to get our app list of files we’ll create a couple of custom scripts.
Let’s start with Next.js scaffolding and boilerplates
# scaffold
npx create-next-app@latest pwa-offline
cd pwa-offline
echo "v22.14.0" > .nvmrc
We lock Node.js using nvm
so nobody gets confused on which node version we were on and we create-next-app
.
To create a static files only we’ll add output: 'export'
in next.config.js
.
// next.config.js
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'export',
distDir: 'dist',
};
export default nextConfig;
After this operation you should have something like this.
.
├── README.md
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── public
│ ├── file.svg
│ ├── globe.svg
│ ├── next.svg
│ ├── vercel.svg
│ └── window.svg
├── src
│ └── app
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.module.css
│ └── page.tsx
└── tsconfig.json
The Service Worker
So, for the service worker, as said, the plan is simple: we’ll have a service worker caching the list of files in the app, scoped by the app version.
To put everything together, we’ll use a couple of custom scripts, tsc
and webpack
to spit out service-worker.js
.
// version.ts, app-file-list.ts will be generated by a script
version.ts |
service-worker.ts |-> service-worker.js
app-file-list.ts |
Let’s start creating the scaffold.
mkdir src/sw
touch src/sw/service-worker.ts
touch src/sw/app-file-list.ts
touch src/sw/version.ts
Let’s work now on the script to generate version.ts
, app-file-list.ts
, let’s call it generate.js
mkdir scripts
touch scripts/generate.js
And in there let’s have
// generate.js
const fs = require('fs');
const path = require('path');
/*
VERSION
1 - get package
2 - export a constant VERSION
*/
const pkg = require('../package.json');
fs.writeFileSync(
'./src/sw/version.ts',
`export const VERSION = '${pkg.version}';\n`
);
/*
APP FILE LIST
1 - get file list
2 - export a constant APP_FILE_LIST
*/
const folderPath = './dist';
function getAllFilesInDir(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries
.flatMap((entry) => {
const fullPath = path.join(dir, entry.name);
return entry.isDirectory()
? getAllFilesInDir(fullPath)
: [fullPath];
});
}
fs.writeFileSync(
'./src/sw/app-file-list.ts',
`export const APP_FILE_LIST = [
"/",
${getAllFilesInDir(folderPath).map(i => "'" + i.slice(4) + "'").join(", \n")}
];`
);
If you now run node scripts/generate.js
you should have.
// version.ts
export const VERSION = '0.3.0';
// app-file-list.ts
export const APP_FILE_LIST = [
"/",
'/404.html',
'/_next/static/OQvQ0DovXF5ZWbrYv4Ncy/_buildManifest.js',
'/_next/static/OQvQ0DovXF5ZWbrYv4Ncy/_ssgManifest.js',
'/_next/static/chunks/4bd1b696-daa26928ff622cec.js',
'/_next/static/chunks/684-703ae9b085b41bfc.js',
'/_next/static/chunks/app/_not-found/page-88c6d7d182d9074a.js',
'/_next/static/chunks/app/layout-ca036fe7ce1c23fd.js',
'/_next/static/chunks/app/page-c3804bb37ebec7f8.js',
'/_next/static/chunks/app/template-eca6ee34e977c582.js',
'/_next/static/chunks/framework-f593a28cde54158e.js',
'/_next/static/chunks/main-app-5054e05586ea1599.js',
'/_next/static/chunks/main-c09f9dcdf4e52331.js',
'/_next/static/chunks/pages/_app-da15c11dea942c36.js',
'/_next/static/chunks/pages/_error-cc3f077a18ea1793.js',
'/_next/static/chunks/polyfills-42372ed130431b0a.js',
'/_next/static/chunks/webpack-8e1805b62d936603.js',
'/_next/static/css/34fc136d66718394.css',
'/_next/static/css/76338d74addccb7a.css',
'/_next/static/media/569ce4b8f30dc480-s.p.woff2',
'/_next/static/media/747892c23ea88013-s.woff2',
'/_next/static/media/8d697b304b401681-s.woff2',
'/_next/static/media/93f479601ee12b01-s.p.woff2',
'/_next/static/media/9610d9e46709d722-s.woff2',
'/_next/static/media/ba015fad6dcf6784-s.woff2',
'/favicon.ico',
'/file.svg',
'/globe.svg',
'/index.html',
'/index.txt',
'/next.svg',
'/vercel.svg',
'/window.svg'
];
Now that we have app-file-list.ts
and version.ts
we can focus on service-worker.ts
.
// service-worker.ts
import { VERSION } from "./version";
import { APP_FILE_LIST } from "./app-file-list";
const sw: ServiceWorkerGlobalScope = self as unknown as ServiceWorkerGlobalScope;
/*
SW: INSTALL
1. open the cache scoped by version number
2. save the files in the list
*/
async function onInstall() {
console.info("SW : Install : " + VERSION);
const cache = await caches.open(VERSION);
return cache.addAll(APP_FILE_LIST);
};
/*
SW: ACTIVATE
1. delete caches that are not the current one
*/
async function onActivate() {
console.info("SW : Activate : " + VERSION);
const cacheNames = await caches.keys();
return Promise.all(
cacheNames
.filter(function (cacheName) {
return cacheName !== VERSION;
})
.map(function (cacheName) {
return caches.delete(cacheName);
})
);
};
/*
SW: FETCH
This is when the service worker intercepts an http request
If the path is in the cache, we use it
else we proceed with the request
*/
async function onFetch(event: FetchEvent) {
const cache = await caches.open(VERSION);
const url = new URL(event.request.url);
const cacheResource = url.pathname;
const response = await cache.match(cacheResource);
return response || fetch(event.request);
};
sw.addEventListener('install', event => event.waitUntil(onInstall()));
sw.addEventListener('activate', event => event.waitUntil(onActivate()));
sw.addEventListener('fetch', event => event.respondWith(onFetch(event)));
To actually build this, we still need a tsconfig
and a webpack.config
.
So next is to create them.
touch tsconfig.sw.json
touch webpack.config.js
In tsconfig.sw.json
we have:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["DOM", "webworker", "ES2020"],
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"skipLibCheck": true
},
"include": ["./src/sw/service-worker.ts"]
}
Which is basically saying, get src/sw/service-worker.ts
and place the result in dist
.
As it’s a web worker in lib
we’ll add "webworker"
as well.
While in tsconfig.json
we’ll add exclude: src/sw
{
.
.
.
.
"exclude": [
"node_modules",
"src/sw"
]
}
In this way, when we build our Next.js solution the service worker won’t interfere.
In webpack.config.js
instead we have.
const path = require('path');
module.exports = {
entry: './src/sw/service-worker.ts',
module: {
rules: [
{
test: /\.ts$/,
use: {
loader: 'ts-loader',
options: {
configFile: 'tsconfig.sw.json'
}
},
exclude: /node_modules/
}
]
},
resolve: {
extensions: ['.ts', '.js']
},
output: {
filename: 'service-worker.js',
path: path.resolve(__dirname, 'dist')
}
};
Last touch, in package.json
let’s add a couple of new script
"scripts": {
"x--------------------NEXT------------------------x": "Next commands",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"x--------------------SW------------------------x": "Service Worker",
"build:sw:generate": "node scripts/generate.js",
"build:sw": "webpack",
"x--------------------VERSION------------------------x": "Bump",
"ver:patch": "npm version patch",
"ver:minor": "npm version minor",
"ver:major": "npm version major",
"x--------------------RELEASE------------------------x": "Release",
"release:patch": "npm run ver:patch && npm run build && npm run build:sw:generate && npm run build:sw",
"release:minor": "npm run ver:minor && npm run build && npm run build:sw:generate && npm run build:sw",
"release:major": "npm run ver:major && npm run build && npm run build:sw:generate && npm run build:sw"
},
With this you should be able to do a release eg npm run release:minor
which will bump up the package version in package.json
and then it will build next js, then generate app-file-list.ts
and version.ts
and lastly create service-worker.js
.
At the end of all of this you should have:
.
├── README.md
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── public
│ ├── file.svg
│ ├── globe.svg
│ ├── next.svg
│ ├── vercel.svg
│ └── window.svg
├── src
│ ├── app
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── page.module.css
│ │ └── page.tsx
│ └── sw
│ ├── app-file-list.ts
│ ├── service-worker.ts
│ └── version.ts
├── tsconfig.json
├── tsconfig.sw.json
└── webpack.config.js
Let’s import the service worker in Next.js
At this point, we should be able to have our Next.js app in our dist
folder and our service-worker.js
, but we still need to register the service worker in a way to use it.
To do that, we’ll add a template.tsx
file in the app
folder.
"use client";
import { useEffect } from "react";
export default function Template({ children }: Readonly<{children: React.ReactNode; }>) {
useEffect(() => {
if (document.domain === "localhost") {
return;
}
if (!('serviceWorker' in navigator)) {
console.error("Service workers are not supported.");
return;
}
navigator
.serviceWorker
.register("/service-worker.js")
.then((registration) => {
console.log("Service worker registration succeeded:", registration);
if (registration.installing) {
console.log("SW status: installing");
return;
}
if (registration.waiting) {
console.log("SW status: waiting");
return;
}
if (registration.active) {
console.log("SW status: active");
return;
}
})
.catch((error) => {
console.error(`Service worker registration failed: ${error}`);
});
}, []);
return (<> {children} </>);
}
Summary
Ok, we saw some basic concepts about service worker, in particular its states download, install, waiting, active and a couple of gotchas to avoid getting crazy on service worker reload.
Then we played with a basic Next.js solution adding support for the service worker. To do that, we used a script to get the app version and the files list to use within the service worker. Within the service worker, we cached the list of files scoped by version and time and on fetch if a resource had a match within the cache we use it else we got it from the net.
Lastly we stiched all together with a couple of new scripts in package.json
.
After this you should be able to investigate the Service worker and Cache docs and extend on the current example.
If you liked what you read, i would recommend playing with skipWaiting
, postMessage
and the controllerchange
event.