---
title: "Progressive web apps"
description: "Making the app installable, and adding push notifications and background sync."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.rsc-kit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Progressive web apps

An installable app is a manifest plus a service worker. The
[offline guide](/guides/offline/) 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:

```ts title="src/app/manifest.ts"
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 filename** — `icon-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.

```text
[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:

```js title="src/app/sw.js"
// 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.

```text
[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.

:::caution[`.js`, not `.ts`]
It is evaluated by the browser, not built. TypeScript would not run.
:::

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

```sh
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

```tsx title="src/components/EnableNotifications.tsx"
'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](/guides/server-actions/), so it is one function and
no endpoint:

```ts title="src/server/push.ts"
'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:

```ts
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.

:::note[Not ours, deliberately]
This package ships no push helper. Everything above is the web API and
`web-push`, and the only part we could own — a place to put the listener — is
`app/sw.js`. A wrapper would be a thin layer over a stable standard, and one
more thing to keep current.
:::

## Background sync

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

```js title="src/app/sw.js"
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:

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

:::danger[Make the endpoint idempotent]
The browser decides when a sync runs and may run it more than once — a request
that succeeded on the server but whose response never arrived gets retried. If
that posts a message twice, the person sent it twice.

Give each queued item an id the client generates, and have the endpoint ignore
one it has already seen. Nothing else about background sync is hard; this is,
and no framework can do it for you because only your data model knows what
"already seen" means.
:::

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:

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

Source: https://docs.rsc-kit.dev/guides/pwa/index.mdx
