all posts
May 16, 2026·5 min read·pwaservice-workernextjsperformance

Making zaidxshaikh installable: PWA without next-pwa

The full PWA pipeline — Next 15 manifest route, dynamic icon endpoint, hand-rolled service worker with smart caching, branded offline page, custom install pill — all without the next-pwa dependency.

zaidxshaikh is now installable on desktop and mobile. Works offline. Survives airplane mode with a branded fallback page. Three app shortcuts. Adaptive icons that respect Android masking.

I did it without next-pwa (or any other PWA wrapper). Less than 400 lines of code, no webpack hacks, full control of the caching strategy.

Why skip next-pwa

next-pwa is convenient if your perf budget is "ship a PWA today and never look at it again." For App Router projects in 2026, it's awkward — it patches the webpack config in ways that fight Next's own bundling decisions, and the cache strategy it generates is one-size-fits-all.

For a portfolio with a streaming AI chat, dynamic OG images, and per-request rate-limited APIs, the wrong caching strategy is worse than no caching. So I wrote the SW by hand.

The manifest

Next 15's metadata route handles the manifest cleanly. Single file at src/app/manifest.ts:

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "zaidxshaikh — Zaid Shaikh",
    short_name: "zaidxshaikh",
    display: "standalone",
    theme_color: "#050507",
    icons: [
      { src: "/icons/192", sizes: "192x192", purpose: "any" },
      { src: "/icons/512", sizes: "512x512", purpose: "any" },
      { src: "/icons/512-maskable", sizes: "512x512", purpose: "maskable" },
    ],
    shortcuts: [
      { name: "Ask Zaid AI", url: "/?cmd=chat" },
      { name: "Services & pricing", url: "/services" },
      { name: "Resume", url: "/resume" },
    ],
  };
}

Long-press the home-screen icon on Android and you get those three shortcuts straight into the right surfaces. Free virality.

The dynamic icon route

Static PNG files for 192/512/maskable icons would mean three image binaries committed to the repo, regenerated by hand when the brand changes. I'd rather generate them from code.

src/app/icons/[size]/route.tsx is an Edge-runtime handler that serves any whitelisted size:

const ALLOWED = new Set([192, 512]);
 
export async function GET(_req: Request, { params }) {
  const { size: sizeParam } = await params;
  const maskable = sizeParam.endsWith("-maskable");
  const num = parseInt(sizeParam.replace("-maskable", ""), 10);
  if (!ALLOWED.has(num)) return new Response("Not found", { status: 404 });
 
  const inset = maskable ? Math.round(num * 0.12) : 0;
  return new ImageResponse(
    <div style={{ width: num, height: num, background: "#050507" }}>
      <div style={{
        width: num - inset * 2,
        background: "linear-gradient(135deg, #9cf2c8, #a3e4ff 55%, #f0a3ff)",
        borderRadius: maskable ? "50%" : "18%",
        fontSize: (num - inset * 2) * 0.62,
        fontWeight: 800,
      }}>Z</div>
    </div>,
    { width: num, height: num },
  );
}

The maskable variant has a 12% safe-zone inset and rounded shape — Android's adaptive icon system will clip and reshape it without losing the "Z".

The service worker

Four routing strategies, picked carefully:

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
 
  // Always fresh: API + OG images + dynamic icons
  if (
    url.pathname.startsWith("/api/") ||
    url.pathname.endsWith("/opengraph-image") ||
    url.pathname.startsWith("/icons/")
  ) return;  // let the browser do its thing
 
  // Static assets: cache-first
  const isStatic = url.pathname.startsWith("/_next/static/") ||
    /\\.(js|css|woff2?|png|jpg|webp|avif|svg)$/.test(url.pathname);
  if (isStatic) {
    event.respondWith(
      caches.match(event.request).then((cached) =>
        cached || fetch(event.request).then((res) => {
          caches.open(RUNTIME).then((c) => c.put(event.request, res.clone()));
          return res;
        }),
      ),
    );
    return;
  }
 
  // Navigations: network-first → cached → /offline fallback
  if (event.request.mode === "navigate") {
    event.respondWith(
      fetch(event.request)
        .then((res) => {
          caches.open(RUNTIME).then((c) => c.put(event.request, res.clone()));
          return res;
        })
        .catch(() =>
          caches.match(event.request).then((hit) =>
            hit || caches.match(OFFLINE_URL),
          ),
        ),
    );
  }
});

The key insight: never cache the streaming AI endpoint, the OG image route, or the dynamic icon route. Each one needs to be fresh per request — caching them would either serve stale content (OG images change with the project) or break entirely (streaming responses don't replay).

Static _next/static assets are cache-first because Next.js hashes filenames — a new build means new filenames, never a stale hit. Navigation requests use network-first so visitors see fresh content when online but fall back to a cached snapshot when not.

The offline page

When all else fails, the SW serves /offline — a branded fallback that matches the void aesthetic:

export function OfflineClient() {
  const [online, setOnline] = useState(navigator.onLine);
  useEffect(() => {
    const on = () => setOnline(true);
    const off = () => setOnline(false);
    window.addEventListener("online", on);
    window.addEventListener("offline", off);
    return () => { /* cleanup */ };
  }, []);
  return (
    <Button onClick={() => location.reload()}>
      {online ? "back online — tap reload" : "still offline"}
    </Button>
  );
}

The button text swaps the moment the connection returnsnavigator.online events fire when the browser detects network restoration. That tiny detail makes "you're offline" feel like a real connection-aware page instead of a dead end.

The install pill

Most PWAs rely on the browser's mini-info bar for the install prompt. It's ugly, easily dismissed, and only shows up after specific engagement heuristics that aren't documented.

Better: intercept beforeinstallprompt, hide the native bar, and show a custom UI pill on your own terms:

useEffect(() => {
  const onPrompt = (e: Event) => {
    e.preventDefault();
    setDeferred(e as BeforeInstallPromptEvent);
  };
  window.addEventListener("beforeinstallprompt", onPrompt);
  return () => window.removeEventListener("beforeinstallprompt", onPrompt);
}, []);
 
const onInstall = async () => {
  if (!deferred) return;
  await deferred.prompt();
  const choice = await deferred.userChoice;
  if (choice.outcome === "dismissed") {
    localStorage.setItem("zaid-pwa-dismissed", "1");
  }
};

The dismissal is persisted, so the pill doesn't nag — once a user says "no thanks," they're not asked again. Click "Install zaidxshaikh" and the OS-native install dialog fires.

The cost

  • Bundle delta: ~3 KB First Load (mostly the install pill component, which is now idle-deferred so it doesn't count toward the critical path)
  • Cache footprint: a handful of static chunks + the offline page; rarely exceeds a few MB
  • No new npm deps

The biggest win is conceptual: a portfolio that survives a subway ride. The recruiter on a flight with spotty Wi-Fi still gets a working site.