Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

How to Add a PWA to Your Next.js App (2026): Manifest, Service Worker, and Offline Setup

Why turn your Next.js app into a PWA in 2026?

If you have a Next.js app that people open in a browser, a Next.js PWA is probably the cheapest distribution upgrade you can make: one deploy gives you a home-screen icon, a standalone window with no browser chrome, offline-capable pages, and (where supported) push notifications. No app-store review, no native codebase, no per-user download.

But most tutorials on this topic are quietly rotting. The dominant search results still tell you to install next-pwa — a package that has not been maintained since 2022 and does not work with modern Next.js builds (it is a webpack-only plugin; Next.js 16 builds with Turbopack by default). This guide does it the way that actually works today: Next.js’s native app/manifest.ts for the web app manifest, and Serwist — the maintained Workbox-based successor — for the service worker. Facts below are verified against the Next.js docs and Serwist’s documentation as of October 2026.

What a PWA actually gives you (and what it does not)

A Progressive Web App is still just your website — plus two browser features: a web app manifest that describes the app (name, icons, colours, start URL) and a service worker that runs in the background and intercepts network requests. With those two pieces you get:

  • Installability: an “Install app” prompt in Chrome/Edge, or “Add to Home Screen” on iOS, opening in a standalone window.
  • Offline support: precached pages and assets still load with no connection; runtime caching makes repeat visits instant.
  • Push notifications: via the Web Push API with a service worker — iOS supports this for installed PWAs since iOS 16.4.

What it is not: a substitute for a native app where you need background execution, Bluetooth, NFC, or platform-specific APIs. For dashboards, blogs, docs, admin panels, and content apps, it is usually enough.

Prerequisites

  • A Next.js 15 or 16 project using the App Router (this guide uses App Router conventions; the manifest file lives at app/manifest.ts).
  • Node.js 20.9+ (Next.js 16’s minimum).
  • HTTPS in production — service workers only run on secure contexts. On localhost they work over plain HTTP, so local development is fine.

Step 1: Create the web app manifest with app/manifest.ts

Next.js ships native manifest support through the Metadata API — you return a typed object and Next.js serves it as /manifest.webmanifest. No library needed, no <link rel="manifest"> tag to manage. The fields that matter for installability are name, short_name, start_url, display, and icons (covered in the next step).

// app/manifest.ts
import type { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
  return {
    id: '/',
    name: 'ShipIt Notes — a fast PWA for dev notes',
    short_name: 'ShipIt Notes',
    description: 'Offline-capable notes app built with Next.js',
    start_url: '/',
    scope: '/',
    display: 'standalone',
    orientation: 'portrait',
    background_color: '#0a0a0a',
    theme_color: '#BB1919',
    icons: [
      {
        src: '/icons/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icons/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
        purpose: 'maskable',
      },
    ],
  }
}

The purpose: 'maskable' entry is worth including: Android devices crop icons to their own shapes, and maskable icons survive that without your logo being decapitated. Keep start_url pointing at a route that loads fast and renders something meaningful offline (the homepage or a dedicated app shell).

Step 2: Generate the icons

Chrome’s install prompt needs at least one icon at 192px and one at 512px. In practice, generate the full set — icon-192x192.png, icon-512x512.png, plus an apple-touch-icon (180×180) for iOS, which does not use the manifest the same way Chrome does.

The fastest path: start from a 1024×1024 source and use pwa-asset-generator, which outputs every size plus the maskable padding calculations. One community write-up verified version 8.1.5 generating the whole set from a single public/favicon.ico. Whatever you use, serve the icons from public/ and confirm each path in the manifest returns a 200 — a broken icon URL is one of the most common reasons the install prompt never appears.

Step 3: Add metadata for the install experience

The manifest covers Chrome and Edge, but iOS Safari still reads HTML meta tags for home-screen icons and the theme colour. Add them to your root layout via the Next.js Metadata API:

// app/layout.tsx
import type { Metadata, Viewport } from 'next'

export const metadata: Metadata = {
  title: 'ShipIt Notes',
  description: 'Offline-capable notes app built with Next.js',
  appleWebApp: {
    capable: true,
    statusBarStyle: 'black-translucent',
    title: 'ShipIt Notes',
  },
}

