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 returns — navigator.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.