Skip to content
Sudip KC writing notes in a journal at a desk
SK.
← All articles

Development · · 7 min read

I Turned My Portfolio Into a PWA — Here's How

  • PWA
  • Next.js
  • Performance
  • Tutorial

My portfolio is heavy on purpose: scroll-driven frame animations, video backdrops, a lot of images. That made it a perfect candidate for a service worker. Here's exactly how I turned it into an installable PWA that keeps working offline.

The site runs on Next.js 16 and React 19. The home, about, work and blog pages each animate through a sequence of image frames as you scroll, and some pages have video behind them. On a good connection it's fine. On patchy mobile data, which is common enough in Nepal, every revisit re-downloaded a lot of the same bytes.

I wanted three things: repeat visits that load from the device, an offline fallback that doesn't look broken, and a clean way to ship updates without serving stale code forever. No PWA plugin, just a hand-written service worker and a few small React pieces.

Step 1: The manifest

Next.js App Router supports a typed manifest file at app/manifest.ts, which it serves as /manifest.webmanifest. Mine looks roughly like this:

import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    id: "/",
    name: SITE.name,
    short_name: SITE.shortName,
    start_url: "/",
    scope: "/",
    display: "standalone",
    display_override: ["standalone", "minimal-ui"],
    theme_color: SITE.themeColor,
    background_color: SITE.backgroundColor,
    icons: [
      ...[48, 72, 96, 128, 144, 152, 192, 384, 512].map((size) => icon(size)),
      icon(192, "maskable"),
      icon(512, "maskable"),
    ],
    shortcuts: [
      { name: "Work", url: "/work", icons: [icon(96)] },
      { name: "Blogs", url: "/blogs", icons: [icon(96)] },
      // About, Contact…
    ],
  };
}

A few choices worth explaining. start_url and scope are the bare root, so if I ever add /en or /np prefixes they stay inside the app's scope. Maskable icons are separate files with extra padding, so Android can crop them into circles without cutting off the logo. And the shortcuts give a long-press menu on the installed icon that jumps straight to Work, About, Blogs or Contact.

Step 2: A versioned service worker

The service worker lives at public/sw.js and is registered with a version in the URL:

const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "dev";
const SW_URL = `/sw.js?v=${encodeURIComponent(APP_VERSION)}`;

await navigator.serviceWorker.register(SW_URL, { scope: "/", updateViaCache: "none" });

NEXT_PUBLIC_APP_VERSION is set in next.config.ts from the Vercel commit SHA, with a fallback for other builds. Every deploy gets a new value, so the browser sees a new worker script and starts the update flow. Inside the worker, the version is read back from self.location and used to name the page cache.

I also serve /sw.js with Cache-Control: no-cache, no-store, must-revalidate through the headers() config. A cached service worker script is one of the classic ways to get stuck on an old version.

Step 3: A caching strategy per kind of request

