This article is a set of notes on how to implement a PWA in Next.js (App Router).
These are the takeaways from implementing it in a meditation app.
Why Serwist (the successor to Workbox)
Serwist is a modern PWA library built on Workbox's design, and it has the following characteristics:
- Support for the Next.js App Router
- Automatic precache injection
- Flexible runtime cache configuration
- Lighter weight than Workbox
are among its notable features.
Setup Steps
I'm personally into pnpm these days, so I'm using pnpm here, but any package manager works fine.
Installing Serwist
pnpm add @serwist/next serwist
next.config.ts
Check that your next.config.ts looks like this.
// next.config.ts
import type { NextConfig } from "next";
import withSerwistInit from "@serwist/next";
const withSerwist = withSerwistInit({
swSrc: "app/sw.ts",
swDest: "public/sw.js",
disable: process.env.NODE_ENV !== "production",
});
const nextConfig: NextConfig = {
reactStrictMode: true,
turbopack: {}, // dev は Turbopack
};
export default withSerwist(nextConfig);
app/manifest.json
{
"name": "Just Meditation",
"short_name": "Meditation",
"start_url": "https://hogehoge.com",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
app/sw.ts
/// <reference lib="webworker" />
import { defaultCache } from "@serwist/next/worker";
import {
Serwist,
CacheFirst,
type PrecacheEntry,
type SerwistGlobalConfig,
type RuntimeCaching,
} from "serwist";
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
declare const self: ServiceWorkerGlobalScope;
const extraRuntimeCaching: RuntimeCaching[] = [
{
matcher: ({ request }) => request.destination === "audio",
handler: new CacheFirst({ cacheName: "audio-cache" }),
},
];
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: [...defaultCache, ...extraRuntimeCaching],
});
serwist.addEventListeners();
app/layout.tsx
You need to add this to the metadata.
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Just Meditation",
manifest: "/manifest.json",
icons: [
{ rel: "icon", url: "/icon-192.png" },
{ rel: "apple-touch-icon", url: "/icon-192.png" }
],
};
Bonus: Implementing an Install Button
I created an install button component and placed it in the header.
// components/InstallPWAButton.tsx
"use client";
import { useEffect, useState } from "react";
import { Button } from "@mui/material";
export default function InstallPWAButton() {
const [promptEvent, setPromptEvent] = useState<any>(null);
useEffect(() => {
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
setPromptEvent(e);
});
}, []);
if (!promptEvent) return null;
return (
<Button
variant="outlined"
onClick={() => {
promptEvent.prompt();
}}
>
Install App
</Button>
);
}
This only shows up in browsers where installation is available.
![How to Implement a PWA in Next.js [Vercel × Serwist]](https://cms.coiai.net/media/2025/11/スクリーンショット-2025-11-23-15.02.48.png)