Skip to content

[INFRA] Install and Configure Serwist PWA in apps/dash #170

@kilip

Description

@kilip

Summary

Install and configure [Serwist](https://serwist.pages.dev/) as the PWA/service worker solution for apps/dash (Next.js 16 App Router) using the Turbopack integration (@serwist/turbopack). This enables installability, offline shell caching, and PWA manifest integration.

Note: Next.js 16 uses Turbopack by default. Use @serwist/turbopack — NOT @serwist/next (which is webpack-only).


Background

Domus is a mobile-first PWA targeting parishioners in East Kalimantan, many of whom are on low-bandwidth connections. Serwist is the chosen PWA library (see docs/tdd.md — Tech Stack). It needs to be properly installed and wired into the Next.js 16 App Router + Turbopack setup.


Acceptance Criteria

  • @serwist/turbopack, esbuild, and serwist installed as dev dependencies in apps/dash
  • next.config.ts wrapped with withSerwist from @serwist/turbopack
  • Serwist Route Handler created at app/serwist/[path]/route.ts
  • Service worker file created at app/sw.ts
  • Offline fallback page created at app/~offline/page.tsx
  • SerwistProvider added to app/layout.tsx
  • Metadata and viewport exported correctly from app/layout.tsx
  • app/manifest.json present and valid (name, short_name, theme_color #1B3A6B, icons array)
  • Favicon and PWA icons placed under apps/dash/public/icons/ (see design assets)
  • App is installable on Android Chrome and iOS Safari
  • Lighthouse PWA audit passes (installable + basic PWA criteria)
  • No TypeScript errors (pnpm typecheck passes)
  • No build errors (pnpm build passes)

Implementation Guide

1. Install dependencies

pnpm --filter @domus/dash add -D @serwist/turbopack esbuild serwist

Do NOT install @serwist/next — that is the webpack variant.


2. Update next.config.ts

// apps/dash/next.config.ts
import { withSerwist } from "@serwist/turbopack";

const nextConfig = {
  // your existing Next.js config here
};

export default withSerwist(nextConfig);

3. Create the Serwist Route Handler

This is how @serwist/turbopack serves the compiled service worker — via a Next.js Route Handler instead of a static file.

// apps/dash/app/serwist/[path]/route.ts
import { spawnSync } from "node:child_process";
import { createSerwistRoute } from "@serwist/turbopack";

const revision =
  spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf-8" }).stdout ??
  crypto.randomUUID();

export const {
  dynamic,
  dynamicParams,
  revalidate,
  generateStaticParams,
  GET,
} = createSerwistRoute({
  additionalPrecacheEntries: [{ url: "/~offline", revision }],
  swSrc: "app/sw.ts",
  useNativeEsbuild: true,
});

4. Create the service worker

// apps/dash/app/sw.ts
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { defaultCache } from "@serwist/turbopack/worker";
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist";
import { Serwist } from "serwist";

declare global {
  interface WorkerGlobalScope extends SerwistGlobalConfig {
    __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
  }
}

declare const self: ServiceWorkerGlobalScope;

const serwist = new Serwist({
  precacheEntries: self.__SW_MANIFEST,
  skipWaiting: true,
  clientsClaim: true,
  navigationPreload: true,
  runtimeCaching: defaultCache,
  fallbacks: {
    entries: [
      {
        url: "/~offline",
        matcher({ request }) {
          return request.destination === "document";
        },
      },
    ],
  },
});

serwist.addEventListeners();

5. Create the offline fallback page

// apps/dash/app/~offline/page.tsx
export default function OfflinePage() {
  return (
    <div>
      <h1>Tidak ada koneksi internet</h1>
      <p>Harap periksa koneksi Anda dan coba lagi.</p>
    </div>
  );
}

6. Update app/layout.tsx

Create a separate client file for SerwistProvider (required because it's a Client Component):

// apps/dash/app/serwist.ts
"use client";
export { SerwistProvider } from "@serwist/turbopack/react";

Then update app/layout.tsx:

// apps/dash/app/layout.tsx
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { SerwistProvider } from "./serwist";

export const metadata: Metadata = {
  applicationName: "Domus",
  title: {
    default: "Domus — Paroki Kristus Raja Barong Tongkok",
    template: "%s — Domus",
  },
  description: "Aplikasi administrasi digital Paroki Kristus Raja Barong Tongkok",
  appleWebApp: {
    capable: true,
    statusBarStyle: "default",
    title: "Domus",
  },
  formatDetection: {
    telephone: false,
  },
};

export const viewport: Viewport = {
  themeColor: "#1B3A6B",
};

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="id">
      <head>
        <link rel="icon" type="image/x-icon" href="/icons/favicon.ico" />
        <link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
        <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
        <link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
      </head>
      <body>
        <SerwistProvider swUrl="/serwist/sw.js">{children}</SerwistProvider>
      </body>
    </html>
  );
}

7. Create app/manifest.json

Place in the App Router root so Next.js serves it automatically at /manifest.json:

{
  "name": "Domus — Paroki Kristus Raja Barong Tongkok",
  "short_name": "Domus",
  "description": "Aplikasi administrasi digital Paroki Kristus Raja Barong Tongkok",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#1B3A6B",
  "theme_color": "#1B3A6B",
  "orientation": "portrait",
  "icons": [
    {
      "src": "/icons/favicon-16x16.png",
      "sizes": "16x16",
      "type": "image/png"
    },
    {
      "src": "/icons/favicon-32x32.png",
      "sizes": "32x32",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-maskable-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable"
    },
    {
      "src": "/icons/icon-maskable-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

8. Add PWA icons to public/

apps/dash/public/
└── icons/
    ├── favicon.ico
    ├── favicon-16x16.png
    ├── favicon-32x32.png
    ├── favicon-48x48.png
    ├── icon-192x192.png
    ├── icon-512x512.png
    ├── icon-maskable-192x192.png
    ├── icon-maskable-512x512.png
    └── apple-touch-icon.png

All files are available in domus-favicon.zip from the design assets.


9. Update .gitignore

No generated service worker file to ignore — @serwist/turbopack serves the SW via Route Handler, not a static file in public/. Nothing to add here.


File Structure Summary

apps/dash/
├── app/
│   ├── manifest.json             ← NEW: PWA manifest
│   ├── serwist.ts                ← NEW: SerwistProvider re-export (client)
│   ├── sw.ts                     ← NEW: Service worker source
│   ├── layout.tsx                ← UPDATED: metadata, viewport, SerwistProvider
│   ├── serwist/
│   │   └── [path]/
│   │       └── route.ts          ← NEW: Serwist Route Handler
│   └── ~offline/
│       └── page.tsx              ← NEW: Offline fallback page
├── public/
│   └── icons/                    ← NEW: All favicon & PWA icon files
└── next.config.ts                ← UPDATED: wrapped with withSerwist

Verification Steps

  1. pnpm build — no errors
  2. pnpm start
  3. Open Chrome DevTools → Application → Service Workers — confirm SW is registered at /serwist/sw.js
  4. Application → Manifest — confirm manifest is valid and icons load
  5. Run Lighthouse audit → PWA section should pass installable criteria
  6. Test "Add to Home Screen" on Android Chrome and iOS Safari
  7. Disable network in DevTools → navigate to any page → /~offline fallback should appear

References


Labels: infra phase-2 status: todo
Milestone: Phase 2 — Auth & Shell

Metadata

Metadata

Assignees

Type

Projects

Status

Done

Relationships

None yet

Development

No branches or pull requests

Issue actions