export const viewport: Viewport = {
  themeColor: '#BB1919',
  viewportFit: 'cover',
}

Then place an apple-touch-icon at app/apple-icon.png (or app/apple-touch-icon.png depending on your Next.js version’s icon conventions) so iOS picks it up automatically. For reference, the official Next.js PWA guide demonstrates this exact metadata pattern.

Step 4: Add the service worker with Serwist

This is where stale tutorials hurt you most. Do not install next-pwa. It is archived/unmaintained, webpack-only, and incompatible with current Next.js builds — several real-world repos documented their service worker silently not being generated at all because the plugin never ran under Turbopack. The maintained replacement is Serwist, a TypeScript-first fork of Workbox’s ideas designed for the Next.js App Router.

Install the packages:

npm install -D @serwist/next serwist

One version note that matters in 2026: @serwist/next is the build integration, serwist is the core library you import inside the worker. If your project builds with Turbopack (the Next.js 16 default), Serwist offers a separate @serwist/turbopack path — check the Serwist docs for your bundler, because the webpack-oriented config below assumes a webpack build.

Wrap your Next.js config:

// next.config.ts
import withSerwistInit from '@serwist/next'
import type { NextConfig } from 'next'

const withSerwist = withSerwistInit({
  swSrc: 'app/sw.ts',      // your service worker source
  swDest: 'public/sw.js',  // compiled output (must be in public/)
  // Never let a service worker cache your dev server:
  disable: process.env.NODE_ENV === 'development',
})

const nextConfig: NextConfig = {
  // your existing config
}

export default withSerwist(nextConfig)

Now write the worker itself. defaultCache from @serwist/next/worker gives you sensible defaults (precaching of your build assets, sane runtime caching for images and fonts), and the fallbacks entry serves an offline page when a document request fails:

// app/sw.ts
/// <reference lib="webworker" />
import { defaultCache } from '@serwist/next/worker'
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
import { Serwist } from 'serwist'

declare global {
  interface WorkerGlobalScope extends SerwistGlobalConfig {
    __SW_MANIFEST: (PrecacheEntry | string)[] | undefined
  }
}

declare const self: ServiceWorkerGlobalScope

const serwist = new Serwist({
  precacheEntries: self.__SW_MANIFEST,
  skipWaiting: true,
  clientsClaim: true,
  navigationPreload: true,
  runtimeCaching: defaultCache,
  fallbacks: {
    entries: [
      {
        url: '/offline',
        matcher({ request }) {
          return request.destination === 'document'
        },
      },
    ],
  },
})

serwist.addEventListeners()

A few lines here do heavy lifting: skipWaiting: true plus clientsClaim: true mean a newly deployed worker takes over immediately instead of waiting for all tabs to close — your users get the fresh version on their next visit, not their next browser restart. navigationPreload: true lets the browser start fetching the page in parallel with worker startup. And disable in the config is non-negotiable: a service worker registered against next dev will serve you stale pages during hot reload and make you question your sanity.

Add the generated files to .gitignore — they are build artefacts, not source:

# Serwist generated files
public/sw*
public/swe-worker*

And add the webworker types to tsconfig.json so self and the Serwist globals type-check:

{
  "compilerOptions": {
    "types": ["@serwist/next/typings"],
    "lib": ["webworker"]
  }
}

Step 5: Build the offline fallback page

The /offline route in the worker above needs to exist. Make it a static page with no data fetching so it always renders, even with the network cable pulled:

// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <main>
      <h1>You are offline</h1>
      <p>
        This page needs a connection, but everything you cached
        while online is still available.
      </p>
    </main>
  )
}

If you want the fallback in the precache manifest rather than relying on runtime navigation caching, pass additionalPrecacheEntries: [{ url: '/offline', revision: 'your-revision' }] to withSerwistInit.

Step 6: Choose caching strategies per resource type

