KindlyChecked Install Guide
⚡ One-time setup · ~30 min

Make it installable.

Turn your Next.js app into a real Progressive Web App that friends can add to their home screen, use full-screen, and run offline. Five new files, two updates, and you're shipping.

9 steps
·
Free infrastructure
·
Works on iPhone & Android

What you're adding

Five new files, plus updates to two existing ones. All paths relative to your project root.

New public/manifest.json tells browsers it's installable
New public/icon-192.png app icon, small
New public/icon-512.png app icon, large
New public/icon-maskable-512.png Android adaptive icon
New public/apple-touch-icon.png iOS home screen icon
New components/InstallPrompt.tsx smart install banner
Update app/layout.tsx manifest link + meta
Update next.config.mjs enable next-pwa
1
Dependencies

Install the PWA package

2 min

In 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.

2
Configuration

Configure caching

3 min

Replace your current next.config.mjs with this. It tells the service worker which APIs to cache and for how long.

next.config.mjs
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);
💡
Why two cache strategies? "StaleWhileRevalidate" serves cached data instantly, then refreshes in the background — perfect for product data. "CacheFirst" never re-fetches if a fresh copy exists — perfect for images and fonts that don't change.
3
Manifest

Define your app identity

2 min

Create public/manifest.json. This is what browsers read to decide your site is an installable app.

public/manifest.json
{
  "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"
    }
  ]
}
4
Icons

Make your app icons

10 min

You need four PNG files in public/. Two ways to do this — pick one.

Recommended

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×192
  • icon-512.png — 512×512
  • icon-maskable-512.png — 512×512 with ~20% padding around the design
  • apple-touch-icon.png — 180×180
Quick MVP

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
🎨
Don't let icons hold up shipping. Friends-test phase needs any icon. You can do beautiful icons after you've confirmed the app is worth keeping.
5
Layout

Wire up the meta tags

2 min

Replace app/layout.tsx with this. Adds all the meta tags iOS needs to treat your site like an app.

app/layout.tsx
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>
  );
}
6
Component

The install prompt

3 min

A polite banner that only appears after the user has scanned 3 products and won't nag them if dismissed.

components/InstallPrompt.tsx
"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
}
📋
The full component (with the styled banner UI) is in your 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 />
7
Tracking

Hook up the scan counter

1 min

For 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());
8
Verify

Test it locally

3 min
npm run build
npm run start
Important: The PWA service worker only activates in production builds. 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.

9
Ship it

Deploy and install on phones

5 min
🔒
HTTPS required. PWAs only fully install over HTTPS. Vercel, Netlify, Cloudflare Pages — all give you HTTPS free, automatically.

After deploy, here's what your friends will do:

🍎
iPhone
Safari only
  1. Open the URL in Safari
  2. Tap the Share icon (square with arrow up)
  3. Scroll down, tap "Add to Home Screen"
  4. Tap "Add"
  5. Icon appears — opens full-screen, no browser bar
🤖
Android
Chrome / Edge
  1. Open the URL in Chrome
  2. After 3 scans, install banner appears
  3. Or: three-dot menu → "Install app"
  4. Tap "Install"
  5. Icon appears in app drawer like native

After this works, here's what you have

What you get
  • 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)
What you don't get
  • 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.

Once it's installed, what's next?

Next →

The Launch Kit

Pre-launch checklist, launch messages for friends, feedback form template, and the "v1 done" criteria.

For your friends

The User Guide

A 10-minute walkthrough you can send testers — first scan, reading the score, allergens, swaps.