What you're adding
Five new files, plus updates to two existing ones. All paths relative to your project root.
Install the PWA package
2 minIn Terminal, inside your project folder:
npm install next-pwa
That's the library that auto-generates a service worker from your config. Service worker = the magic that makes offline work.
Configure caching
3 minReplace your current next.config.mjs with this. It tells the service worker which APIs to cache and for how long.
import withPWAInit from "next-pwa";
const withPWA = withPWAInit({
dest: "public",
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === "development",
runtimeCaching: [
{
// Product API: 7-day cache, works offline
urlPattern: /^https:\/\/world\.openfoodfacts\.org\/api\/.*/i,
handler: "StaleWhileRevalidate",
options: {
cacheName: "openfoodfacts-api",
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7 },
},
},
{
urlPattern: /^https:\/\/world\.openbeautyfacts\.org\/api\/.*/i,
handler: "StaleWhileRevalidate",
options: {
cacheName: "openbeautyfacts-api",
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 7 },
},
},
{
urlPattern: /^https:\/\/world\.openproductsfacts\.org\/api\/.*/i,
handler: "StaleWhileRevalidate",
options: {
cacheName: "openproductsfacts-api",
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 7 },
},
},
{
// Product images: 30-day cache
urlPattern: /^https:\/\/.*\.openfoodfacts\.org\/images\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "product-images",
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
},
},
{
// Google Fonts: 1-year cache
urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "google-fonts",
expiration: { maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 },
},
},
],
});
const nextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.openfoodfacts.org' },
{ protocol: 'https', hostname: 'static.openfoodfacts.org' },
{ protocol: 'https', hostname: 'images.openbeautyfacts.org' },
{ protocol: 'https', hostname: 'static.openbeautyfacts.org' },
{ protocol: 'https', hostname: 'images.openproductsfacts.org' },
],
},
};
export default withPWA(nextConfig);
Define your app identity
2 minCreate public/manifest.json. This is what browsers read to decide your site is an installable app.
{
"name": "KindlyChecked",
"short_name": "KindlyChecked",
"description": "Scan food, cosmetics, and cleaners. See what's really inside.",
"start_url": "/",
"display": "standalone",
"background_color": "#F3F6EC",
"theme_color": "#F3F6EC",
"orientation": "portrait",
"categories": ["health", "food", "lifestyle"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
Make your app icons
10 minYou need four PNG files in public/. Two ways to do this — pick one.
Option A — Use a generator
Upload a single 1024×1024 source image to pwabuilder.com/imageGenerator or realfavicongenerator.net. They spit out all the sizes in a zip.
Drop these files into public/ with these exact names:
icon-192.png— 192×192icon-512.png— 512×512icon-maskable-512.png— 512×512 with ~20% padding around the designapple-touch-icon.png— 180×180
Option B — Make a placeholder in 5 minutes
Open Figma or Canva. 1024×1024 square with:
- Background:
#D6F84C(your lime) - A bold black "M" or sparkle in the center
- Export at the four sizes above
Wire up the meta tags
2 minReplace app/layout.tsx with this. Adds all the meta tags iOS needs to treat your site like an app.
import type { Metadata, Viewport } from "next";
import { Fraunces, Plus_Jakarta_Sans } from "next/font/google";
import "./globals.css";
const fraunces = Fraunces({
subsets: ["latin"],
variable: "--font-fraunces",
display: "swap",
});
const jakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
variable: "--font-jakarta",
display: "swap",
});
export const metadata: Metadata = {
title: "KindlyChecked",
description: "The label, kindly checked. Scan food, cosmetics, and cleaners.",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "KindlyChecked",
startupImage: ["/apple-touch-icon.png"],
},
formatDetection: { telephone: false },
icons: {
icon: "/icon-192.png",
apple: "/apple-touch-icon.png",
},
};
export const viewport: Viewport = {
themeColor: "#F3F6EC",
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${fraunces.variable} ${jakarta.variable}`}>
<head>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="KindlyChecked" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
</head>
<body className="font-body bg-cream text-ink antialiased">{children}</body>
</html>
);
}
The install prompt
3 minA polite banner that only appears after the user has scanned 3 products and won't nag them if dismissed.
"use client";
import { useEffect, useState } from "react";
import { Download, X } from "lucide-react";
export default function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [showPrompt, setShowPrompt] = useState(false);
const [isIOS, setIsIOS] = useState(false);
useEffect(() => {
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream;
setIsIOS(iOS);
if (window.matchMedia("(display-mode: standalone)").matches) return;
if ((window.navigator as any).standalone) return;
const dismissed = localStorage.getItem("myf.installPromptDismissed");
if (dismissed && Date.now() - parseInt(dismissed) < 7 * 24 * 60 * 60 * 1000) return;
const scanCount = parseInt(localStorage.getItem("myf.scanCountForPrompt") || "0");
if (scanCount < 3) return;
if (iOS) { setShowPrompt(true); return; }
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e);
setShowPrompt(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
// ... full component code in your project
}
PWA_SETUP.md reference doc — too long to fit here readably. Copy from there.Then in app/page.tsx, import it and drop it near the root:
import InstallPrompt from "@/components/InstallPrompt";
// In your component's JSX:
<InstallPrompt />
Hook up the scan counter
1 minFor the install prompt to appear after 3 scans, add one line wherever you save a scan to history:
// After saveToHistory(...)
const count = parseInt(localStorage.getItem("myf.scanCountForPrompt") || "0");
localStorage.setItem("myf.scanCountForPrompt", (count + 1).toString());
Test it locally
3 minnpm run build
npm run start
npm run dev skips it on purpose so you don't fight stale caches while developing.Open http://localhost:3000 in Chrome. Open DevTools → Application tab.
Service Workers
You should see one registered with status "activated and is running."
Manifest
You should see all your icons, name, theme color, and start URL listed.
Then look at Chrome's URL bar on desktop — there'll be a small install icon (looks like a monitor with a down arrow). Click it to install KindlyChecked as a desktop app and confirm everything works.
Deploy and install on phones
5 minAfter deploy, here's what your friends will do:
- Open the URL in Safari
- Tap the Share icon (square with arrow up)
- Scroll down, tap "Add to Home Screen"
- Tap "Add"
- Icon appears — opens full-screen, no browser bar
- Open the URL in Chrome
- After 3 scans, install banner appears
- Or: three-dot menu → "Install app"
- Tap "Install"
- Icon appears in app drawer like native
After this works, here's what you have
- Installable on iPhone & Android
- Standalone full-screen launch
- Offline support for previously-scanned products
- Aggressive image & font caching
- Install prompt that respects users (3-scan threshold)
- Push notifications (needs backend = v2)
- App store presence (Capacitor wrap = later)
- Background sync
- iOS auto-install banner (Apple won't allow)
If something breaks
Most common issue: stale cache after deploy
The service worker can serve old cached content after you push updates. If friends report "the new version isn't showing up," tell them to either pull-to-refresh in the standalone app, or delete and re-install from home screen.
For your own testing: Chrome DevTools → Application → Service Workers → "Unregister" → refresh. Clears any cached weirdness.