Default caching is fine until it is not. The classic failure: caching an authenticated API response and serving one user’s dashboard to another. Serwist supports the standard Workbox-style strategies — pick deliberately:

  • Cache First — images, fonts, long-lived static assets. Fastest repeat loads; accept that content can be stale until expiry.
  • Stale While Revalidate — JS/CSS bundles, public pages. Instant response from cache, fresh copy fetched in the background for next time. This is the right default for most of a marketing/content site.
  • Network First — your API reads. Fresh when online, cached copy only as a fallback when offline.
  • Network Only — authentication endpoints, mutations, payments, anything session-dependent or cookie-sensitive. This must be explicit: never let an API route fall into a generic same-origin page cache. One repo’s post-mortem found a greedy route matcher accidentally caching /api/* responses as pages — pin API traffic to NetworkOnly by matching the parsed pathname.

The rule of thumb: cache what is identical for every user, fetch what depends on who is asking.

Step 7: Add the install prompt (Chrome/Edge)

Chromium fires a beforeinstallprompt event when your site meets the installability criteria: HTTPS, a valid manifest with identity/start URL/display metadata, suitable icons, and a registered service worker. Capture the event and show your own install button instead of waiting for the browser’s menu:

// components/install-button.tsx
'use client'

import { useEffect, useState } from 'react'

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

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault() // stop Chrome's automatic mini-infobar
      setDeferredPrompt(e)
    }
    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>
  )
}

Hide the button (or the whole banner) once installed: window.matchMedia('(display-mode: standalone)').matches tells you the app is already running as an installed PWA.

Step 8: Handle iOS separately

iOS does not fire beforeinstallprompt — there is no programmatic install prompt on Safari. Users install through Share → “Add to Home Screen”, so your job is to show them how:

// components/ios-install-hint.tsx
'use client'

import { useEffect, useState } from 'react'

export default function IosInstallHint() {
  const [show, setShow] = useState(false)

  useEffect(() => {
    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
    const isStandalone = window.matchMedia('(display-mode: standalone)').matches
    setShow(isIOS && !isStandalone)
  }, [])

  if (!show) return null

  return (
    <p>
      To install this app on your iPhone: tap the Share button,
      then choose "Add to Home Screen".
    </p>
  )
}

Three iOS-specific gotchas to bake in: icons — iOS uses the apple-touch-icon, not your manifest icons, for the home-screen tile, so ship one. Push — web push on iOS only works for installed, standalone PWAs on iOS 16.4+, and subscriptions can disappear silently; re-check pushManager.getSubscription() on startup. Debugging — Safari Web Inspector cannot inspect installed home-screen PWAs, so add an in-app diagnostics path (a hidden debug route or an embedded console like Eruda) if you ever need to troubleshoot in the field.

Step 9: Test it properly

  1. Build, don’t dev: run npm run build && npm run start. Your service worker is disabled in dev (by the disable flag), so testing against next dev tells you nothing.
  2. DevTools → Application: check that /manifest.webmanifest parses with no errors, that icons load, and that /sw.js is registered and activated. The Application panel also has a “Bypass for network” checkbox — keep it in mind when the worker keeps serving you a cached build during testing.
  3. Offline test: in the Network tab, toggle “Offline” and reload. Your precached pages and the /offline fallback should render.
  4. HTTPS locally: some features (push in particular) need a real secure context; next dev --experimental-https covers local testing.
  5. Audit: note that Chrome removed the dedicated PWA category from Lighthouse in version 126 — there is no PWA “score” anymore. Rely on the Application panel checks and the manifest/installability validators instead of hunting for the old category.

What not to do

  • Do not install next-pwa or @ducanh2912/next-pwa for new projects. Both are effectively abandoned; the maintainers’ own ecosystem moved to Serwist. Stale blog posts are the only thing keeping it alive.
  • Do not cache authenticated traffic by accident. Route matchers are greedy; keep /api/* and session-dependent navigations on NetworkOnly.
  • Do not register the worker in development. disable: process.env.NODE_ENV === 'development' exists for a reason — stale caches during dev are a debugging nightmare.
  • Do not expect beforeinstallprompt on iOS. Build the manual iOS hint; it is the only install UX Apple gives you.

Putting it together

That is the complete loop: a native app/manifest.ts plus icons makes your Next.js app installable; a Serwist worker with precaching, runtime caching, and an offline fallback makes it survive bad networks; a small install-button component plus an iOS hint makes people actually install it. The whole thing is maybe 60 lines of config and one small worker file — and it is the closest thing to free distribution a web app gets. Written October 2026; Serwist 9.x and Next.js 16 verified at time of writing.

Further Reading & References

Leave a Comment