Progressive Web App

A Progressive Web App (PWA) is just a web app that meets a few browser-defined criteria closely enough that the browser offers to "install" it -- an icon on the home screen or app drawer, a standalone window without browser chrome, and optionally the ability to keep working (at least partially) offline. Since a Next.js app is already a web app, turning it into a PWA doesn't require a new codebase, a new language, or an app store review -- it's the lowest-effort item on the whole "going mobile" spectrum, which is why it's worth doing even if you also build one of the native options described elsewhere in this chapter.

There are two pieces to a PWA: a manifest.json file that describes the app, and a service worker that intercepts network requests to enable offline behavior and caching. Both are plain web standards -- Next.js doesn't invent its own PWA format.

The web app manifest

The manifest tells the browser the app's name, icons, start URL, and display mode. In the App Router you can either drop a static manifest.json (or .webmanifest) file into the /app folder, or generate one dynamically with a manifest.ts file exporting a MetadataRoute.Manifest object -- see manifest.json for the full option list. The display field is the one that matters most for the "feels like an app" effect: setting it to standalone or fullscreen hides the browser's address bar and tab UI once installed.

A minimal manifest is usually all you need to make the app installable. Icons should include at least a 192x192 and a 512x512 PNG.
app,manifest.ts:
import type { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'My Next.js Application',
    short_name: 'MyApp',
    description: 'An installable app built with Next.js',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#0f172a',
    icons: [
      { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
      { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
    ],
  }
}


Service workers: Next.js has no built-in generator

Unlike the manifest, Next.js does not ship a built-in way to generate a service worker. A service worker is a browser-level background script, registered from client-side JavaScript, that can intercept fetch requests, serve cached responses when offline, and receive push notifications -- but you have to bring your own. There are two realistic ways to add one:

A hand-written service worker still has to be registered somewhere that runs in the browser. A small Client Component mounted once in the root layout is a common place to do it.
public,sw.js:
const CACHE_NAME = 'my-app-cache-v1'
const OFFLINE_URLS = ['/', '/offline']

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(OFFLINE_URLS))
  )
})

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).catch(() => caches.match('/offline'))
    })
  )
})

app,register-sw.tsx:
'use client'

import { useEffect } from 'react'

export default function RegisterServiceWorker() {
  useEffect(() => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js').catch(console.error)
    }
  }, [])

  return null
}


Install prompts

Chromium-based browsers fire a beforeinstallprompt event when a page satisfies the PWA installability criteria (a valid manifest, an HTTPS origin, and a registered service worker). Listening for that event lets you defer the browser's default mini-infobar and show your own "Install App" button instead. Safari on iOS does not support beforeinstallprompt at all -- there, installation is a manual "Add to Home Screen" step from the share sheet, which you can only prompt users toward with instructions, not trigger programmatically.


app,install-button.tsx:
'use client'

import { useEffect, useState } from 'react'

export default function InstallButton() {
  const [deferredPrompt, setDeferredPrompt] = useState<any>(null)

  useEffect(() => {
    const handler = (event: Event) => {
      event.preventDefault()
      setDeferredPrompt(event)
    }
    window.addEventListener('beforeinstallprompt', handler)
    return () => window.removeEventListener('beforeinstallprompt', handler)
  }, [])

  if (!deferredPrompt) return null

  return (
    <button
      onClick={async () => {
        deferredPrompt.prompt()
        await deferredPrompt.userChoice
        setDeferredPrompt(null)
      }}
    >
      Install App
    </button>
  )
}


Why this is the lowest-effort "mobile" option

A PWA reuses 100% of your existing Next.js codebase -- same components, same routes, same deployment. There's no separate app to keep in sync, no native build toolchain, no app store review process, and updates ship the instant you deploy (the service worker just fetches the new version). The tradeoffs are real, though: no app store listing (unless you also wrap it, see Capacitor or React Native WebView), no access to native-only APIs like Bluetooth or full background processing, and materially weaker support on iOS than on Android. For content sites, internal tools, and dashboards, that trade is usually a good one; for apps that need deep native integration, treat the PWA as a nice-to-have layered on top of one of the other approaches, not a replacement for it.