This is the real design work. One strategy for everything is always wrong for something. The worker only handles same-origin GET requests and splits them like this:

  • /_next/static/*: cache-first.** These files are content-hashed and never change, so the cache is always right.
  • Page navigations: network-first. Fresh HTML when online; the cached copy, or the offline page, when not.
  • Images, fonts, icons, `/_next/image`: stale-while-revalidate. Serve instantly from cache, refresh in the background. This covers the scroll animation frames.
  • `.mp4` and `.webm`: cache on first play, then serve from cache with Range support.
  • Everything else: network only. API routes, POSTs and React Server Component payloads.

That last rule matters in Next.js specifically. RSC payloads vary based on router-state headers, so caching them by URL alone can hand the router the wrong data. The worker skips any request with an RSC header or _rsc query param.

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "GET") return;
  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return;
  if (url.pathname === "/sw.js" || url.pathname.startsWith("/api/")) return;
  if (request.headers.has("RSC") || url.searchParams.has("_rsc")) return;

  if (MEDIA_EXT.test(url.pathname)) return event.respondWith(media(event));
  if (request.mode === "navigate") return event.respondWith(networkFirstPage(event));
  if (url.pathname.startsWith("/_next/static/"))
    return event.respondWith(cacheFirst(request, CACHES.static, LIMITS.static));
  if (isAsset(request, url))
    return event.respondWith(staleWhileRevalidate(event, CACHES.assets, LIMITS.assets));
});

Each cache has a size limit, and a small trim() helper deletes the oldest entries when a cache grows past it. The assets cache is sized for roughly five frame sequences of about 40 frames each plus icons, so a full browse through the site fits without growing forever.

I also enable navigation preload on activate. It lets the browser start the page request in parallel with booting the worker, which removes a small delay on network-first navigations.

Step 4: Video needs Range requests

Videos were the trickiest part. Browsers request video in byte ranges, and a plain cache.match() returns the whole file, which some players reject.

On the first play, the worker passes the request through to the network as normal and, in the background, fetches and caches the full file. On later plays it reads the cached blob and slices out the requested range itself, returning a proper 206 Partial Content response with a Content-Range header. The media cache only holds a handful of files, since videos are large.

Step 5: An offline page that actually helps

The install step precaches / and /offline. When a navigation fails and there's no cached copy of that page, the worker doesn't return the offline HTML under the original URL. It redirects:

return Response.redirect(`${OFFLINE_URL}?from=${encodeURIComponent(url.pathname + url.search)}`, 302);

I learned this the hard way. Serving /offline markup at /work made Next.js hydrate one route's HTML against another route, and it showed its error screen. The redirect keeps URL and content consistent.

The offline page itself is a normal Next.js route. Its retry button reads the from param (only accepting same-site paths starting with a single /), and it also listens for the browser's online event so the page retries automatically the moment the connection comes back. Pages you've already visited keep working offline in the meantime.

Step 6: Updates the user controls

The worker deliberately doesn't call skipWaiting() during install. A new version waits until the user accepts it, because swapping code under someone mid-scroll is a good way to break things.

The registration code watches for a worker reaching the installed state while another one already controls the page. When that happens, a small toast appears: "New version available", with Update and dismiss buttons. Clicking Update posts a SKIP_WAITING message to the waiting worker, and a controllerchange listener reloads the page once the new worker takes over.

There's one shortcut. If the waiting worker's version matches the version of the page currently on screen, which happens on a fresh load right after a deploy, it activates silently. Nothing stale is showing, so there's nothing to ask about.

The app also checks for updates when the tab becomes visible again and once an hour, so a site left open in a tab doesn't stay on an old build for days.

The update state is exposed to React with useSyncExternalStore, which fits nicely: the service worker is an external source of truth, and components just subscribe.

Step 7: An install button that's honest

Chromium browsers fire beforeinstallprompt. I capture it at module load, since it can fire before the component that needs it mounts, call preventDefault() to suppress the default mini-infobar, and keep the event for later.

The install hook returns one of four states: installed, installable, ios or unavailable. The button renders nothing in the first and last cases. On iOS and iPadOS, where there's no install API, it toggles short instructions instead: tap Share, then Add to Home Screen. iPadOS reporting itself as a Mac is detected by checking for touch points.

Don't show an install button that can't install anything. Either it works, it explains, or it's not there.

Step 8: Keep development clean

Service workers and next dev don't mix well. A worker left over from testing a production build can serve stale files in development and waste an hour of your life. So in development, the registration function does the opposite: it unregisters every worker and deletes every cache with the site's sk- prefix.

What I'd tell you

Write the service worker by hand at least once. Pick a strategy per request type instead of one for everything, never cache RSC payloads, version the worker URL per deploy, and let the user decide when to update. The web.dev PWA course and the MDN Service Worker API docs cover the fundamentals well. The result for me is a portfolio that opens instantly on repeat visits, survives a dropped connection, and shows a toast instead of silently serving old code.