coiai Logo

This article is an English translation of the original Japanese post. Read the Japanese original →

How to Implement a PWA in Next.js [Vercel × Serwist]

November 23, 2025

Categories: React · Tags: プログラミング

How to Implement a PWA in Next.js [Vercel × Serwist]

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.

https://just-meditation.com


Why Serwist (the successor to Workbox)

Serwist is a modern PWA library built on Workbox's design, and it has the following characteristics:

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.