Skip to content

Progressive web apps

Making the app installable, and adding push notifications and background sync.

Updated View as Markdown

An installable app is a manifest plus a service worker. The offline guide covers the worker; this covers everything that makes the app feel like one the operating system knows about.

The manifest

A service worker makes an app survive a dead network. It does not make a browser offer to put it on a home screen — that needs a web app manifest, which is a file beside your routes:

src/app/manifest.tsts
import type { WebManifest } from '@rsc-kit/core/manifest-file'

export default {
  name: 'Orders',
  shortName: 'Orders',
  themeColor: '#0b0b0c',
  backgroundColor: '#ffffff',
  icons: ['icon-192.png', 'icon-512.png'],
} satisfies WebManifest

A file rather than a vite.config.ts key, and rather than metadata in a layout. It is not build configuration — it is one more thing the app declares about itself, so it lives where the app is, next to layout.tsx and not-found.tsx.

Not metadata either, and for a reason worth stating: metadata is resolved per route and can be computed per request. A manifest is one file for the whole app and has to exist before anything renders. Putting it in an inheritable, dynamic mechanism would invite a question — can I override it for this route? — whose only honest answer is no.

The build reads it, writes manifest.webmanifest, and links it from every page. There is no layout to edit — React hoists the <link> and the theme-color meta into <head> from wherever they are rendered, so this works on an app that already has its own root layout.

It is read at build time, before there is a module graph to evaluate it in, so it must be an object literal — not computed, not imported from elsewhere. A file that is not gets a build error rather than an app that is quietly not installable.

Icons are paths in your public/ directory, and their sizes are read from the filenameicon-192.png and icon-192x192.png both mean 192. Declaring the size in the config as well would be the same number written twice, and the one that drifts is the one nobody looks at.

Every icon is marked any maskable, because without it Android crops a square icon into a circle and takes the corners off whatever was in them.

The build tells you if it will not work

This is the failure worth guarding: a manifest with no icon is valid. It parses, it links, it sets the theme colour, and no browser ever offers to install it. Nothing is wrong and nothing works.

[rsc-kit] manifest: no icons, so no browser will offer to install this. Add a 192px and a 512px png.
[rsc-kit] manifest: Orders is installable

A warning rather than a failed build — the manifest still does its other job — but it is said out loud, because someone who wrote manifest: {…} meant installable and would otherwise find out months later.

Your own worker code

Push, notification clicks and background sync are events the generated worker does not handle, and there is nowhere to put them in a file that says do not edit. So it will import yours:

src/app/sw.jsjs
// Plain javascript. The browser evaluates this in a worker scope with no build
// step in front of it, so what you write is what runs.

self.addEventListener('push', (event) => {
  const payload = event.data ? event.data.json() : {}

  event.waitUntil(
    self.registration.showNotification(payload.title ?? 'Update', {
      body: payload.body,
      data: { url: payload.url ?? '/' },
    }),
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  event.waitUntil(self.clients.openWindow(event.notification.data.url))
})

The build copies it beside the generated worker and imports it first, so your listeners are registered before anything of ours can answer an event. It is precached like everything else — a worker whose import fails does not start, and then nothing is cached at all.

[rsc-kit] offline: 32 files precached as rsc-kit-3d9785e4, falling back to /offline, with app/sw.js

There is no option for this. The file is there or it is not.

Push notifications

Four pieces, and only one of them is ours.

1. Keys

Push needs a VAPID key pair — the public half identifies your server to the browser, the private half signs what you send.

npx web-push generate-vapid-keys

Keep the private key wherever you keep secrets. The public one reaches the browser, so it can be a plain environment variable.

2. Ask, and subscribe

src/components/EnableNotifications.tsxtsx
'use client'

export function EnableNotifications() {
  async function enable() {
    if (await Notification.requestPermission() !== 'granted') return

    const registration = await navigator.serviceWorker.ready
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
    })

    await saveSubscription(subscription.toJSON())
  }

  return <button onClick={enable}>Enable notifications</button>
}

Ask when they have a reason to say yes. A permission prompt on first load is how an app gets denied permanently — the browser remembers a refusal, and there is no second chance.

3. Store the subscription

An ordinary server action, so it is one function and no endpoint:

src/server/push.tsts
'use server'

import { client } from './client'

export const saveSubscription = client.input(subscriptionSchema).handler(
  async ({ input, ctx }) => db.pushSubscriptions.upsert(ctx.user.id, input),
)

Store it against a user, not a session. A subscription outlives the session that created it, and that is the point of one.

4. Send

From wherever the event happens — a queue, a cron, a webhook:

import webpush from 'web-push'

webpush.setVapidDetails('mailto:you@example.com', PUBLIC_KEY, PRIVATE_KEY)

for (const subscription of await db.pushSubscriptions.forUser(userId)) {
  try {
    await webpush.sendNotification(subscription, JSON.stringify({
      title: 'Your order shipped',
      url: '/orders/42',
    }))
  } catch (error) {
    // 404 and 410 mean the subscription is dead — the app was uninstalled, or
    // the browser rotated it. Delete it rather than retrying forever.
    if (error.statusCode === 404 || error.statusCode === 410) {
      await db.pushSubscriptions.remove(subscription.endpoint)
    }
  }
}

That last branch is the one people skip, and it is why push senders accumulate dead endpoints until they are mostly dead endpoints.

Background sync

For work that must happen even if the person closes the tab — a queued message, an offline edit.

src/app/sw.jsjs
self.addEventListener('sync', (event) => {
  if (event.tag === 'outbox') event.waitUntil(flushOutbox())
})

async function flushOutbox() {
  const db = await openDB()

  for (const item of await db.getAll('outbox')) {
    const response = await fetch('/api/messages', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item.body),
    })

    // Left in the queue on failure, so the browser retries the whole sync
    // later. Removed only once the server has it.
    if (response.ok) await db.delete('outbox', item.id)
  }
}

Register it from the page after queueing the work:

const registration = await navigator.serviceWorker.ready
await registration.sync.register('outbox')

Support is narrower than the rest of this page — Chromium has it, Safari and Firefox do not. Treat it as an optimisation over an ordinary retry, never as the only path:

if ('sync' in registration) await registration.sync.register('outbox')
else await flushNow()
Navigation

Type to search…

↑↓ navigate↵ selectEsc close